This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 082b596dee4 Preview JSON fields as inline badges in tables (#72353)
082b596dee4 is described below

commit 082b596dee4fba16b8f83b413f32be9d500fea44
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Sep 9 15:34:09 2026 -0400

    Preview JSON fields as inline badges in tables (#72353)
    
    * UI: Preview JSON fields as inline badges in tables
    
    A collapsed JSON cell showed an anonymous `{ ... }` over two lines, taking
    real estate in every table row while telling the reader nothing about the
    payload. Summarising the top-level entries instead lets someone scan a conf
    or an audit-log extra without opening anything, and keeps the full editor 
one
    click away for the payloads a single line cannot describe.
    
    * Fix import statement for Chakra UI components
    
    * pnpm format
---
 .../ui/public/i18n/locales/en/components.json      |   4 +
 .../ui/src/components/JsonPreviewBadges.tsx        | 120 ++++++++++++
 .../ui/src/components/RenderedJsonField.test.tsx   | 149 ++++++++++++++
 .../ui/src/components/RenderedJsonField.tsx        | 216 +++++++++++++--------
 .../src/airflow/ui/src/utils/jsonPreview.test.ts   |  69 +++++++
 .../src/airflow/ui/src/utils/jsonPreview.ts        |  77 ++++++++
 6 files changed, 549 insertions(+), 86 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 17443023152..b7dd72b81ed 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -110,6 +110,10 @@
     "taskGroup": "Task Group",
     "zoomToTask": "Zoom to selected task"
   },
+  "jsonPreview": {
+    "items_one": "1 item",
+    "items_other": "{{count}} items"
+  },
   "limitedList": "+{{count}} more",
   "limitedList.allItems": "All {{count}} items:",
   "limitedList.allTags_one": "All Tags (1)",
diff --git a/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx 
b/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx
new file mode 100644
index 00000000000..eab98a331a9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx
@@ -0,0 +1,120 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Badge, Button, HStack, Text } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { Tooltip } from "src/system-components";
+
+import type { JsonPreviewEntry } from "src/utils/jsonPreview";
+
+const MAX_VALUE_CHARS = 40;
+// Widest advance of one glyph in the 12px badge font, measured across real 
payloads. Estimating
+// high costs a little column width; estimating low wraps the preview onto a 
second line.
+const CHAR_WIDTH = 7.3;
+// A badge's own padding and inner gap, plus the gap that follows it.
+const BADGE_CHROME = 20;
+const MORE_BUTTON_WIDTH = 64;
+// The preview asks for the width its badges need so a table column widens 
rather than stacking
+// them, but never so much that it starves the columns beside it.
+const MAX_WIDTH = 380;
+
+type Props = {
+  readonly entries: Array<JsonPreviewEntry>;
+  readonly onExpand: () => void;
+};
+
+export const JsonPreviewBadges = ({ entries, onExpand }: Props) => {
+  const { t: translate } = useTranslation(["components", "common"]);
+
+  const previews = entries.map((entry) => {
+    const full =
+      entry.itemCount === undefined
+        ? entry.value.replaceAll(/\s+/gu, " ")
+        : translate("jsonPreview.items", { count: entry.itemCount });
+    const text = full.length > MAX_VALUE_CHARS ? `${full.slice(0, 
MAX_VALUE_CHARS)}…` : full;
+
+    // A summarised or shortened value only hints at what it stands for, so 
the editor still has
+    // something to add even when every entry is on screen.
+    return { ...entry, full, isElided: entry.isComplex || text !== full, text 
};
+  });
+
+  // Badges fill a single line; the first one that would not fit, and 
everything after it, is left
+  // to the expand button — which has to fit on that same line too.
+  const visibleEntries: Array<(typeof previews)[number]> = [];
+  let usedWidth = 0;
+
+  for (const [index, preview] of previews.entries()) {
+    const labelChars = preview.label === undefined ? 0 : preview.label.length 
+ 1;
+    const badgeWidth = (preview.text.length + labelChars) * CHAR_WIDTH + 
BADGE_CHROME;
+    const needsButton =
+      index < previews.length - 1 || preview.isElided || 
visibleEntries.some((shown) => shown.isElided);
+
+    if (
+      visibleEntries.length > 0 &&
+      usedWidth + badgeWidth + (needsButton ? MORE_BUTTON_WIDTH : 0) > 
MAX_WIDTH
+    ) {
+      break;
+    }
+
+    visibleEntries.push(preview);
+    usedWidth += badgeWidth;
+  }
+
+  const hiddenCount = previews.length - visibleEntries.length;
+  // Nothing left to reveal means nothing to expand.
+  const canExpand = hiddenCount > 0 || visibleEntries.some((preview) => 
preview.isElided);
+  const previewWidth = Math.min(usedWidth + (canExpand ? MORE_BUTTON_WIDTH : 
0), MAX_WIDTH);
+
+  return (
+    <HStack data-testid="json-preview-badges" gap={1} 
minW={`${previewWidth}px`}>
+      {visibleEntries.map(({ full, id, isComplex, label, text }) => (
+        <Tooltip content={full} disabled={text === full} key={id} portalled>
+          <Badge colorPalette="gray" gap={1} size="sm" variant="surface" 
whiteSpace="nowrap">
+            {label === undefined ? undefined : (
+              <Text as="span" color="fg.muted">
+                {label}:
+              </Text>
+            )}
+            <Text as="span" color={isComplex ? "fg.muted" : "fg"} 
fontFamily="mono">
+              {text}
+            </Text>
+          </Badge>
+        </Tooltip>
+      ))}
+      {canExpand ? (
+        <Button
+          // A filled chip so the control reads as a control on any row, 
header, or stripe behind
+          // it, and badge-height so it sits inline with them rather than 
growing the row.
+          colorPalette="gray"
+          data-testid="json-preview-more"
+          fontSize="xs"
+          h={5}
+          onClick={onExpand}
+          px={1.5}
+          size="xs"
+          variant="subtle"
+        >
+          {hiddenCount > 0
+            ? translate("limitedList", { count: hiddenCount })
+            : translate("common:expand.expand")}
+        </Button>
+      ) : undefined}
+    </HStack>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx 
b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx
new file mode 100644
index 00000000000..12c9701c2d6
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx
@@ -0,0 +1,149 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import commonLocale from "../../public/i18n/locales/en/common.json";
+import componentsLocale from "../../public/i18n/locales/en/components.json";
+import RenderedJsonField from "./RenderedJsonField";
+
+vi.mock("src/components/MonacoEditor", () => ({
+  default: ({ value }: { readonly value?: string }) => <div 
data-testid="monaco-editor">{value}</div>,
+}));
+
+vi.mock("src/context/colorMode", () => ({
+  useMonacoTheme: () => ({ beforeMount: vi.fn(), theme: "airflow-light" }),
+}));
+
+const expandLabel = () => i18n.t("expand.expand", { ns: "common" });
+
+describe("RenderedJsonField", () => {
+  beforeAll(() => {
+    i18n.addResourceBundle("en", "common", commonLocale, true, true);
+    i18n.addResourceBundle("en", "components", componentsLocale, true, true);
+  });
+
+  it("previews a collapsed object as badges instead of the editor", () => {
+    render(<RenderedJsonField collapsed content={{ env: "prod", score: 0.99 }} 
/>, { wrapper: Wrapper });
+
+    expect(screen.getByText("score:")).toBeInTheDocument();
+    expect(screen.getByText("0.99")).toBeInTheDocument();
+    expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument();
+  });
+
+  it("expands a summarised value to the editor", () => {
+    render(<RenderedJsonField collapsed content={{ options: { deep: true } }} 
/>, { wrapper: Wrapper });
+
+    fireEvent.click(screen.getByRole("button", { name: expandLabel() }));
+
+    expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
+    
expect(screen.queryByTestId("json-preview-badges")).not.toBeInTheDocument();
+  });
+
+  it("offers no expand control once the badges show everything", () => {
+    render(<RenderedJsonField collapsed content={{ alpha: 1, bravo: 2, 
charlie: 3, delta: 4, echo: 5 }} />, {
+      wrapper: Wrapper,
+    });
+
+    
expect(screen.getAllByText(/^(?:alpha|bravo|charlie|delta|echo):$/u)).toHaveLength(5);
+    expect(screen.queryByTestId("json-preview-more")).not.toBeInTheDocument();
+  });
+
+  it("keeps entries that overflow the line behind a count that expands the 
full JSON", () => {
+    const wide = "x".repeat(20);
+
+    render(
+      <RenderedJsonField collapsed content={{ alpha: wide, bravo: wide, 
charlie: wide, delta: wide }} />,
+      { wrapper: Wrapper },
+    );
+
+    expect(screen.getByText("alpha:")).toBeInTheDocument();
+    expect(screen.queryByText("delta:")).not.toBeInTheDocument();
+
+    fireEvent.click(screen.getByTestId("json-preview-more"));
+
+    expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
+  });
+
+  it("summarises an array of objects on one line instead of rendering the 
editor", () => {
+    render(<RenderedJsonField collapsed content={[{ id: 1 }, { id: 2 }]} />, { 
wrapper: Wrapper });
+
+    expect(screen.getByText(i18n.t("jsonPreview.items", { count: 2, ns: 
"components" }))).toBeInTheDocument();
+    expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument();
+  });
+
+  it.each([
+    ["an empty object", {}],
+    ["an empty array", []],
+  ])("renders nothing for %s", (_label, content) => {
+    const { container } = render(<RenderedJsonField collapsed 
content={content} />, { wrapper: Wrapper });
+
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it("renders the editor when the caller never collapses it", () => {
+    render(<RenderedJsonField content={{ score: 0.99 }} />, { wrapper: Wrapper 
});
+
+    expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
+    
expect(screen.queryByTestId("json-preview-badges")).not.toBeInTheDocument();
+  });
+
+  it("collapses back to badges from the expanded editor", () => {
+    render(<RenderedJsonField collapsed content={{ options: { deep: true } }} 
/>, { wrapper: Wrapper });
+
+    fireEvent.click(screen.getByTestId("json-preview-more"));
+    expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
+
+    fireEvent.click(screen.getByTestId("json-preview-collapse"));
+
+    expect(screen.getByText("options:")).toBeInTheDocument();
+    expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument();
+  });
+
+  it("offers no collapse control when the caller never collapses it", () => {
+    render(<RenderedJsonField content={{ score: 0.99 }} />, { wrapper: Wrapper 
});
+
+    
expect(screen.queryByTestId("json-preview-collapse")).not.toBeInTheDocument();
+  });
+
+  it("collapses back to badges when the caller collapses all", () => {
+    const { rerender } = render(<RenderedJsonField collapsed={false} 
content={{ score: 0.99 }} />, {
+      wrapper: Wrapper,
+    });
+
+    expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
+
+    rerender(<RenderedJsonField collapsed content={{ score: 0.99 }} />);
+
+    expect(screen.getByText("score:")).toBeInTheDocument();
+    expect(screen.queryByTestId("monaco-editor")).not.toBeInTheDocument();
+  });
+
+  it("truncates long values and keeps the full value in a tooltip", () => {
+    const long = "x".repeat(60);
+
+    render(<RenderedJsonField collapsed content={{ sql: long }} />, { wrapper: 
Wrapper });
+
+    expect(screen.getByText(`${"x".repeat(40)}…`)).toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx 
b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
index 7a56f9ee13f..55f12010cec 100644
--- a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
+++ b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
@@ -18,113 +18,157 @@
  */
 import { useCallback, useEffect, useRef, useState } from "react";
 
-import { Flex, type FlexProps } from "@chakra-ui/react";
+import { Box, Flex, type FlexProps, VStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiChevronUp } from "react-icons/fi";
 
-import { ClipboardRoot, ClipboardIconButton } from "src/system-components";
+import { ClipboardRoot, ClipboardIconButton, IconButton } from 
"src/system-components";
 
+import { JsonPreviewBadges } from "src/components/JsonPreviewBadges";
 import Editor, { type OnMount } from "src/components/MonacoEditor";
 
 import { useMonacoTheme } from "src/context/colorMode";
+import { useContainerWidth } from "src/utils";
+import { getJsonPreviewEntries } from "src/utils/jsonPreview";
 
 const MAX_HEIGHT = 300;
 const MIN_HEIGHT = 40;
-
-type EditorInstance = Parameters<OnMount>[0];
-
+const MIN_WIDTH = 200;
+// Wide enough for most payloads to stop wrapping, narrow enough to leave a 
table's other columns room.
+const MAX_WIDTH = 700;
+// Approximate advance of one glyph in the editor's 13px monospace font.
+const CHAR_WIDTH = 7.8;
+// The editor's own folding gutter, which sits left of the first character.
+const EDITOR_GUTTER = 30;
+
+// `content` is the JSON payload here, so Flex's CSS `content` prop is dropped 
from the props.
 type Props = {
   readonly collapsed?: boolean;
   readonly content: object;
   readonly enableClipboard?: boolean;
-} & FlexProps;
+} & Omit<FlexProps, "content">;
+
+type EditorInstance = Parameters<OnMount>[0];
 
-const RenderedJsonField = ({ collapsed = false, content, enableClipboard = 
true, ...rest }: Props) => {
+const RenderedJsonField = ({ collapsed, content, enableClipboard = true, 
...rest }: Props) => {
   const contentFormatted = JSON.stringify(content, undefined, 2);
+  const { t: translate } = useTranslation("common");
   const { beforeMount, theme } = useMonacoTheme();
-  const lineCount = contentFormatted.split("\n").length;
-  const expandedHeight = Math.min(Math.max(lineCount * 19 + 10, MIN_HEIGHT), 
MAX_HEIGHT);
-  const [editorHeight, setEditorHeight] = useState(collapsed ? MIN_HEIGHT : 
expandedHeight);
-  const [isReady, setIsReady] = useState(!collapsed);
-  const editorRef = useRef<EditorInstance | null>(null);
-
-  const handleMount: OnMount = useCallback(
-    (editorInstance) => {
-      editorRef.current = editorInstance;
-
-      editorInstance.onDidContentSizeChange(() => {
-        const contentHeight = editorInstance.getContentHeight();
-
-        setEditorHeight(Math.min(Math.max(contentHeight, MIN_HEIGHT), 
MAX_HEIGHT));
-      });
-
-      if (collapsed) {
-        const action = editorInstance.getAction("editor.foldAll");
-
-        if (action) {
-          void action.run().then(() => {
-            setIsReady(true);
-          });
-        } else {
-          setIsReady(true);
-        }
-      }
-    },
-    [collapsed],
-  );
-
-  // Sync fold state when the `collapsed` prop changes after mount (e.g. via 
Expand/Collapse All).
-  // The initial fold is handled in `handleMount` to avoid the 
unfolded->folded flicker.
+  const lines = contentFormatted.split("\n");
+  const expandedHeight = Math.min(Math.max(lines.length * 19 + 10, 
MIN_HEIGHT), MAX_HEIGHT);
+  // An expanded editor asks for the width its longest line needs, so a table 
column widens to fit
+  // instead of wrapping the JSON. Anything past MAX_WIDTH still wraps.
+  const longestLine = lines.reduce((longest, line) => Math.max(longest, 
line.length), 0);
+  const editorWidth = Math.min(Math.max(longestLine * CHAR_WIDTH + 
EDITOR_GUTTER, MIN_WIDTH), MAX_WIDTH);
+
+  const previewEntries = getJsonPreviewEntries(content);
+  const [isExpanded, setIsExpanded] = useState(collapsed !== true);
+  const [lastCollapsed, setLastCollapsed] = useState(collapsed);
+
+  if (collapsed !== lastCollapsed) {
+    setLastCollapsed(collapsed);
+    setIsExpanded(collapsed !== true);
+  }
+
+  // Only a field the caller can collapse gets a preview; the always-expanded 
ones stay plain editors.
+  const showBadges = collapsed !== undefined && !isExpanded;
+
+  const [editorHeight, setEditorHeight] = useState(expandedHeight);
+  const [editor, setEditor] = useState<EditorInstance | null>(null);
+  const containerRef = useRef<HTMLDivElement | null>(null);
+  const containerWidth = useContainerWidth(containerRef);
+
+  const handleMount: OnMount = useCallback((editorInstance) => {
+    setEditor(editorInstance);
+
+    editorInstance.onDidContentSizeChange(() => {
+      const contentHeight = editorInstance.getContentHeight();
+
+      setEditorHeight(Math.min(Math.max(contentHeight, MIN_HEIGHT), 
MAX_HEIGHT));
+    });
+
+    editorInstance.onDidDispose(() => {
+      setEditor(null);
+    });
+  }, []);
+
+  // A collapsed field renders no editor at all, so expanding one mounts 
Monaco into a container it
+  // has never measured. If that measurement lands before the row is laid out, 
the editor settles at
+  // a few pixels and stays there: `automaticLayout` only reacts to resizes 
that come after. Handing
+  // it the container's real size once both the editor and that size are known 
fixes the first
+  // layout, whichever of the two arrives last.
   useEffect(() => {
-    const editor = editorRef.current;
+    const container = containerRef.current;
 
-    if (editor === null || !isReady) {
+    if (editor === null || container === null) {
       return;
     }
-    const action = editor.getAction(collapsed ? "editor.foldAll" : 
"editor.unfoldAll");
-
-    if (action) {
-      void action.run();
-    }
-  }, [collapsed, isReady]);
+    editor.layout({ height: container.clientHeight, width: 
container.clientWidth });
+  }, [containerWidth, editor, editorHeight]);
+
+  // An empty payload has nothing to say in a badge and nothing to show in an 
editor.
+  if (previewEntries === undefined) {
+    return undefined;
+  }
+
+  const clipboardButton = enableClipboard ? (
+    <ClipboardRoot value={contentFormatted}>
+      <ClipboardIconButton h={7} minW={7} />
+    </ClipboardRoot>
+  ) : undefined;
+
+  if (showBadges) {
+    return (
+      <Flex alignItems="center" flex={1} gap={1} minW={`${MIN_WIDTH}px`} 
{...rest}>
+        <JsonPreviewBadges entries={previewEntries} onExpand={() => 
setIsExpanded(true)} />
+        {clipboardButton}
+      </Flex>
+    );
+  }
 
   return (
-    <Flex
-      flex={1}
-      gap={2}
-      minW={200}
-      // Hide the editor until it's ready to prevent a flickering effect when 
collapsing.
-      // The editor will be hidden until the fold action is completed (if 
collapsed) or immediately if not collapsed.
-      style={isReady ? undefined : { height: "0px", overflow: "hidden" }}
-      {...rest}
-    >
-      <Editor
-        beforeMount={beforeMount}
-        height={`${editorHeight}px`}
-        language="json"
-        onMount={handleMount}
-        options={{
-          automaticLayout: true,
-          contextmenu: false,
-          folding: true,
-          fontSize: 13,
-          glyphMargin: false,
-          lineDecorationsWidth: 0,
-          lineNumbers: "off",
-          minimap: { enabled: false },
-          overviewRulerLanes: 0,
-          readOnly: true,
-          renderLineHighlight: "none",
-          scrollbar: { vertical: "hidden", verticalScrollbarSize: 0 },
-          scrollBeyondLastLine: false,
-          wordWrap: "on",
-        }}
-        theme={theme}
-        value={contentFormatted}
-      />
-      {enableClipboard ? (
-        <ClipboardRoot value={contentFormatted}>
-          <ClipboardIconButton h={7} minW={7} />
-        </ClipboardRoot>
-      ) : undefined}
+    <Flex flex={1} gap={2} minW={`${MIN_WIDTH}px`} {...rest}>
+      <Box flex={1} minW={`${editorWidth}px`} ref={containerRef}>
+        <Editor
+          beforeMount={beforeMount}
+          height={`${editorHeight}px`}
+          language="json"
+          onMount={handleMount}
+          options={{
+            automaticLayout: true,
+            contextmenu: false,
+            folding: true,
+            fontSize: 13,
+            glyphMargin: false,
+            lineDecorationsWidth: 0,
+            lineNumbers: "off",
+            minimap: { enabled: false },
+            overviewRulerLanes: 0,
+            readOnly: true,
+            renderLineHighlight: "none",
+            scrollbar: { vertical: "hidden", verticalScrollbarSize: 0 },
+            scrollBeyondLastLine: false,
+            wordWrap: "on",
+          }}
+          theme={theme}
+          value={contentFormatted}
+        />
+      </Box>
+      <VStack gap={1}>
+        {collapsed === undefined ? undefined : (
+          <IconButton
+            data-testid="json-preview-collapse"
+            h={7}
+            label={translate("expand.collapse")}
+            minW={7}
+            onClick={() => setIsExpanded(false)}
+            size="xs"
+          >
+            <FiChevronUp />
+          </IconButton>
+        )}
+        {clipboardButton}
+      </VStack>
     </Flex>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/utils/jsonPreview.test.ts 
b/airflow-core/src/airflow/ui/src/utils/jsonPreview.test.ts
new file mode 100644
index 00000000000..f2e1b84f849
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/jsonPreview.test.ts
@@ -0,0 +1,69 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { describe, expect, it } from "vitest";
+
+import { getJsonPreviewEntries } from "./jsonPreview";
+
+describe("getJsonPreviewEntries", () => {
+  it("summarises primitive object values", () => {
+    expect(
+      getJsonPreviewEntries({ empty: "", flag: false, missing: null, name: 
"prod", score: 0.99 }),
+    ).toStrictEqual([
+      { id: "empty", isComplex: false, label: "empty", value: '""' },
+      { id: "flag", isComplex: false, label: "flag", value: "false" },
+      { id: "missing", isComplex: false, label: "missing", value: "null" },
+      { id: "name", isComplex: false, label: "name", value: "prod" },
+      { id: "score", isComplex: false, label: "score", value: "0.99" },
+    ]);
+  });
+
+  it("hints at nested values instead of inlining them", () => {
+    expect(
+      getJsonPreviewEntries({ items: [1, 2], nested: { deep: 1 }, noItems: [], 
noKeys: {} }),
+    ).toStrictEqual([
+      { id: "items", isComplex: true, label: "items", value: "[…]" },
+      { id: "nested", isComplex: true, label: "nested", value: "{…}" },
+      { id: "noItems", isComplex: true, label: "noItems", value: "[]" },
+      { id: "noKeys", isComplex: true, label: "noKeys", value: "{}" },
+    ]);
+  });
+
+  it("labels nothing for arrays of primitives", () => {
+    expect(getJsonPreviewEntries(["a", 2])).toStrictEqual([
+      { id: "0", isComplex: false, value: "a" },
+      { id: "1", isComplex: false, value: "2" },
+    ]);
+  });
+
+  it.each([
+    ["an array of objects", [{ id: 1 }, { id: 2 }]],
+    ["an array of arrays", [[1], [2]]],
+  ])("summarises %s as a single count", (_label, content) => {
+    expect(getJsonPreviewEntries(content)).toStrictEqual([
+      { id: "items", isComplex: true, itemCount: 2, value: "[…]" },
+    ]);
+  });
+
+  it.each([
+    ["an empty object", {}],
+    ["an empty array", []],
+  ])("has no preview for %s", (_label, content) => {
+    expect(getJsonPreviewEntries(content)).toBeUndefined();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/jsonPreview.ts 
b/airflow-core/src/airflow/ui/src/utils/jsonPreview.ts
new file mode 100644
index 00000000000..53ace97a10c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/jsonPreview.ts
@@ -0,0 +1,77 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export type JsonPreviewEntry = {
+  /** Stable React key: the object key, or the index for array items. */
+  readonly id: string;
+  /** Nested objects and arrays are only hinted at — their contents need the 
full editor. */
+  readonly isComplex: boolean;
+  /** Set when the badge stands in for a whole array; the caller renders a 
translated count. */
+  readonly itemCount?: number;
+  /** Absent for array items, which have nothing meaningful to label them 
with. */
+  readonly label?: string;
+  readonly value: string;
+};
+
+const isPrimitive = (value: unknown) =>
+  value === null || ["boolean", "number", "string", 
"undefined"].includes(typeof value);
+
+const formatValue = (value: unknown): Pick<JsonPreviewEntry, "isComplex" | 
"value"> => {
+  if (typeof value === "string") {
+    return { isComplex: false, value: value === "" ? '""' : value };
+  }
+  if (isPrimitive(value)) {
+    return { isComplex: false, value: String(value) };
+  }
+  if (Array.isArray(value)) {
+    return { isComplex: true, value: value.length === 0 ? "[]" : "[…]" };
+  }
+
+  return { isComplex: true, value: Object.keys(value as object).length === 0 ? 
"{}" : "{…}" };
+};
+
+/**
+ * Flatten a JSON value into one badge per top-level entry, so a table cell 
can say
+ * `score: 0.99` instead of an anonymous `{ ... }`.
+ *
+ * Returns `undefined` for empty content, which has nothing worth rendering at 
all.
+ */
+export const getJsonPreviewEntries = (content: object): 
Array<JsonPreviewEntry> | undefined => {
+  if (Array.isArray(content)) {
+    if (content.length === 0) {
+      return undefined;
+    }
+
+    // Items nested in an array have no key to identify them, so one badge 
each would read
+    // `{…} {…} {…}`. Summarise the array as a whole instead.
+    if (!content.every((item: unknown) => isPrimitive(item))) {
+      return [{ id: "items", isComplex: true, itemCount: content.length, 
value: "[…]" }];
+    }
+
+    return content.map((item: unknown, index) => ({ id: String(index), 
...formatValue(item) }));
+  }
+
+  const entries = Object.entries(content);
+
+  if (entries.length === 0) {
+    return undefined;
+  }
+
+  return entries.map(([key, value]) => ({ id: key, label: key, 
...formatValue(value) }));
+};

Reply via email to