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) }));
+};