diff --git a/.changeset/copyable-data-block-tests.md b/.changeset/copyable-data-block-tests.md new file mode 100644 index 0000000000..cbf5db6fd0 --- /dev/null +++ b/.changeset/copyable-data-block-tests.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Export `serializeForClipboard` from the copyable data block and add unit tests covering its clipboard serialization and the `CopyableDataBlock`/`EncryptedDataBlock` rendering. diff --git a/.changeset/data-inspector-expanded-brackets.md b/.changeset/data-inspector-expanded-brackets.md new file mode 100644 index 0000000000..98128f03af --- /dev/null +++ b/.changeset/data-inspector-expanded-brackets.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Rework the data inspector's JSON rendering: bracket notation (`{ … }` / `[ … ]`), colored keys, typed value colors, `▸`/`▾` disclosure icons, trailing commas, and a `...` collapsed indicator. Replaces the `react-inspector` engine with an in-house tree renderer while keeping the workflow-specific value handling (StreamRef/RunRef badges, encrypted markers, decoded byte streams, dates, class instances). diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index f7491208ce..c60c203ae2 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -60,7 +60,6 @@ "lucide-react": "0.575.0", "react": "19.1.0", "react-dom": "19.1.0", - "react-inspector": "9.0.0", "react-use-measure": "2.1.1", "react-virtuoso": "4.18.1", "shiki": "4.0.0", diff --git a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx index 26abfb87ea..a99c09c8ad 100644 --- a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx +++ b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx @@ -44,7 +44,7 @@ export function EncryptedDataBlock() { ); } -const serializeForClipboard = (value: unknown): string => { +export const serializeForClipboard = (value: unknown): string => { if (typeof value === 'string') return value; if ( typeof value === 'number' || diff --git a/packages/web-shared/src/components/ui/data-inspector.styles.ts b/packages/web-shared/src/components/ui/data-inspector.styles.ts new file mode 100644 index 0000000000..5af1224455 --- /dev/null +++ b/packages/web-shared/src/components/ui/data-inspector.styles.ts @@ -0,0 +1,96 @@ +/** + * Class names and styles for the data inspector tree. + * + * Kept out of the component module for readability. The CSS is injected via a + * React 19 hoistable ` + + + ); if (onStreamClick) { - wrapped = ( + content = ( - {wrapped} + {content} ); } if (onRunClick) { - wrapped = ( + content = ( - {wrapped} + {content} ); } if (onDecrypt) { - wrapped = ( + content = ( - {wrapped} + {content} ); } - return wrapped; + return content; } +// --------------------------------------------------------------------------- +// Render stabilization (avoid re-renders when data is deeply equal) +// --------------------------------------------------------------------------- + function useStableInspectorData(next: T): T { const previousRef = useRef(next); if (!isDeepEqual(previousRef.current, next)) { diff --git a/packages/web-shared/src/components/ui/inspector-theme.ts b/packages/web-shared/src/components/ui/inspector-theme.ts deleted file mode 100644 index 5d04188082..0000000000 --- a/packages/web-shared/src/components/ui/inspector-theme.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Shared theme configuration for react-inspector's ObjectInspector. - * - * Colors follow Geist's Shiki JSON palette so the inspector reads the same - * as highlighted code blocks across the product: - * - property names / punctuation: --ds-gray-1000 (default foreground) - * - strings / numbers / booleans: --ds-green-900 - * - null / undefined: --ds-gray-900 (muted) - * - regexp / function: --ds-purple-900 - * - date: --ds-pink-900 - * - * Because the `--ds-*` tokens adapt to theme automatically, the light and - * dark objects are intentionally identical. - */ - -// --------------------------------------------------------------------------- -// Extended color tokens not supported by react-inspector's built-in theme -// system, applied via our custom nodeRenderer in data-inspector.tsx. -// --------------------------------------------------------------------------- - -export interface InspectorThemeExtended { - /** Color for Date values (Node: 'magenta') */ - OBJECT_VALUE_DATE_COLOR: string; -} - -export const inspectorThemeExtendedLight: InspectorThemeExtended = { - OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)', -}; - -export const inspectorThemeExtendedDark: InspectorThemeExtended = { - OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)', -}; - -// --------------------------------------------------------------------------- -// Shared structural values (same in both themes) -// --------------------------------------------------------------------------- - -const shared = { - BASE_FONT_SIZE: '11px', - BASE_LINE_HEIGHT: 1.4, - BASE_BACKGROUND_COLOR: 'transparent', - OBJECT_PREVIEW_ARRAY_MAX_PROPERTIES: 10, - OBJECT_PREVIEW_OBJECT_MAX_PROPERTIES: 5, - HTML_TAGNAME_TEXT_TRANSFORM: 'lowercase' as const, - ARROW_MARGIN_RIGHT: 3, - ARROW_FONT_SIZE: 12, - TREENODE_FONT_FAMILY: 'var(--font-mono)', - TREENODE_FONT_SIZE: '11px', - TREENODE_LINE_HEIGHT: 1.4, - TREENODE_PADDING_LEFT: 12, - TABLE_DATA_BACKGROUND_IMAGE: 'none', - TABLE_DATA_BACKGROUND_SIZE: '0', -}; - -// --------------------------------------------------------------------------- -// Light theme -// --------------------------------------------------------------------------- - -const geistTheme = { - ...shared, - - // Base text - BASE_COLOR: 'var(--ds-gray-1000)', - - // Property names — default foreground (matches JSON key color in Geist Shiki) - OBJECT_NAME_COLOR: 'var(--ds-gray-1000)', - - // Strings & symbols — green - OBJECT_VALUE_STRING_COLOR: 'var(--ds-green-900)', - OBJECT_VALUE_SYMBOL_COLOR: 'var(--ds-green-900)', - - // Numbers & booleans — green (Geist JSON tokens) - OBJECT_VALUE_NUMBER_COLOR: 'var(--ds-green-900)', - OBJECT_VALUE_BOOLEAN_COLOR: 'var(--ds-green-900)', - - // null — muted foreground - OBJECT_VALUE_NULL_COLOR: 'var(--ds-gray-900)', - - // undefined — muted foreground - OBJECT_VALUE_UNDEFINED_COLOR: 'var(--ds-gray-900)', - - // RegExp — purple - OBJECT_VALUE_REGEXP_COLOR: 'var(--ds-purple-900)', - - // Functions — purple - OBJECT_VALUE_FUNCTION_PREFIX_COLOR: 'var(--ds-purple-900)', - - // HTML (rarely used here, kept consistent with the palette) - HTML_TAG_COLOR: 'var(--ds-gray-900)', - HTML_TAGNAME_COLOR: 'var(--ds-blue-900)', - HTML_ATTRIBUTE_NAME_COLOR: 'var(--ds-amber-900)', - HTML_ATTRIBUTE_VALUE_COLOR: 'var(--ds-green-900)', - HTML_COMMENT_COLOR: 'var(--ds-gray-700)', - HTML_DOCTYPE_COLOR: 'var(--ds-gray-700)', - - // Structural - ARROW_COLOR: 'var(--ds-gray-700)', - TABLE_BORDER_COLOR: 'var(--ds-gray-300)', - TABLE_TH_BACKGROUND_COLOR: 'var(--ds-gray-100)', - TABLE_TH_HOVER_COLOR: 'var(--ds-gray-200)', - TABLE_SORT_ICON_COLOR: 'var(--ds-gray-700)', -}; - -export const inspectorThemeLight = geistTheme; - -// --------------------------------------------------------------------------- -// Dark theme -// --------------------------------------------------------------------------- - -export const inspectorThemeDark = geistTheme; diff --git a/packages/web-shared/src/lib/hydration.ts b/packages/web-shared/src/lib/hydration.ts index dbf1d34d6f..b7472021b9 100644 --- a/packages/web-shared/src/lib/hydration.ts +++ b/packages/web-shared/src/lib/hydration.ts @@ -298,7 +298,7 @@ export function getWebRevivers(): Revivers { // Web-specific overrides for class instances. // Create objects with a dynamically-named constructor so that - // react-inspector shows the class name (it reads constructor.name). + // the data inspector shows the class name (it reads constructor.name). Class: (value) => ``, Instance: (value) => { // Run instances are rendered as clickable RunRef badges @@ -311,7 +311,7 @@ export function getWebRevivers(): Revivers { const props = data && typeof data === 'object' ? { ...data } : { value: data }; // Create a constructor with the right name using computed property - // so react-inspector's `object.constructor.name` shows the class name. + // so the data inspector's `object.constructor.name` shows the class name. // Must use `function` (not arrow) because arrow functions have no .prototype. // biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype const ctor = { [className]: function () {} }[className]!; diff --git a/packages/web-shared/test/copyable-data-block.test.ts b/packages/web-shared/test/copyable-data-block.test.ts new file mode 100644 index 0000000000..20bfd6f237 --- /dev/null +++ b/packages/web-shared/test/copyable-data-block.test.ts @@ -0,0 +1,142 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { + CopyableDataBlock, + EncryptedDataBlock, + serializeForClipboard, +} from '../src/components/sidebar/copyable-data-block.js'; +import { + DecryptClickContext, + type DecryptClickContextValue, +} from '../src/components/ui/data-inspector.js'; + +/** + * `serializeForClipboard` is the helper behind the copy button on the JSON-style + * data viewer (`CopyableDataBlock`). It decides what text lands on the clipboard + * when a user copies a step's input/output/error payload, so these tests pin the + * formatting contract: strings stay verbatim, primitives stringify, and objects + * become pretty-printed JSON with a defensive fallback for un-serializable values. + */ +describe('serializeForClipboard', () => { + it('returns strings verbatim without quoting them', () => { + expect(serializeForClipboard('hello world')).toBe('hello world'); + // Strings that happen to be JSON must not be double-encoded. + expect(serializeForClipboard('{"a":1}')).toBe('{"a":1}'); + expect(serializeForClipboard('')).toBe(''); + }); + + it('stringifies numeric, boolean, and null primitives', () => { + expect(serializeForClipboard(42)).toBe('42'); + expect(serializeForClipboard(0)).toBe('0'); + expect(serializeForClipboard(-1.5)).toBe('-1.5'); + expect(serializeForClipboard(Number.NaN)).toBe('NaN'); + expect(serializeForClipboard(true)).toBe('true'); + expect(serializeForClipboard(false)).toBe('false'); + expect(serializeForClipboard(null)).toBe('null'); + }); + + it('pretty-prints objects as two-space-indented JSON', () => { + const value = { input: 'x', count: 2, nested: { ok: true } }; + expect(serializeForClipboard(value)).toBe(JSON.stringify(value, null, 2)); + // Sanity-check the formatting is actually multi-line and indented. + expect(serializeForClipboard(value)).toContain('\n "input": "x"'); + }); + + it('pretty-prints arrays as two-space-indented JSON', () => { + const value = [1, 'two', { three: 3 }]; + expect(serializeForClipboard(value)).toBe(JSON.stringify(value, null, 2)); + }); + + it('falls back to String() when JSON.stringify throws (circular refs)', () => { + const circular: Record = { name: 'loop' }; + circular.self = circular; + + expect(serializeForClipboard(circular)).toBe('[object Object]'); + }); + + it('falls back to String() for BigInt values JSON cannot encode', () => { + expect(serializeForClipboard(10n)).toBe('10'); + }); + + it('passes undefined through (JSON.stringify yields no string)', () => { + // Documents the current behavior: undefined is neither a handled primitive + // nor JSON-serializable, so the helper returns undefined as-is. + expect(serializeForClipboard(undefined)).toBeUndefined(); + }); +}); + +describe('CopyableDataBlock', () => { + it('renders a labelled copy button for the data viewer', () => { + const markup = renderToStaticMarkup( + createElement(CopyableDataBlock, { data: { input: 'value' } }) + ); + + expect(markup).toContain('aria-label="Copy data"'); + }); + + it('renders without throwing for a variety of payload shapes', () => { + const payloads: unknown[] = [ + 'a plain string', + 1234, + null, + { nested: { deeply: [1, 2, 3] } }, + [{ id: 'a' }, { id: 'b' }], + ]; + + for (const data of payloads) { + expect(() => + renderToStaticMarkup(createElement(CopyableDataBlock, { data })) + ).not.toThrow(); + } + }); +}); + +describe('EncryptedDataBlock', () => { + it('shows a static Encrypted badge when no decrypt context is provided', () => { + const markup = renderToStaticMarkup(createElement(EncryptedDataBlock)); + + expect(markup).toContain('Encrypted'); + // The blurred placeholder previews the encrypted shape to the user. + expect(markup).toContain('[encrypted]'); + expect(markup).not.toContain('Decrypt'); + }); + + it('renders an enabled Decrypt button when a decrypt context is present', () => { + const ctx: DecryptClickContextValue = { + onDecrypt: () => {}, + isDecrypting: false, + }; + + const markup = renderToStaticMarkup( + createElement( + DecryptClickContext.Provider, + { value: ctx }, + createElement(EncryptedDataBlock) + ) + ); + + expect(markup).toContain('Decrypt'); + // The `disabled:`-prefixed Tailwind classes are always present, so assert on + // the actual `disabled` attribute instead of a loose substring match. + expect(markup).not.toContain('disabled=""'); + }); + + it('disables the Decrypt button while decryption is in progress', () => { + const ctx: DecryptClickContextValue = { + onDecrypt: () => {}, + isDecrypting: true, + }; + + const markup = renderToStaticMarkup( + createElement( + DecryptClickContext.Provider, + { value: ctx }, + createElement(EncryptedDataBlock) + ) + ); + + expect(markup).toContain('Decrypt'); + expect(markup).toContain('disabled=""'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f58a922fa3..1b296d155a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1221,9 +1221,6 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) - react-inspector: - specifier: 9.0.0 - version: 9.0.0(react@19.1.0) react-use-measure: specifier: 2.1.1 version: 2.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -15277,11 +15274,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-inspector@9.0.0: - resolution: {integrity: sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -26057,14 +26049,6 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 @@ -33822,10 +33806,6 @@ snapshots: dependencies: react: 19.2.3 - react-inspector@9.0.0(react@19.1.0): - dependencies: - react: 19.1.0 - react-is@16.13.1: {} react-is@17.0.2: {} @@ -36422,7 +36402,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18