Skip to content

Commit 727629e

Browse files
committed
feat(workflows): render structured version changes by subblock type
1 parent d4e23ca commit 727629e

10 files changed

Lines changed: 793 additions & 28 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import { ChipModalField, ChipTag, cn } from '@sim/emcn'
44
import { ArrowRight } from '@sim/emcn/icons'
55
import { isContainerType } from '@/lib/workflows/autolayout'
66
import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values'
7+
import { StructuredValueDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/structured-value-diff'
78
import {
89
InlineDiff,
910
TextDiff,
1011
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff'
12+
import { getStructuredValuePresentation } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation'
1113
import {
1214
classifyChange,
1315
containerFieldLabel,
1416
ENGINE_FIELD_LABELS,
17+
findSubBlockConfig,
1518
formatScalar,
1619
isBlankValue,
1720
isSentenceLike,
@@ -34,7 +37,12 @@ interface FieldChangeRowProps {
3437
* cleared) shows that one side alone, in that side's colour.
3538
*/
3639
export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldChangeRowProps) {
40+
const config = findSubBlockConfig(blockType, field)
3741
const kind = classifyChange(blockType, field, oldValue, newValue)
42+
const before =
43+
kind === 'structured' ? getStructuredValuePresentation(config, field, oldValue) : null
44+
const after =
45+
kind === 'structured' ? getStructuredValuePresentation(config, field, newValue) : null
3846
const oneSided = isBlankValue(oldValue) !== isBlankValue(newValue)
3947
const wordDiff = kind === 'scalar' && isSentenceLike(oldValue) && isSentenceLike(newValue)
4048
const resolvedLabel = isContainerType(blockType)
@@ -47,14 +55,28 @@ export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldCh
4755
const formatted = toDiffText(value, blockType, field)
4856
return kind === 'json' && typeof value === 'string' ? JSON.stringify(formatted) : formatted
4957
}
50-
const oldText = textual ? text(oldValue) : scalar ? formatScalar(blockType, field, oldValue) : ''
51-
const newText = textual ? text(newValue) : scalar ? formatScalar(blockType, field, newValue) : ''
58+
let oldText = textual ? text(oldValue) : scalar ? formatScalar(blockType, field, oldValue) : ''
59+
let newText = textual ? text(newValue) : scalar ? formatScalar(blockType, field, newValue) : ''
60+
if (
61+
scalar &&
62+
(config?.type === 'dropdown' || config?.type === 'combobox') &&
63+
oldText === newText &&
64+
typeof oldValue === 'string' &&
65+
typeof newValue === 'string' &&
66+
oldValue !== newValue
67+
) {
68+
oldText = `${oldText} (${oldValue})`
69+
newText = `${newText} (${newValue})`
70+
}
5271
const emptyTextChange = textual && oldText === '' && newText === ''
5372
const sameDisplayValue =
5473
!emptyTextChange && (textual || (scalar && !oneSided)) && oldText === newText
5574

5675
return (
5776
<ChipModalField type='custom' title={label} flush>
77+
{before && after && (
78+
<StructuredValueDiff before={before} after={after} config={config} label={label} />
79+
)}
5880
{kind === 'secret' && (
5981
<span className='text-[var(--text-secondary)] text-small'>
6082
{isBlankValue(oldValue) ? 'Set' : isBlankValue(newValue) ? 'Cleared' : 'Value changed'}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { diffOrderedRows } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/ordered-row-diff'
3+
4+
/** Duplicate keys, reordered rows, hidden values, and bounded work must preserve both inputs. */
5+
describe('ordered structured-value differences', () => {
6+
it('preserves duplicate header names and their execution order', () => {
7+
const first = { key: 'X-Mode:first' }
8+
const last = { key: 'X-Mode:last' }
9+
const rows = diffOrderedRows([first, last], [last, first])
10+
expect(rows.filter((row) => row.kind !== 'added').map((row) => row.value)).toEqual([
11+
first,
12+
last,
13+
])
14+
expect(rows.filter((row) => row.kind !== 'removed').map((row) => row.value)).toEqual([
15+
last,
16+
first,
17+
])
18+
expect(rows.some((row) => row.kind === 'removed')).toBe(true)
19+
expect(rows.some((row) => row.kind === 'added')).toBe(true)
20+
})
21+
22+
it('detects a secret-only edit before identical masked cells are rendered', () => {
23+
const before = { key: 'Authorization:before', display: '•••' }
24+
const after = { key: 'Authorization:after', display: '•••' }
25+
expect(diffOrderedRows([before], [after])).toEqual([
26+
{ kind: 'removed', value: before },
27+
{ kind: 'added', value: after },
28+
])
29+
})
30+
31+
it('keeps every row when a large rewrite exceeds the edit budget', () => {
32+
const before = Array.from({ length: 300 }, (_, index) => ({ key: `old-${index}` }))
33+
const after = Array.from({ length: 300 }, (_, index) => ({ key: `new-${index}` }))
34+
const rows = diffOrderedRows(before, after)
35+
expect(rows.filter((row) => row.kind !== 'added').map((row) => row.value)).toEqual(before)
36+
expect(rows.filter((row) => row.kind !== 'removed').map((row) => row.value)).toEqual(after)
37+
})
38+
39+
it('retains repeated identical rows and one-sided values', () => {
40+
const repeated = { key: 'Accept:json' }
41+
expect(diffOrderedRows([repeated, repeated], [repeated])).toEqual([
42+
{ kind: 'context', value: repeated },
43+
{ kind: 'removed', value: repeated },
44+
])
45+
expect(diffOrderedRows([], [repeated])).toEqual([{ kind: 'added', value: repeated }])
46+
expect(diffOrderedRows([repeated], [])).toEqual([{ kind: 'removed', value: repeated }])
47+
})
48+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { diffArrays } from 'diff'
2+
3+
const MAX_ROW_EDITS = 128
4+
5+
export interface OrderedDiffRow<T> {
6+
kind: 'added' | 'removed' | 'context'
7+
value: T
8+
}
9+
10+
/**
11+
* Compares exact, unmasked row keys in order. Duplicate keys remain separate occurrences.
12+
* Large rewrites use complete before/after rows once the bounded edit search is exhausted.
13+
*/
14+
export function diffOrderedRows<T extends { key: string }>(
15+
before: T[],
16+
after: T[]
17+
): OrderedDiffRow<T>[] {
18+
const parts = diffArrays(before, after, {
19+
comparator: (left, right) => left.key === right.key,
20+
maxEditLength: MAX_ROW_EDITS,
21+
})
22+
if (!parts) {
23+
return [
24+
...before.map((value) => ({ kind: 'removed' as const, value })),
25+
...after.map((value) => ({ kind: 'added' as const, value })),
26+
]
27+
}
28+
return parts.flatMap((part) => {
29+
const kind = part.added ? 'added' : part.removed ? 'removed' : 'context'
30+
return part.value.map((value) => ({ kind, value }))
31+
})
32+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ChipTag } from '@sim/emcn'
2+
import { isRecordLike } from '@sim/utils/object'
3+
4+
interface ReadableValueProps {
5+
value: unknown
6+
}
7+
8+
/** Nested domain values retain every property and ordered item without exposing JSON syntax. */
9+
export function ReadableValue({ value }: ReadableValueProps) {
10+
if (Array.isArray(value)) {
11+
return value.length ? (
12+
<ol className='list-inside list-decimal space-y-2'>
13+
{value.map((item, index) => (
14+
<li key={index}>
15+
<ReadableValue value={item} />
16+
</li>
17+
))}
18+
</ol>
19+
) : (
20+
<span className='text-[var(--text-muted)]'>No items</span>
21+
)
22+
}
23+
if (isRecordLike(value)) {
24+
const entries = Object.entries(value)
25+
return entries.length ? (
26+
<dl className='space-y-2'>
27+
{entries.map(([key, entry]) => (
28+
<div key={key} className='grid grid-cols-[minmax(0,1fr)_minmax(0,3fr)] items-start gap-2'>
29+
<dt className='break-words text-[var(--text-muted)] text-caption'>{key}</dt>
30+
<dd className='min-w-0'>
31+
<ReadableValue value={entry} />
32+
</dd>
33+
</div>
34+
))}
35+
</dl>
36+
) : (
37+
<span className='text-[var(--text-muted)]'>No fields</span>
38+
)
39+
}
40+
if (value === undefined || value === null || value === '') {
41+
return (
42+
<span className='text-[var(--text-muted)] text-caption italic'>
43+
{value === undefined ? 'Unset' : value === null ? 'Null' : 'Empty'}
44+
</span>
45+
)
46+
}
47+
if (typeof value !== 'string') return <ChipTag variant='mono'>{String(value)}</ChipTag>
48+
return <span className='whitespace-pre-wrap break-words text-[var(--text-body)]'>{value}</span>
49+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use client'
2+
3+
import { useMemo, useState } from 'react'
4+
import {
5+
Button,
6+
cn,
7+
Table,
8+
TableBody,
9+
TableCell,
10+
TableHead,
11+
TableHeader,
12+
TableRow,
13+
} from '@sim/emcn'
14+
import { toRecord } from '@sim/utils/object'
15+
import {
16+
DIFF_SIGN,
17+
DIFF_SIGN_CLASS,
18+
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/diff-signs'
19+
import { diffOrderedRows } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/ordered-row-diff'
20+
import { ReadableValue } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/readable-value'
21+
import type { StructuredValuePresentation } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation'
22+
import { maskSecretsDeep } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils'
23+
import type { SubBlockConfig } from '@/blocks/types'
24+
25+
const ROWS_PER_PAGE = 30
26+
27+
interface StructuredValueDiffProps {
28+
before: StructuredValuePresentation
29+
after: StructuredValuePresentation
30+
config?: SubBlockConfig
31+
label: string
32+
}
33+
34+
/** Declared columns and ordered rows, with masking applied only after the comparison. */
35+
export function StructuredValueDiff({ before, after, config, label }: StructuredValueDiffProps) {
36+
const rows = useMemo(() => diffOrderedRows(before.rows, after.rows), [before, after])
37+
const columns = [...new Set([...after.columns, ...before.columns])]
38+
const [visibleCount, setVisibleCount] = useState(ROWS_PER_PAGE)
39+
const tabular = config?.type === 'table'
40+
const maskTableValues = tabular && config.password
41+
const keyColumn = config?.columns?.[0]
42+
const representationChanged =
43+
before.sourceKey !== after.sourceKey && rows.every((row) => row.kind === 'context')
44+
45+
return (
46+
<div className='min-w-0'>
47+
{representationChanged && (
48+
<span className='text-[var(--text-muted)] text-small'>
49+
{before.encoding === 'json-text' && after.encoding === 'json-text'
50+
? 'Formatting changed'
51+
: 'Value type changed'}
52+
</span>
53+
)}
54+
<Table aria-label={`${label} changes`} className='table-fixed'>
55+
<colgroup>
56+
<col className='w-10' />
57+
<col span={tabular ? columns.length : 1} />
58+
</colgroup>
59+
{tabular && (
60+
<TableHeader>
61+
<TableRow>
62+
<TableHead className='w-10'>
63+
<span className='sr-only'>Change</span>
64+
</TableHead>
65+
{columns.map((column) => (
66+
<TableHead key={column}>{column}</TableHead>
67+
))}
68+
</TableRow>
69+
</TableHeader>
70+
)}
71+
<TableBody>
72+
{rows.slice(0, visibleCount).map((row, index) => {
73+
const masked = toRecord(maskSecretsDeep({ cells: row.value.cells }))
74+
const cells = toRecord(masked.cells)
75+
const sign = row.kind === 'context' ? null : row.kind
76+
return (
77+
<TableRow
78+
key={index}
79+
className={cn(
80+
row.kind === 'added' && 'bg-[var(--badge-success-bg)]',
81+
row.kind === 'removed' && 'bg-[var(--badge-error-bg)]'
82+
)}
83+
>
84+
<TableCell className='w-10 align-top'>
85+
{sign && (
86+
<span className={cn('font-mono', DIFF_SIGN_CLASS[sign])}>
87+
<span className='sr-only'>{sign === 'added' ? 'Added' : 'Removed'}</span>
88+
<span aria-hidden='true'>{DIFF_SIGN[sign]}</span>
89+
</span>
90+
)}
91+
</TableCell>
92+
{tabular ? (
93+
columns.map((column) => {
94+
const value = cells[column]
95+
const concealed =
96+
maskTableValues && column !== keyColumn && value != null && value !== ''
97+
return (
98+
<TableCell key={column} className='align-top'>
99+
<ReadableValue value={concealed ? '•••' : value} />
100+
</TableCell>
101+
)
102+
})
103+
) : (
104+
<TableCell className='align-top'>
105+
<ReadableValue value={cells} />
106+
</TableCell>
107+
)}
108+
</TableRow>
109+
)
110+
})}
111+
</TableBody>
112+
</Table>
113+
{visibleCount < rows.length && (
114+
<Button
115+
variant='quiet'
116+
size='sm'
117+
onClick={() => setVisibleCount((count) => count + ROWS_PER_PAGE)}
118+
>
119+
Show more rows
120+
</Button>
121+
)}
122+
</div>
123+
)
124+
}

0 commit comments

Comments
 (0)