@workbench-kit/shell-react 0.0.2-prototype.0.2.6 → 0.0.2-prototype.0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +41 -0
  2. package/package.json +9 -9
  3. package/src/{chat-ai-mock-proposals.ts → chat/ai-mock-proposals.ts} +39 -39
  4. package/src/{chat-command-input.ts → chat/command-input.ts} +52 -52
  5. package/src/{chat-command-policy.ts → chat/command-policy.ts} +54 -54
  6. package/src/{chat-command-surface.tsx → chat/command-surface.tsx} +155 -155
  7. package/src/{use-workbench-chat-command-proposals.ts → chat/use-chat-command-proposals.ts} +194 -194
  8. package/src/{chat-view-data.ts → chat/view-data.ts} +20 -20
  9. package/src/{chat-view.tsx → chat/view.tsx} +220 -220
  10. package/src/{command-inspector-surface.tsx → commands/inspector-surface.tsx} +46 -46
  11. package/src/{use-workbench-command-descriptors.ts → commands/use-command-descriptors.ts} +41 -41
  12. package/src/{use-context-key-revision.ts → commands/use-context-key-revision.ts} +18 -18
  13. package/src/{commands-view-data.ts → commands/view-data.ts} +17 -17
  14. package/src/{commands-view.tsx → commands/view.tsx} +43 -43
  15. package/src/devtools/WorkbenchDevtoolsPanel.tsx +124 -124
  16. package/src/devtools/WorkbenchDevtoolsShell.tsx +17 -17
  17. package/src/devtools/index.ts +11 -11
  18. package/src/devtools/use-workbench-devtools-snapshot.ts +84 -84
  19. package/src/devtools/workbench-devtools-snapshot.ts +140 -140
  20. package/src/devtools/workbench-devtools.css +82 -82
  21. package/src/{editor-area-dnd.ts → editor/area-dnd.ts} +69 -69
  22. package/src/{editor-area-model.ts → editor/area-model.ts} +71 -71
  23. package/src/{editor-area.css → editor/area.css} +454 -454
  24. package/src/{editor-area.tsx → editor/area.tsx} +676 -676
  25. package/src/{editor-host-surface.tsx → editor/host-surface.tsx} +631 -634
  26. package/src/{editor-pane-visibility.ts → editor/pane-visibility.ts} +130 -130
  27. package/src/{editor-resource.ts → editor/resource.ts} +36 -36
  28. package/src/{editor-state-storage.ts → editor/state-storage.ts} +182 -193
  29. package/src/{editor-tab-context-menu.ts → editor/tab-context-menu.ts} +118 -118
  30. package/src/{use-editor.ts → editor/use-editor.ts} +114 -107
  31. package/src/{editor-view-providers.tsx → editor/view-providers.tsx} +552 -552
  32. package/src/{editor-workspace-file-availability.ts → editor/workspace-file-availability.ts} +15 -15
  33. package/src/{editor-workspace-reconcile.tsx → editor/workspace-reconcile.tsx} +31 -28
  34. package/src/{explorer-context-menu.ts → explorer/context-menu.ts} +114 -114
  35. package/src/{createCommandWorkspaceExplorerPort.ts → explorer/create-command-workspace-explorer-port.ts} +86 -86
  36. package/src/{explorer-reveal.ts → explorer/reveal.ts} +112 -112
  37. package/src/{search-view-data.ts → explorer/search-view-data.ts} +15 -15
  38. package/src/{search-view.tsx → explorer/search-view.tsx} +65 -62
  39. package/src/{use-active-workspace-path.ts → explorer/use-active-workspace-path.ts} +15 -15
  40. package/src/{explorer-view-data.ts → explorer/view-data.ts} +58 -58
  41. package/src/{explorer-view.tsx → explorer/view.tsx} +227 -224
  42. package/src/{extension-context-menu.ts → extensions/context-menu.ts} +52 -52
  43. package/src/{extension-management-model.ts → extensions/management-model.ts} +313 -313
  44. package/src/{use-extension-management.ts → extensions/use-extension-management.ts} +241 -193
  45. package/src/{extensions-view-data.ts → extensions/view-data.ts} +19 -19
  46. package/src/{extensions-view.tsx → extensions/view.tsx} +63 -38
  47. package/src/{field-remap-demo.tsx → field-remap/demo.tsx} +18 -18
  48. package/src/{field-remap-editor-surface.tsx → field-remap/editor-surface.tsx} +36 -36
  49. package/src/{field-remap-flow-adapter.ts → field-remap/flow-adapter.ts} +240 -240
  50. package/src/{field-remap-flow.tsx → field-remap/flow.tsx} +369 -369
  51. package/src/{field-remap-graph.tsx → field-remap/graph.tsx} +559 -559
  52. package/src/field-remap/jsonata-transform.ts +166 -0
  53. package/src/{field-remap-panel.tsx → field-remap/panel.tsx} +193 -175
  54. package/src/{field-remap-samples.ts → field-remap/samples.ts} +352 -352
  55. package/src/{table-mapping-adapter.ts → field-remap/table-mapping-adapter.ts} +59 -59
  56. package/src/{field-remap-tree.tsx → field-remap/tree.tsx} +404 -404
  57. package/src/{field-remap-view-data.ts → field-remap/view-data.ts} +16 -16
  58. package/src/{field-remap-view.css → field-remap/view.css} +676 -676
  59. package/src/{field-remap-view.tsx → field-remap/view.tsx} +86 -86
  60. package/src/index.ts +212 -199
  61. package/src/{json-form-source-patch.ts → jdw/json-form-source-patch.ts} +323 -323
  62. package/src/{jdw-lab-view-data.ts → jdw/lab-view-data.ts} +25 -25
  63. package/src/{jdw-lab-view.css → jdw/lab-view.css} +21 -21
  64. package/src/{jdw-lab-view.tsx → jdw/lab-view.tsx} +79 -79
  65. package/src/{jdw-widget-form-view.tsx → jdw/widget-form-view.tsx} +68 -65
  66. package/src/{jdw-widget-preview-view.tsx → jdw/widget-preview-view.tsx} +54 -51
  67. package/src/{keybinding-overrides-storage.ts → management/keybinding-overrides-storage.ts} +49 -57
  68. package/src/{keybinding-management-settings.tsx → management/keybinding-settings.tsx} +16 -16
  69. package/src/{management-palette-commands.ts → management/palette-commands.ts} +38 -38
  70. package/src/{preference-settings-storage.ts → management/preference-settings-storage.ts} +50 -58
  71. package/src/{management-settings-ids.ts → management/settings-ids.ts} +12 -12
  72. package/src/{management-settings.tsx → management/settings.tsx} +96 -75
  73. package/src/{use-command-management.ts → management/use-command-management.ts} +191 -191
  74. package/src/{use-keybinding-management.ts → management/use-keybinding-management.ts} +130 -130
  75. package/src/{shell-model.tsx → shell/model.tsx} +149 -149
  76. package/src/{provider.tsx → shell/provider.tsx} +603 -573
  77. package/src/{shell-secondary-actions.tsx → shell/secondary-actions.tsx} +59 -59
  78. package/src/{shell-settings-constants.ts → shell/settings-constants.ts} +9 -9
  79. package/src/{shell-settings.tsx → shell/settings.tsx} +599 -599
  80. package/src/{shell.tsx → shell/shell.tsx} +680 -649
  81. package/src/{shell-titlebar-layout-controls.tsx → shell/titlebar-layout-controls.tsx} +4 -4
  82. package/src/{shell-view-host.tsx → shell/view-host.tsx} +212 -199
  83. package/src/{workbench-appearance-storage.ts → workbench/appearance-storage.ts} +107 -115
  84. package/src/{workbench-command-host.tsx → workbench/command-host.tsx} +243 -243
  85. package/src/{workbench-command-palette.ts → workbench/command-palette.ts} +208 -208
  86. package/src/{workbench-keybinding-bridge.ts → workbench/keybinding-bridge.ts} +54 -54
  87. package/src/{workbench-layout-storage.ts → workbench/layout-storage.ts} +134 -145
  88. package/src/{workbench-profile-modal.tsx → workbench/profile-modal.tsx} +118 -118
  89. package/src/{workbench-shell-command-registration.ts → workbench/shell-command-registration.ts} +58 -58
  90. package/src/{workbench-startup-gate.tsx → workbench/startup-gate.tsx} +136 -136
  91. package/src/{workbench-status-sections.ts → workbench/status-sections.ts} +43 -43
  92. package/src/{use-persisted-workbench-appearance.ts → workbench/use-persisted-appearance.ts} +33 -33
  93. package/src/{workbench-user-commands.ts → workbench/user-commands.ts} +46 -46
  94. package/src/{workspace-view-state.ts → workbench/workspace-view-state.ts} +35 -35
  95. package/src/jsonata-transform.ts +0 -36
@@ -1,559 +1,559 @@
1
- import { useMemo, useState, type JSX } from 'react';
2
- import { Badge, Button } from '@workbench-kit/react/primitives';
3
- import {
4
- MAX_TRANSFORM_CHAIN,
5
- flattenSourceFields,
6
- flattenTargetSlots,
7
- type MappingEdge,
8
- type SourceField,
9
- type TargetSlot,
10
- type ValueTransformDefinition,
11
- type ValueTransformRegistry,
12
- } from '@workbench-kit/field-remap';
13
-
14
- interface KeyRow {
15
- readonly id: string;
16
- readonly label: string;
17
- readonly path?: string;
18
- readonly dataType?: string;
19
- readonly depth: number;
20
- }
21
-
22
- function collectKeys(
23
- nodes: readonly {
24
- id: string;
25
- label: string;
26
- path?: string;
27
- dataType?: string;
28
- children?: readonly unknown[];
29
- }[],
30
- depth = 0,
31
- ): KeyRow[] {
32
- const rows: KeyRow[] = [];
33
- for (const node of nodes) {
34
- rows.push({
35
- id: node.id,
36
- label: node.label,
37
- path: node.path,
38
- dataType: node.dataType,
39
- depth,
40
- });
41
- if (node.children && Array.isArray(node.children) && node.children.length > 0) {
42
- rows.push(
43
- ...collectKeys(
44
- node.children as readonly {
45
- id: string;
46
- label: string;
47
- path?: string;
48
- dataType?: string;
49
- children?: readonly unknown[];
50
- }[],
51
- depth + 1,
52
- ),
53
- );
54
- }
55
- }
56
- return rows;
57
- }
58
-
59
- function labelFor(
60
- id: string,
61
- sources: readonly SourceField[],
62
- targets: readonly TargetSlot[],
63
- ): string {
64
- const source = flattenSourceFields(sources).find((field) => field.id === id);
65
- if (source) {
66
- return source.path ?? source.label;
67
- }
68
- const target = flattenTargetSlots(targets).find((slot) => slot.id === id);
69
- return target?.path ?? target?.label ?? id;
70
- }
71
-
72
- function findSource(sources: readonly SourceField[], id: string): SourceField | undefined {
73
- return flattenSourceFields(sources).find((field) => field.id === id);
74
- }
75
-
76
- function findTarget(targets: readonly TargetSlot[], id: string): TargetSlot | undefined {
77
- return flattenTargetSlots(targets).find((slot) => slot.id === id);
78
- }
79
-
80
- export interface FieldRemapGraphMapperProps {
81
- readonly sources: readonly SourceField[];
82
- readonly targets: readonly TargetSlot[];
83
- readonly edges: readonly MappingEdge[];
84
- readonly transforms: ValueTransformRegistry;
85
- readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
86
- }
87
-
88
- /**
89
- * Left/right key columns + middle transform nodes per binding (format / reduce chain).
90
- * Edges stay `MappingEdge` (`transformIds` ≤ {@link MAX_TRANSFORM_CHAIN}).
91
- */
92
- export function FieldRemapGraphMapper({
93
- sources,
94
- targets,
95
- edges,
96
- transforms,
97
- onEdgesChange,
98
- }: FieldRemapGraphMapperProps): JSX.Element {
99
- const sourceKeys = useMemo(() => collectKeys(sources), [sources]);
100
- const targetKeys = useMemo(() => collectKeys(targets), [targets]);
101
- const catalog = useMemo(
102
- () =>
103
- transforms
104
- .list()
105
- .filter((item) => item.id !== 'identity')
106
- .sort((a, b) => a.label.localeCompare(b.label)),
107
- [transforms],
108
- );
109
-
110
- const [pendingSourceId, setPendingSourceId] = useState<string | null>(null);
111
- const [listContextEdgeId, setListContextEdgeId] = useState<string | null>(null);
112
- const [pendingItemSourceId, setPendingItemSourceId] = useState<string | null>(null);
113
- const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
114
-
115
- const mappedSourceIds = useMemo(() => new Set(edges.map((edge) => edge.sourceFieldId)), [edges]);
116
- const mappedTargetIds = useMemo(() => new Set(edges.map((edge) => edge.targetSlotId)), [edges]);
117
-
118
- const listContextEdge = edges.find((edge) => edge.id === listContextEdgeId) ?? null;
119
- const listSource = listContextEdge
120
- ? findSource(sources, listContextEdge.sourceFieldId)
121
- : undefined;
122
- const listTarget = listContextEdge
123
- ? findTarget(targets, listContextEdge.targetSlotId)
124
- : undefined;
125
-
126
- const patchEdge = (edgeId: string, patch: Partial<MappingEdge>) => {
127
- onEdgesChange(edges.map((edge) => (edge.id === edgeId ? { ...edge, ...patch } : edge)));
128
- };
129
-
130
- const connect = (sourceId: string, targetId: string) => {
131
- const source = findSource(sources, sourceId);
132
- const target = findTarget(targets, targetId);
133
- if (!source || !target) {
134
- return;
135
- }
136
- const isListContext =
137
- source.dataType === 'array' &&
138
- target.dataType === 'array' &&
139
- Boolean(source.children?.length && target.children?.length);
140
-
141
- const nextEdge: MappingEdge = {
142
- id: `e-${sourceId}-${targetId}-${Date.now()}`,
143
- sourceFieldId: sourceId,
144
- targetSlotId: targetId,
145
- ...(isListContext ? { itemEdges: [] as MappingEdge[] } : {}),
146
- };
147
- const withoutTarget = edges.filter((edge) => edge.targetSlotId !== targetId);
148
- onEdgesChange([...withoutTarget, nextEdge]);
149
- setPendingSourceId(null);
150
- setSelectedEdgeId(nextEdge.id);
151
- if (isListContext) {
152
- setListContextEdgeId(nextEdge.id);
153
- setPendingItemSourceId(null);
154
- }
155
- };
156
-
157
- const handleSourceSelect = (id: string) => {
158
- if (listContextEdgeId) {
159
- setPendingItemSourceId(id);
160
- return;
161
- }
162
- setPendingSourceId(id);
163
- };
164
-
165
- const handleTargetSelect = (id: string) => {
166
- if (listContextEdge && pendingItemSourceId) {
167
- const itemEdge: MappingEdge = {
168
- id: `ie-${pendingItemSourceId}-${id}-${Date.now()}`,
169
- sourceFieldId: pendingItemSourceId,
170
- targetSlotId: id,
171
- };
172
- onEdgesChange(
173
- edges.map((edge) =>
174
- edge.id === listContextEdge.id
175
- ? {
176
- ...edge,
177
- itemEdges: [
178
- ...(edge.itemEdges ?? []).filter((child) => child.targetSlotId !== id),
179
- itemEdge,
180
- ],
181
- }
182
- : edge,
183
- ),
184
- );
185
- setPendingItemSourceId(null);
186
- return;
187
- }
188
- if (!pendingSourceId) {
189
- return;
190
- }
191
- connect(pendingSourceId, id);
192
- };
193
-
194
- const addTransformNode = (edgeId: string, transformId: string) => {
195
- const edge = edges.find((item) => item.id === edgeId);
196
- if (!edge) {
197
- return;
198
- }
199
- const current = edge.transformIds ?? [];
200
- if (current.length >= MAX_TRANSFORM_CHAIN) {
201
- return;
202
- }
203
- patchEdge(edgeId, { transformIds: [...current, transformId] });
204
- };
205
-
206
- const setTransformAt = (edgeId: string, index: number, transformId: string) => {
207
- const edge = edges.find((item) => item.id === edgeId);
208
- if (!edge) {
209
- return;
210
- }
211
- const next = [...(edge.transformIds ?? [])];
212
- next[index] = transformId;
213
- patchEdge(edgeId, { transformIds: next });
214
- };
215
-
216
- const removeTransformAt = (edgeId: string, index: number) => {
217
- const edge = edges.find((item) => item.id === edgeId);
218
- if (!edge) {
219
- return;
220
- }
221
- const next = [...(edge.transformIds ?? [])];
222
- next.splice(index, 1);
223
- const optionSteps = edge.transformOptionSteps ? [...edge.transformOptionSteps] : undefined;
224
- if (optionSteps) {
225
- optionSteps.splice(index, 1);
226
- }
227
- patchEdge(edgeId, {
228
- transformIds: next.length > 0 ? next : undefined,
229
- transformOptionSteps: optionSteps && optionSteps.length > 0 ? optionSteps : undefined,
230
- });
231
- };
232
-
233
- const setNodeOption = (edgeId: string, index: number, key: string, value: string) => {
234
- const edge = edges.find((item) => item.id === edgeId);
235
- if (!edge) {
236
- return;
237
- }
238
- const chain = edge.transformIds ?? [];
239
- const steps = Array.from({ length: chain.length }, (_, i) => ({
240
- ...(edge.transformOptionSteps?.[i] ?? {}),
241
- }));
242
- steps[index] = { ...steps[index], [key]: value };
243
- patchEdge(edgeId, { transformOptionSteps: steps });
244
- };
245
-
246
- const removeEdge = (edgeId: string) => {
247
- onEdgesChange(edges.filter((edge) => edge.id !== edgeId));
248
- if (listContextEdgeId === edgeId) {
249
- setListContextEdgeId(null);
250
- setPendingItemSourceId(null);
251
- }
252
- if (selectedEdgeId === edgeId) {
253
- setSelectedEdgeId(null);
254
- }
255
- };
256
-
257
- const visibleSourceKeys =
258
- listContextEdge && listSource?.children ? collectKeys(listSource.children) : sourceKeys;
259
- const visibleTargetKeys =
260
- listContextEdge && listTarget?.children ? collectKeys(listTarget.children) : targetKeys;
261
-
262
- return (
263
- <div className="workbench-field-remap-mapper" data-testid="field-remap-mapper">
264
- <p className="workbench-field-remap-mapper__hint" data-testid="field-remap-hint">
265
- {listContextEdge
266
- ? pendingItemSourceId
267
- ? 'List context: select a target item field.'
268
- : 'List context: select a source item field, then a target item field.'
269
- : pendingSourceId
270
- ? 'Select a target key to create a binding. Add middle nodes for formatting.'
271
- : 'Select a source key, then a target key. Insert transform nodes in the middle when values need formatting.'}
272
- </p>
273
-
274
- <div className="workbench-field-remap-graph" data-testid="field-remap-graph">
275
- <KeyColumn
276
- title={listContextEdge ? `A item · ${listSource?.label ?? 'source'}` : 'A keys'}
277
- keys={visibleSourceKeys}
278
- selectedId={listContextEdge ? pendingItemSourceId : pendingSourceId}
279
- mappedIds={
280
- listContextEdge
281
- ? new Set((listContextEdge.itemEdges ?? []).map((edge) => edge.sourceFieldId))
282
- : mappedSourceIds
283
- }
284
- onSelect={handleSourceSelect}
285
- side="source"
286
- />
287
-
288
- <div className="workbench-field-remap-graph__center" data-testid="field-remap-lanes">
289
- <h4 className="workbench-field-remap-tree__title">Bindings / nodes</h4>
290
- {edges.length === 0 ? (
291
- <p className="workbench-field-remap-graph__empty">No bindings yet.</p>
292
- ) : (
293
- <ul className="workbench-field-remap-graph__lanes">
294
- {edges.map((edge) => (
295
- <BindingLane
296
- key={edge.id}
297
- edge={edge}
298
- selected={selectedEdgeId === edge.id}
299
- sourceLabel={labelFor(edge.sourceFieldId, sources, targets)}
300
- targetLabel={labelFor(edge.targetSlotId, sources, targets)}
301
- catalog={catalog}
302
- onSelect={() => setSelectedEdgeId(edge.id)}
303
- onAddNode={(transformId) => addTransformNode(edge.id, transformId)}
304
- onChangeNode={(index, transformId) => setTransformAt(edge.id, index, transformId)}
305
- onRemoveNode={(index) => removeTransformAt(edge.id, index)}
306
- onOptionChange={(index, key, value) => setNodeOption(edge.id, index, key, value)}
307
- onEditItems={
308
- edge.itemEdges
309
- ? () => {
310
- setListContextEdgeId(edge.id);
311
- setPendingItemSourceId(null);
312
- setPendingSourceId(null);
313
- }
314
- : undefined
315
- }
316
- onRemove={() => removeEdge(edge.id)}
317
- />
318
- ))}
319
- </ul>
320
- )}
321
- </div>
322
-
323
- <KeyColumn
324
- title={listContextEdge ? `B item · ${listTarget?.label ?? 'target'}` : 'B keys'}
325
- keys={visibleTargetKeys}
326
- selectedId={null}
327
- mappedIds={
328
- listContextEdge
329
- ? new Set((listContextEdge.itemEdges ?? []).map((edge) => edge.targetSlotId))
330
- : mappedTargetIds
331
- }
332
- onSelect={handleTargetSelect}
333
- side="target"
334
- />
335
- </div>
336
-
337
- {listContextEdge ? (
338
- <div
339
- className="workbench-field-remap-mapper__list-context"
340
- data-testid="field-remap-list-context"
341
- >
342
- <div className="workbench-field-remap-mapper__list-context-bar">
343
- <strong>List context</strong>
344
- <span>
345
- {listContextEdge.sourceFieldId} → {listContextEdge.targetSlotId}
346
- </span>
347
- <Button
348
- compact
349
- type="button"
350
- onClick={() => {
351
- setListContextEdgeId(null);
352
- setPendingItemSourceId(null);
353
- }}
354
- >
355
- Done
356
- </Button>
357
- </div>
358
- <ul className="workbench-field-remap-mapper__edges" data-testid="field-remap-edges">
359
- {(listContextEdge.itemEdges ?? []).map((edge) => (
360
- <li key={edge.id}>
361
- <code>
362
- {edge.sourceFieldId} → {edge.targetSlotId}
363
- </code>
364
- </li>
365
- ))}
366
- </ul>
367
- </div>
368
- ) : (
369
- <ul
370
- className="workbench-field-remap-mapper__edges visually-hidden"
371
- data-testid="field-remap-edges"
372
- >
373
- {edges.map((edge) => (
374
- <li key={edge.id}>
375
- {edge.sourceFieldId} → {edge.targetSlotId}
376
- {edge.transformIds?.length ? ` · ${edge.transformIds.join(' → ')}` : ''}
377
- </li>
378
- ))}
379
- </ul>
380
- )}
381
- </div>
382
- );
383
- }
384
-
385
- function KeyColumn({
386
- title,
387
- keys,
388
- selectedId,
389
- mappedIds,
390
- onSelect,
391
- side,
392
- }: {
393
- readonly title: string;
394
- readonly keys: readonly KeyRow[];
395
- readonly selectedId: string | null;
396
- readonly mappedIds: ReadonlySet<string>;
397
- readonly onSelect: (id: string) => void;
398
- readonly side: 'source' | 'target';
399
- }): JSX.Element {
400
- return (
401
- <div className="workbench-field-remap-tree" data-side={side}>
402
- <h4 className="workbench-field-remap-tree__title">{title}</h4>
403
- <ul className="workbench-field-remap-graph__keys" role="list">
404
- {keys.map((key) => {
405
- const badge = key.dataType === 'object' || key.dataType === 'array' ? key.dataType : null;
406
- return (
407
- <li key={key.id}>
408
- <button
409
- type="button"
410
- className={[
411
- 'workbench-field-remap-graph__key',
412
- selectedId === key.id ? 'is-selected' : '',
413
- mappedIds.has(key.id) ? 'is-mapped' : '',
414
- ]
415
- .filter(Boolean)
416
- .join(' ')}
417
- style={{ paddingLeft: `${0.5 + key.depth * 0.75}rem` }}
418
- data-testid={`field-remap-node-${key.id}`}
419
- onClick={() => onSelect(key.id)}
420
- >
421
- <span className="workbench-field-remap-tree__label">{key.label}</span>
422
- {badge ? <Badge variant="muted">{badge}</Badge> : null}
423
- </button>
424
- </li>
425
- );
426
- })}
427
- </ul>
428
- </div>
429
- );
430
- }
431
-
432
- function BindingLane({
433
- edge,
434
- selected,
435
- sourceLabel,
436
- targetLabel,
437
- catalog,
438
- onSelect,
439
- onAddNode,
440
- onChangeNode,
441
- onRemoveNode,
442
- onOptionChange,
443
- onEditItems,
444
- onRemove,
445
- }: {
446
- readonly edge: MappingEdge;
447
- readonly selected: boolean;
448
- readonly sourceLabel: string;
449
- readonly targetLabel: string;
450
- readonly catalog: readonly ValueTransformDefinition[];
451
- readonly onSelect: () => void;
452
- readonly onAddNode: (transformId: string) => void;
453
- readonly onChangeNode: (index: number, transformId: string) => void;
454
- readonly onRemoveNode: (index: number) => void;
455
- readonly onOptionChange: (index: number, key: string, value: string) => void;
456
- readonly onEditItems?: (() => void) | undefined;
457
- readonly onRemove: () => void;
458
- }): JSX.Element {
459
- const chain = edge.transformIds ?? [];
460
- const defaultAddId = catalog[0]?.id ?? 'string:trim';
461
-
462
- return (
463
- <li
464
- className={['workbench-field-remap-graph__lane', selected ? 'is-selected' : '']
465
- .filter(Boolean)
466
- .join(' ')}
467
- data-testid={`field-remap-lane-${edge.id}`}
468
- >
469
- <button type="button" className="workbench-field-remap-graph__endpoint" onClick={onSelect}>
470
- {sourceLabel}
471
- </button>
472
-
473
- <span className="workbench-field-remap-graph__arrow" aria-hidden="true">
474
-
475
- </span>
476
-
477
- <div className="workbench-field-remap-graph__nodes">
478
- {chain.length === 0 ? (
479
- <span className="workbench-field-remap-graph__passthrough" title="Pass-through">
480
- direct
481
- </span>
482
- ) : (
483
- chain.map((transformId, index) => {
484
- const definition = catalog.find((item) => item.id === transformId);
485
- const optionFields = definition?.optionFields ?? [];
486
- return (
487
- <div
488
- key={`${edge.id}-${index}-${transformId}`}
489
- className="workbench-field-remap-graph__node"
490
- data-testid={`field-remap-node-transform-${edge.id}-${index}`}
491
- >
492
- <select
493
- aria-label={`Transform step ${index + 1}`}
494
- value={transformId}
495
- onChange={(event) => onChangeNode(index, event.target.value)}
496
- >
497
- {!catalog.some((item) => item.id === transformId) ? (
498
- <option value={transformId}>{transformId}</option>
499
- ) : null}
500
- {catalog.map((item) => (
501
- <option key={item.id} value={item.id}>
502
- {item.label}
503
- </option>
504
- ))}
505
- </select>
506
- {optionFields.map((field) => (
507
- <input
508
- key={field.key}
509
- type="text"
510
- aria-label={field.label}
511
- placeholder={field.label}
512
- value={String(edge.transformOptionSteps?.[index]?.[field.key] ?? '')}
513
- onChange={(event) => onOptionChange(index, field.key, event.target.value)}
514
- />
515
- ))}
516
- <Button compact type="button" onClick={() => onRemoveNode(index)}>
517
- ×
518
- </Button>
519
- </div>
520
- );
521
- })
522
- )}
523
-
524
- {chain.length < MAX_TRANSFORM_CHAIN ? (
525
- <Button
526
- compact
527
- type="button"
528
- data-testid={`field-remap-add-node-${edge.id}`}
529
- onClick={() => onAddNode(defaultAddId)}
530
- >
531
- + node
532
- </Button>
533
- ) : null}
534
-
535
- {edge.itemSourcePath ? <Badge variant="muted">project {edge.itemSourcePath}</Badge> : null}
536
- {edge.itemEdges ? <Badge variant="muted">{edge.itemEdges.length} item fields</Badge> : null}
537
- </div>
538
-
539
- <span className="workbench-field-remap-graph__arrow" aria-hidden="true">
540
-
541
- </span>
542
-
543
- <button type="button" className="workbench-field-remap-graph__endpoint" onClick={onSelect}>
544
- {targetLabel}
545
- </button>
546
-
547
- <span className="workbench-field-remap-mapper__edge-actions">
548
- {onEditItems ? (
549
- <Button compact type="button" onClick={onEditItems}>
550
- Edit items
551
- </Button>
552
- ) : null}
553
- <Button compact type="button" onClick={onRemove}>
554
- Remove
555
- </Button>
556
- </span>
557
- </li>
558
- );
559
- }
1
+ import { useMemo, useState, type JSX } from 'react';
2
+ import { Badge, Button } from '@workbench-kit/react/primitives';
3
+ import {
4
+ MAX_TRANSFORM_CHAIN,
5
+ flattenSourceFields,
6
+ flattenTargetSlots,
7
+ type MappingEdge,
8
+ type SourceField,
9
+ type TargetSlot,
10
+ type ValueTransformDefinition,
11
+ type ValueTransformRegistry,
12
+ } from '@workbench-kit/field-remap';
13
+
14
+ interface KeyRow {
15
+ readonly id: string;
16
+ readonly label: string;
17
+ readonly path?: string;
18
+ readonly dataType?: string;
19
+ readonly depth: number;
20
+ }
21
+
22
+ function collectKeys(
23
+ nodes: readonly {
24
+ id: string;
25
+ label: string;
26
+ path?: string;
27
+ dataType?: string;
28
+ children?: readonly unknown[];
29
+ }[],
30
+ depth = 0,
31
+ ): KeyRow[] {
32
+ const rows: KeyRow[] = [];
33
+ for (const node of nodes) {
34
+ rows.push({
35
+ id: node.id,
36
+ label: node.label,
37
+ path: node.path,
38
+ dataType: node.dataType,
39
+ depth,
40
+ });
41
+ if (node.children && Array.isArray(node.children) && node.children.length > 0) {
42
+ rows.push(
43
+ ...collectKeys(
44
+ node.children as readonly {
45
+ id: string;
46
+ label: string;
47
+ path?: string;
48
+ dataType?: string;
49
+ children?: readonly unknown[];
50
+ }[],
51
+ depth + 1,
52
+ ),
53
+ );
54
+ }
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ function labelFor(
60
+ id: string,
61
+ sources: readonly SourceField[],
62
+ targets: readonly TargetSlot[],
63
+ ): string {
64
+ const source = flattenSourceFields(sources).find((field) => field.id === id);
65
+ if (source) {
66
+ return source.path ?? source.label;
67
+ }
68
+ const target = flattenTargetSlots(targets).find((slot) => slot.id === id);
69
+ return target?.path ?? target?.label ?? id;
70
+ }
71
+
72
+ function findSource(sources: readonly SourceField[], id: string): SourceField | undefined {
73
+ return flattenSourceFields(sources).find((field) => field.id === id);
74
+ }
75
+
76
+ function findTarget(targets: readonly TargetSlot[], id: string): TargetSlot | undefined {
77
+ return flattenTargetSlots(targets).find((slot) => slot.id === id);
78
+ }
79
+
80
+ export interface FieldRemapGraphMapperProps {
81
+ readonly sources: readonly SourceField[];
82
+ readonly targets: readonly TargetSlot[];
83
+ readonly edges: readonly MappingEdge[];
84
+ readonly transforms: ValueTransformRegistry;
85
+ readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
86
+ }
87
+
88
+ /**
89
+ * Left/right key columns + middle transform nodes per binding (format / reduce chain).
90
+ * Edges stay `MappingEdge` (`transformIds` ≤ {@link MAX_TRANSFORM_CHAIN}).
91
+ */
92
+ export function FieldRemapGraphMapper({
93
+ sources,
94
+ targets,
95
+ edges,
96
+ transforms,
97
+ onEdgesChange,
98
+ }: FieldRemapGraphMapperProps): JSX.Element {
99
+ const sourceKeys = useMemo(() => collectKeys(sources), [sources]);
100
+ const targetKeys = useMemo(() => collectKeys(targets), [targets]);
101
+ const catalog = useMemo(
102
+ () =>
103
+ transforms
104
+ .list()
105
+ .filter((item) => item.id !== 'identity')
106
+ .sort((a, b) => a.label.localeCompare(b.label)),
107
+ [transforms],
108
+ );
109
+
110
+ const [pendingSourceId, setPendingSourceId] = useState<string | null>(null);
111
+ const [listContextEdgeId, setListContextEdgeId] = useState<string | null>(null);
112
+ const [pendingItemSourceId, setPendingItemSourceId] = useState<string | null>(null);
113
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
114
+
115
+ const mappedSourceIds = useMemo(() => new Set(edges.map((edge) => edge.sourceFieldId)), [edges]);
116
+ const mappedTargetIds = useMemo(() => new Set(edges.map((edge) => edge.targetSlotId)), [edges]);
117
+
118
+ const listContextEdge = edges.find((edge) => edge.id === listContextEdgeId) ?? null;
119
+ const listSource = listContextEdge
120
+ ? findSource(sources, listContextEdge.sourceFieldId)
121
+ : undefined;
122
+ const listTarget = listContextEdge
123
+ ? findTarget(targets, listContextEdge.targetSlotId)
124
+ : undefined;
125
+
126
+ const patchEdge = (edgeId: string, patch: Partial<MappingEdge>) => {
127
+ onEdgesChange(edges.map((edge) => (edge.id === edgeId ? { ...edge, ...patch } : edge)));
128
+ };
129
+
130
+ const connect = (sourceId: string, targetId: string) => {
131
+ const source = findSource(sources, sourceId);
132
+ const target = findTarget(targets, targetId);
133
+ if (!source || !target) {
134
+ return;
135
+ }
136
+ const isListContext =
137
+ source.dataType === 'array' &&
138
+ target.dataType === 'array' &&
139
+ Boolean(source.children?.length && target.children?.length);
140
+
141
+ const nextEdge: MappingEdge = {
142
+ id: `e-${sourceId}-${targetId}-${Date.now()}`,
143
+ sourceFieldId: sourceId,
144
+ targetSlotId: targetId,
145
+ ...(isListContext ? { itemEdges: [] as MappingEdge[] } : {}),
146
+ };
147
+ const withoutTarget = edges.filter((edge) => edge.targetSlotId !== targetId);
148
+ onEdgesChange([...withoutTarget, nextEdge]);
149
+ setPendingSourceId(null);
150
+ setSelectedEdgeId(nextEdge.id);
151
+ if (isListContext) {
152
+ setListContextEdgeId(nextEdge.id);
153
+ setPendingItemSourceId(null);
154
+ }
155
+ };
156
+
157
+ const handleSourceSelect = (id: string) => {
158
+ if (listContextEdgeId) {
159
+ setPendingItemSourceId(id);
160
+ return;
161
+ }
162
+ setPendingSourceId(id);
163
+ };
164
+
165
+ const handleTargetSelect = (id: string) => {
166
+ if (listContextEdge && pendingItemSourceId) {
167
+ const itemEdge: MappingEdge = {
168
+ id: `ie-${pendingItemSourceId}-${id}-${Date.now()}`,
169
+ sourceFieldId: pendingItemSourceId,
170
+ targetSlotId: id,
171
+ };
172
+ onEdgesChange(
173
+ edges.map((edge) =>
174
+ edge.id === listContextEdge.id
175
+ ? {
176
+ ...edge,
177
+ itemEdges: [
178
+ ...(edge.itemEdges ?? []).filter((child) => child.targetSlotId !== id),
179
+ itemEdge,
180
+ ],
181
+ }
182
+ : edge,
183
+ ),
184
+ );
185
+ setPendingItemSourceId(null);
186
+ return;
187
+ }
188
+ if (!pendingSourceId) {
189
+ return;
190
+ }
191
+ connect(pendingSourceId, id);
192
+ };
193
+
194
+ const addTransformNode = (edgeId: string, transformId: string) => {
195
+ const edge = edges.find((item) => item.id === edgeId);
196
+ if (!edge) {
197
+ return;
198
+ }
199
+ const current = edge.transformIds ?? [];
200
+ if (current.length >= MAX_TRANSFORM_CHAIN) {
201
+ return;
202
+ }
203
+ patchEdge(edgeId, { transformIds: [...current, transformId] });
204
+ };
205
+
206
+ const setTransformAt = (edgeId: string, index: number, transformId: string) => {
207
+ const edge = edges.find((item) => item.id === edgeId);
208
+ if (!edge) {
209
+ return;
210
+ }
211
+ const next = [...(edge.transformIds ?? [])];
212
+ next[index] = transformId;
213
+ patchEdge(edgeId, { transformIds: next });
214
+ };
215
+
216
+ const removeTransformAt = (edgeId: string, index: number) => {
217
+ const edge = edges.find((item) => item.id === edgeId);
218
+ if (!edge) {
219
+ return;
220
+ }
221
+ const next = [...(edge.transformIds ?? [])];
222
+ next.splice(index, 1);
223
+ const optionSteps = edge.transformOptionSteps ? [...edge.transformOptionSteps] : undefined;
224
+ if (optionSteps) {
225
+ optionSteps.splice(index, 1);
226
+ }
227
+ patchEdge(edgeId, {
228
+ transformIds: next.length > 0 ? next : undefined,
229
+ transformOptionSteps: optionSteps && optionSteps.length > 0 ? optionSteps : undefined,
230
+ });
231
+ };
232
+
233
+ const setNodeOption = (edgeId: string, index: number, key: string, value: string) => {
234
+ const edge = edges.find((item) => item.id === edgeId);
235
+ if (!edge) {
236
+ return;
237
+ }
238
+ const chain = edge.transformIds ?? [];
239
+ const steps = Array.from({ length: chain.length }, (_, i) => ({
240
+ ...(edge.transformOptionSteps?.[i] ?? {}),
241
+ }));
242
+ steps[index] = { ...steps[index], [key]: value };
243
+ patchEdge(edgeId, { transformOptionSteps: steps });
244
+ };
245
+
246
+ const removeEdge = (edgeId: string) => {
247
+ onEdgesChange(edges.filter((edge) => edge.id !== edgeId));
248
+ if (listContextEdgeId === edgeId) {
249
+ setListContextEdgeId(null);
250
+ setPendingItemSourceId(null);
251
+ }
252
+ if (selectedEdgeId === edgeId) {
253
+ setSelectedEdgeId(null);
254
+ }
255
+ };
256
+
257
+ const visibleSourceKeys =
258
+ listContextEdge && listSource?.children ? collectKeys(listSource.children) : sourceKeys;
259
+ const visibleTargetKeys =
260
+ listContextEdge && listTarget?.children ? collectKeys(listTarget.children) : targetKeys;
261
+
262
+ return (
263
+ <div className="workbench-field-remap-mapper" data-testid="field-remap-mapper">
264
+ <p className="workbench-field-remap-mapper__hint" data-testid="field-remap-hint">
265
+ {listContextEdge
266
+ ? pendingItemSourceId
267
+ ? 'List context: select a target item field.'
268
+ : 'List context: select a source item field, then a target item field.'
269
+ : pendingSourceId
270
+ ? 'Select a target key to create a binding. Add middle nodes for formatting.'
271
+ : 'Select a source key, then a target key. Insert transform nodes in the middle when values need formatting.'}
272
+ </p>
273
+
274
+ <div className="workbench-field-remap-graph" data-testid="field-remap-graph">
275
+ <KeyColumn
276
+ title={listContextEdge ? `A item · ${listSource?.label ?? 'source'}` : 'A keys'}
277
+ keys={visibleSourceKeys}
278
+ selectedId={listContextEdge ? pendingItemSourceId : pendingSourceId}
279
+ mappedIds={
280
+ listContextEdge
281
+ ? new Set((listContextEdge.itemEdges ?? []).map((edge) => edge.sourceFieldId))
282
+ : mappedSourceIds
283
+ }
284
+ onSelect={handleSourceSelect}
285
+ side="source"
286
+ />
287
+
288
+ <div className="workbench-field-remap-graph__center" data-testid="field-remap-lanes">
289
+ <h4 className="workbench-field-remap-tree__title">Bindings / nodes</h4>
290
+ {edges.length === 0 ? (
291
+ <p className="workbench-field-remap-graph__empty">No bindings yet.</p>
292
+ ) : (
293
+ <ul className="workbench-field-remap-graph__lanes">
294
+ {edges.map((edge) => (
295
+ <BindingLane
296
+ key={edge.id}
297
+ edge={edge}
298
+ selected={selectedEdgeId === edge.id}
299
+ sourceLabel={labelFor(edge.sourceFieldId, sources, targets)}
300
+ targetLabel={labelFor(edge.targetSlotId, sources, targets)}
301
+ catalog={catalog}
302
+ onSelect={() => setSelectedEdgeId(edge.id)}
303
+ onAddNode={(transformId) => addTransformNode(edge.id, transformId)}
304
+ onChangeNode={(index, transformId) => setTransformAt(edge.id, index, transformId)}
305
+ onRemoveNode={(index) => removeTransformAt(edge.id, index)}
306
+ onOptionChange={(index, key, value) => setNodeOption(edge.id, index, key, value)}
307
+ onEditItems={
308
+ edge.itemEdges
309
+ ? () => {
310
+ setListContextEdgeId(edge.id);
311
+ setPendingItemSourceId(null);
312
+ setPendingSourceId(null);
313
+ }
314
+ : undefined
315
+ }
316
+ onRemove={() => removeEdge(edge.id)}
317
+ />
318
+ ))}
319
+ </ul>
320
+ )}
321
+ </div>
322
+
323
+ <KeyColumn
324
+ title={listContextEdge ? `B item · ${listTarget?.label ?? 'target'}` : 'B keys'}
325
+ keys={visibleTargetKeys}
326
+ selectedId={null}
327
+ mappedIds={
328
+ listContextEdge
329
+ ? new Set((listContextEdge.itemEdges ?? []).map((edge) => edge.targetSlotId))
330
+ : mappedTargetIds
331
+ }
332
+ onSelect={handleTargetSelect}
333
+ side="target"
334
+ />
335
+ </div>
336
+
337
+ {listContextEdge ? (
338
+ <div
339
+ className="workbench-field-remap-mapper__list-context"
340
+ data-testid="field-remap-list-context"
341
+ >
342
+ <div className="workbench-field-remap-mapper__list-context-bar">
343
+ <strong>List context</strong>
344
+ <span>
345
+ {listContextEdge.sourceFieldId} → {listContextEdge.targetSlotId}
346
+ </span>
347
+ <Button
348
+ compact
349
+ type="button"
350
+ onClick={() => {
351
+ setListContextEdgeId(null);
352
+ setPendingItemSourceId(null);
353
+ }}
354
+ >
355
+ Done
356
+ </Button>
357
+ </div>
358
+ <ul className="workbench-field-remap-mapper__edges" data-testid="field-remap-edges">
359
+ {(listContextEdge.itemEdges ?? []).map((edge) => (
360
+ <li key={edge.id}>
361
+ <code>
362
+ {edge.sourceFieldId} → {edge.targetSlotId}
363
+ </code>
364
+ </li>
365
+ ))}
366
+ </ul>
367
+ </div>
368
+ ) : (
369
+ <ul
370
+ className="workbench-field-remap-mapper__edges visually-hidden"
371
+ data-testid="field-remap-edges"
372
+ >
373
+ {edges.map((edge) => (
374
+ <li key={edge.id}>
375
+ {edge.sourceFieldId} → {edge.targetSlotId}
376
+ {edge.transformIds?.length ? ` · ${edge.transformIds.join(' → ')}` : ''}
377
+ </li>
378
+ ))}
379
+ </ul>
380
+ )}
381
+ </div>
382
+ );
383
+ }
384
+
385
+ function KeyColumn({
386
+ title,
387
+ keys,
388
+ selectedId,
389
+ mappedIds,
390
+ onSelect,
391
+ side,
392
+ }: {
393
+ readonly title: string;
394
+ readonly keys: readonly KeyRow[];
395
+ readonly selectedId: string | null;
396
+ readonly mappedIds: ReadonlySet<string>;
397
+ readonly onSelect: (id: string) => void;
398
+ readonly side: 'source' | 'target';
399
+ }): JSX.Element {
400
+ return (
401
+ <div className="workbench-field-remap-tree" data-side={side}>
402
+ <h4 className="workbench-field-remap-tree__title">{title}</h4>
403
+ <ul className="workbench-field-remap-graph__keys" role="list">
404
+ {keys.map((key) => {
405
+ const badge = key.dataType === 'object' || key.dataType === 'array' ? key.dataType : null;
406
+ return (
407
+ <li key={key.id}>
408
+ <button
409
+ type="button"
410
+ className={[
411
+ 'workbench-field-remap-graph__key',
412
+ selectedId === key.id ? 'is-selected' : '',
413
+ mappedIds.has(key.id) ? 'is-mapped' : '',
414
+ ]
415
+ .filter(Boolean)
416
+ .join(' ')}
417
+ style={{ paddingLeft: `${0.5 + key.depth * 0.75}rem` }}
418
+ data-testid={`field-remap-node-${key.id}`}
419
+ onClick={() => onSelect(key.id)}
420
+ >
421
+ <span className="workbench-field-remap-tree__label">{key.label}</span>
422
+ {badge ? <Badge variant="muted">{badge}</Badge> : null}
423
+ </button>
424
+ </li>
425
+ );
426
+ })}
427
+ </ul>
428
+ </div>
429
+ );
430
+ }
431
+
432
+ function BindingLane({
433
+ edge,
434
+ selected,
435
+ sourceLabel,
436
+ targetLabel,
437
+ catalog,
438
+ onSelect,
439
+ onAddNode,
440
+ onChangeNode,
441
+ onRemoveNode,
442
+ onOptionChange,
443
+ onEditItems,
444
+ onRemove,
445
+ }: {
446
+ readonly edge: MappingEdge;
447
+ readonly selected: boolean;
448
+ readonly sourceLabel: string;
449
+ readonly targetLabel: string;
450
+ readonly catalog: readonly ValueTransformDefinition[];
451
+ readonly onSelect: () => void;
452
+ readonly onAddNode: (transformId: string) => void;
453
+ readonly onChangeNode: (index: number, transformId: string) => void;
454
+ readonly onRemoveNode: (index: number) => void;
455
+ readonly onOptionChange: (index: number, key: string, value: string) => void;
456
+ readonly onEditItems?: (() => void) | undefined;
457
+ readonly onRemove: () => void;
458
+ }): JSX.Element {
459
+ const chain = edge.transformIds ?? [];
460
+ const defaultAddId = catalog[0]?.id ?? 'string:trim';
461
+
462
+ return (
463
+ <li
464
+ className={['workbench-field-remap-graph__lane', selected ? 'is-selected' : '']
465
+ .filter(Boolean)
466
+ .join(' ')}
467
+ data-testid={`field-remap-lane-${edge.id}`}
468
+ >
469
+ <button type="button" className="workbench-field-remap-graph__endpoint" onClick={onSelect}>
470
+ {sourceLabel}
471
+ </button>
472
+
473
+ <span className="workbench-field-remap-graph__arrow" aria-hidden="true">
474
+
475
+ </span>
476
+
477
+ <div className="workbench-field-remap-graph__nodes">
478
+ {chain.length === 0 ? (
479
+ <span className="workbench-field-remap-graph__passthrough" title="Pass-through">
480
+ direct
481
+ </span>
482
+ ) : (
483
+ chain.map((transformId, index) => {
484
+ const definition = catalog.find((item) => item.id === transformId);
485
+ const optionFields = definition?.optionFields ?? [];
486
+ return (
487
+ <div
488
+ key={`${edge.id}-${index}-${transformId}`}
489
+ className="workbench-field-remap-graph__node"
490
+ data-testid={`field-remap-node-transform-${edge.id}-${index}`}
491
+ >
492
+ <select
493
+ aria-label={`Transform step ${index + 1}`}
494
+ value={transformId}
495
+ onChange={(event) => onChangeNode(index, event.target.value)}
496
+ >
497
+ {!catalog.some((item) => item.id === transformId) ? (
498
+ <option value={transformId}>{transformId}</option>
499
+ ) : null}
500
+ {catalog.map((item) => (
501
+ <option key={item.id} value={item.id}>
502
+ {item.label}
503
+ </option>
504
+ ))}
505
+ </select>
506
+ {optionFields.map((field) => (
507
+ <input
508
+ key={field.key}
509
+ type="text"
510
+ aria-label={field.label}
511
+ placeholder={field.label}
512
+ value={String(edge.transformOptionSteps?.[index]?.[field.key] ?? '')}
513
+ onChange={(event) => onOptionChange(index, field.key, event.target.value)}
514
+ />
515
+ ))}
516
+ <Button compact type="button" onClick={() => onRemoveNode(index)}>
517
+ ×
518
+ </Button>
519
+ </div>
520
+ );
521
+ })
522
+ )}
523
+
524
+ {chain.length < MAX_TRANSFORM_CHAIN ? (
525
+ <Button
526
+ compact
527
+ type="button"
528
+ data-testid={`field-remap-add-node-${edge.id}`}
529
+ onClick={() => onAddNode(defaultAddId)}
530
+ >
531
+ + node
532
+ </Button>
533
+ ) : null}
534
+
535
+ {edge.itemSourcePath ? <Badge variant="muted">project {edge.itemSourcePath}</Badge> : null}
536
+ {edge.itemEdges ? <Badge variant="muted">{edge.itemEdges.length} item fields</Badge> : null}
537
+ </div>
538
+
539
+ <span className="workbench-field-remap-graph__arrow" aria-hidden="true">
540
+
541
+ </span>
542
+
543
+ <button type="button" className="workbench-field-remap-graph__endpoint" onClick={onSelect}>
544
+ {targetLabel}
545
+ </button>
546
+
547
+ <span className="workbench-field-remap-mapper__edge-actions">
548
+ {onEditItems ? (
549
+ <Button compact type="button" onClick={onEditItems}>
550
+ Edit items
551
+ </Button>
552
+ ) : null}
553
+ <Button compact type="button" onClick={onRemove}>
554
+ Remove
555
+ </Button>
556
+ </span>
557
+ </li>
558
+ );
559
+ }