@workbench-kit/shell-react 0.0.2-prototype.0.2.43 → 0.0.2-prototype.0.2.44

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.
@@ -1,5 +1,13 @@
1
- import { useMemo, useState, type JSX } from 'react';
2
- import { Button } from '@workbench-kit/react/primitives';
1
+ import { useId, useMemo, useState, type JSX } from 'react';
2
+ import { WorkbenchPropertyInline } from '@workbench-kit/react/layout';
3
+ import {
4
+ Button,
5
+ Select,
6
+ WorkbenchPropertyHint,
7
+ WorkbenchPropertyRow,
8
+ WorkbenchPropertySection,
9
+ WorkbenchPropertyStack,
10
+ } from '@workbench-kit/react/primitives';
3
11
  import {
4
12
  MAX_TRANSFORM_CHAIN,
5
13
  findSourceField,
@@ -109,6 +117,14 @@ export function FieldRemapDetailPanel({
109
117
  const [paletteId, setPaletteId] = useState<string>('');
110
118
  const [pendingOperatorFieldId, setPendingOperatorFieldId] = useState('');
111
119
  const [pendingOperatorSlotId, setPendingOperatorSlotId] = useState('');
120
+ const propertyId = useId();
121
+ const operatorAddInputId = `${propertyId}-operator-add-input`;
122
+ const operatorOutputId = `${propertyId}-operator-output`;
123
+ const operatorInputId = `${propertyId}-operator-input`;
124
+ const operatorAddOutputId = `${propertyId}-operator-add-output`;
125
+ const paletteSelectId = `${propertyId}-palette-select`;
126
+ const itemSourceId = `${propertyId}-item-source`;
127
+ const itemTargetId = `${propertyId}-item-target`;
112
128
  const flatSources = useMemo(() => flattenSourceFields(sources), [sources]);
113
129
  const flatTargets = useMemo(() => flattenTargetSlots(targets), [targets]);
114
130
  const commitOperators = (next: readonly MappingOperator[]) => {
@@ -125,8 +141,16 @@ export function FieldRemapDetailPanel({
125
141
  data-testid="field-remap-detail"
126
142
  aria-label="Binding details"
127
143
  >
128
- <h4>{emptyDetailTitle}</h4>
129
- <p data-testid="field-remap-detail-empty-hint">{emptyDetailDescription}</p>
144
+ <WorkbenchPropertyStack gap="sm">
145
+ <WorkbenchPropertySection title={emptyDetailTitle}>
146
+ <WorkbenchPropertyHint
147
+ className="workbench-field-remap-detail__muted"
148
+ data-testid="field-remap-detail-empty-hint"
149
+ >
150
+ {emptyDetailDescription}
151
+ </WorkbenchPropertyHint>
152
+ </WorkbenchPropertySection>
153
+ </WorkbenchPropertyStack>
130
154
  </aside>
131
155
  );
132
156
  }
@@ -139,47 +163,51 @@ export function FieldRemapDetailPanel({
139
163
  data-testid="field-remap-detail"
140
164
  aria-label="Draft convert"
141
165
  >
142
- <div className="workbench-field-remap-detail__header">
143
- <div>
144
- <h4>Draft convert</h4>
145
- <code data-testid="field-remap-detail-draft-id">
146
- {definition?.label ?? draft?.transformId ?? selection.localId}
147
- </code>
148
- </div>
149
- {!readOnly ? (
150
- <Button
151
- compact
152
- type="button"
153
- data-testid="field-remap-detail-discard-draft"
154
- onClick={() => {
155
- onDiscardDraft?.(selection.localId);
156
- onSelectionChange(null);
157
- }}
158
- >
159
- Discard
160
- </Button>
161
- ) : null}
162
- </div>
163
- <p className="workbench-field-remap-detail__muted">
164
- Wire source then target ports. When both bind, the convert note editor opens.
165
- </p>
166
- <dl
167
- className="workbench-field-remap-detail__draft-ports"
168
- data-testid="field-remap-detail-draft-ports"
169
- >
170
- <div>
171
- <dt>Source</dt>
172
- <dd>
173
- <code>{draft?.sourceFieldId ?? 'Unwired'}</code>
174
- </dd>
175
- </div>
176
- <div>
177
- <dt>Target</dt>
178
- <dd>
179
- <code>{draft?.targetSlotId ?? 'Unwired'}</code>
180
- </dd>
181
- </div>
182
- </dl>
166
+ <WorkbenchPropertyStack gap="sm">
167
+ <WorkbenchPropertySection
168
+ title="Draft convert"
169
+ actions={
170
+ !readOnly ? (
171
+ <Button
172
+ compact
173
+ type="button"
174
+ data-testid="field-remap-detail-discard-draft"
175
+ onClick={() => {
176
+ onDiscardDraft?.(selection.localId);
177
+ onSelectionChange(null);
178
+ }}
179
+ >
180
+ Discard
181
+ </Button>
182
+ ) : null
183
+ }
184
+ >
185
+ <WorkbenchPropertyStack gap="sm">
186
+ <WorkbenchPropertyRow label="Convert">
187
+ <code data-testid="field-remap-detail-draft-id">
188
+ {definition?.label ?? draft?.transformId ?? selection.localId}
189
+ </code>
190
+ </WorkbenchPropertyRow>
191
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
192
+ Wire source then target ports. When both bind, the convert note editor opens.
193
+ </WorkbenchPropertyHint>
194
+ </WorkbenchPropertyStack>
195
+ </WorkbenchPropertySection>
196
+ <WorkbenchPropertySection
197
+ level="group"
198
+ title="Ports"
199
+ data-testid="field-remap-detail-draft-ports"
200
+ >
201
+ <WorkbenchPropertyStack gap="xs">
202
+ <WorkbenchPropertyRow label="Source">
203
+ <code>{draft?.sourceFieldId ?? 'Unwired'}</code>
204
+ </WorkbenchPropertyRow>
205
+ <WorkbenchPropertyRow label="Target">
206
+ <code>{draft?.targetSlotId ?? 'Unwired'}</code>
207
+ </WorkbenchPropertyRow>
208
+ </WorkbenchPropertyStack>
209
+ </WorkbenchPropertySection>
210
+ </WorkbenchPropertyStack>
183
211
  </aside>
184
212
  );
185
213
  }
@@ -188,11 +216,18 @@ export function FieldRemapDetailPanel({
188
216
  if (!operator || (!readOnly && !onOperatorsChange)) {
189
217
  return (
190
218
  <aside className="workbench-field-remap-detail" data-testid="field-remap-detail">
191
- <h4>Operator</h4>
192
- <code data-testid="field-remap-detail-operator-id">{selection.operatorId}</code>
193
- <p className="workbench-field-remap-detail__muted">
194
- Host must wire onOperatorsChange for authoring.
195
- </p>
219
+ <WorkbenchPropertyStack gap="sm">
220
+ <WorkbenchPropertySection title="Operator">
221
+ <WorkbenchPropertyStack gap="sm">
222
+ <WorkbenchPropertyRow label="Identifier">
223
+ <code data-testid="field-remap-detail-operator-id">{selection.operatorId}</code>
224
+ </WorkbenchPropertyRow>
225
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
226
+ Host must wire onOperatorsChange for authoring.
227
+ </WorkbenchPropertyHint>
228
+ </WorkbenchPropertyStack>
229
+ </WorkbenchPropertySection>
230
+ </WorkbenchPropertyStack>
196
231
  </aside>
197
232
  );
198
233
  }
@@ -202,204 +237,217 @@ export function FieldRemapDetailPanel({
202
237
  data-testid="field-remap-detail"
203
238
  aria-label="Operator details"
204
239
  >
205
- <div className="workbench-field-remap-detail__header">
206
- <div>
207
- <h4>{operator.kind === 'combine' ? 'Combine (n→1)' : 'Split (1→n)'}</h4>
208
- <code data-testid="field-remap-detail-operator-id">{operator.id}</code>
209
- </div>
210
- {!readOnly ? (
211
- <Button
212
- compact
213
- type="button"
214
- data-testid="field-remap-detail-delete-operator"
215
- onClick={() => {
216
- commitOperators(removeMappingOperator(operators, operator.id));
217
- onSelectionChange(null);
218
- }}
219
- >
220
- Delete
221
- </Button>
222
- ) : null}
223
- </div>
224
- {operator.kind === 'combine' ? (
225
- <section className="workbench-field-remap-detail__section">
226
- <h5>Inputs</h5>
227
- <ul className="workbench-field-remap-detail__item-edges">
228
- {operator.inputFieldIds.map((fieldId) => (
229
- <li key={fieldId}>
230
- <code>{fieldLabel(fieldId, sources, targets)}</code>
231
- {!readOnly ? (
232
- <Button
233
- compact
234
- type="button"
235
- onClick={() =>
236
- commitOperators(
237
- updateMappingOperator(operators, operator.id, (current) =>
238
- removeOperatorInput(current, fieldId),
239
- ),
240
- )
241
- }
242
- >
243
- Remove
244
- </Button>
245
- ) : null}
246
- </li>
247
- ))}
248
- </ul>
249
- {!readOnly ? (
250
- <div className="workbench-field-remap-detail__field">
251
- <label>
252
- <span>Add input</span>
253
- <select
254
- data-testid="field-remap-operator-add-input"
255
- value={pendingOperatorFieldId}
256
- onChange={(event) => setPendingOperatorFieldId(event.target.value)}
257
- >
258
- <option value="">Select…</option>
259
- {flatSources.map((field) => (
260
- <option key={field.id} value={field.id}>
261
- {field.path ?? field.label}
262
- </option>
263
- ))}
264
- </select>
265
- </label>
240
+ <WorkbenchPropertyStack gap="sm">
241
+ <WorkbenchPropertySection
242
+ title={operator.kind === 'combine' ? 'Combine (n→1)' : 'Split (1→n)'}
243
+ actions={
244
+ !readOnly ? (
266
245
  <Button
267
246
  compact
268
247
  type="button"
269
- data-testid="field-remap-operator-bind-input"
270
- disabled={!pendingOperatorFieldId}
248
+ data-testid="field-remap-detail-delete-operator"
271
249
  onClick={() => {
272
- if (!pendingOperatorFieldId) return;
273
- commitOperators(
274
- updateMappingOperator(operators, operator.id, (current) =>
275
- bindOperatorInput(current, pendingOperatorFieldId),
276
- ),
277
- );
278
- setPendingOperatorFieldId('');
250
+ commitOperators(removeMappingOperator(operators, operator.id));
251
+ onSelectionChange(null);
279
252
  }}
280
253
  >
281
- Add
254
+ Delete
282
255
  </Button>
283
- </div>
284
- ) : null}
285
- <div className="workbench-field-remap-detail__field">
286
- <label>
287
- <span>Output slot</span>
288
- <select
289
- data-testid="field-remap-operator-output"
290
- value={operator.outputSlotId}
291
- disabled={readOnly}
292
- onChange={(event) => {
293
- const slotId = event.target.value;
294
- commitOperators(
295
- updateMappingOperator(operators, operator.id, (current) =>
296
- slotId
297
- ? bindOperatorOutput(current, slotId)
298
- : removeOperatorOutput(current, operator.outputSlotId),
299
- ),
300
- );
301
- }}
302
- >
303
- <option value="">Unwired…</option>
304
- {flatTargets.map((slot) => (
305
- <option key={slot.id} value={slot.id}>
306
- {slot.path ?? slot.label}
307
- </option>
308
- ))}
309
- </select>
310
- </label>
311
- </div>
312
- </section>
313
- ) : (
314
- <section className="workbench-field-remap-detail__section">
315
- <div className="workbench-field-remap-detail__field">
316
- <label>
317
- <span>Input field</span>
318
- <select
319
- data-testid="field-remap-operator-input"
320
- value={operator.inputFieldId}
321
- disabled={readOnly}
322
- onChange={(event) => {
323
- const fieldId = event.target.value;
324
- commitOperators(
325
- updateMappingOperator(operators, operator.id, (current) =>
326
- fieldId
327
- ? bindOperatorInput(current, fieldId)
328
- : removeOperatorInput(current, operator.inputFieldId),
329
- ),
330
- );
331
- }}
332
- >
333
- <option value="">Unwired…</option>
334
- {flatSources.map((field) => (
335
- <option key={field.id} value={field.id}>
336
- {field.path ?? field.label}
337
- </option>
256
+ ) : null
257
+ }
258
+ >
259
+ <WorkbenchPropertyRow label="Identifier">
260
+ <code data-testid="field-remap-detail-operator-id">{operator.id}</code>
261
+ </WorkbenchPropertyRow>
262
+ </WorkbenchPropertySection>
263
+ {operator.kind === 'combine' ? (
264
+ <WorkbenchPropertySection level="group" title="Inputs">
265
+ <WorkbenchPropertyStack gap="sm">
266
+ <ul className="workbench-field-remap-detail__item-edges">
267
+ {operator.inputFieldIds.map((fieldId) => (
268
+ <li key={fieldId}>
269
+ <WorkbenchPropertyInline justify="between">
270
+ <code>{fieldLabel(fieldId, sources, targets)}</code>
271
+ {!readOnly ? (
272
+ <Button
273
+ compact
274
+ type="button"
275
+ onClick={() =>
276
+ commitOperators(
277
+ updateMappingOperator(operators, operator.id, (current) =>
278
+ removeOperatorInput(current, fieldId),
279
+ ),
280
+ )
281
+ }
282
+ >
283
+ Remove
284
+ </Button>
285
+ ) : null}
286
+ </WorkbenchPropertyInline>
287
+ </li>
338
288
  ))}
339
- </select>
340
- </label>
341
- </div>
342
- <h5>Outputs</h5>
343
- <ul className="workbench-field-remap-detail__item-edges">
344
- {operator.outputSlotIds.map((slotId) => (
345
- <li key={slotId}>
346
- <code>{fieldLabel(slotId, sources, targets)}</code>
347
- {!readOnly ? (
348
- <Button
349
- compact
350
- type="button"
351
- onClick={() =>
352
- commitOperators(
353
- updateMappingOperator(operators, operator.id, (current) =>
354
- removeOperatorOutput(current, slotId),
355
- ),
356
- )
357
- }
358
- >
359
- Remove
360
- </Button>
361
- ) : null}
362
- </li>
363
- ))}
364
- </ul>
365
- {!readOnly ? (
366
- <div className="workbench-field-remap-detail__field">
367
- <label>
368
- <span>Add output</span>
369
- <select
370
- data-testid="field-remap-operator-add-output"
371
- value={pendingOperatorSlotId}
372
- onChange={(event) => setPendingOperatorSlotId(event.target.value)}
289
+ </ul>
290
+ {!readOnly ? (
291
+ <WorkbenchPropertyRow label="Add input" htmlFor={operatorAddInputId}>
292
+ <WorkbenchPropertyInline>
293
+ <Select
294
+ id={operatorAddInputId}
295
+ aria-label="Add input"
296
+ controlWidth="full"
297
+ data-testid="field-remap-operator-add-input"
298
+ value={pendingOperatorFieldId}
299
+ onValueChange={setPendingOperatorFieldId}
300
+ >
301
+ <option value="">Select…</option>
302
+ {flatSources.map((field) => (
303
+ <option key={field.id} value={field.id}>
304
+ {field.path ?? field.label}
305
+ </option>
306
+ ))}
307
+ </Select>
308
+ <Button
309
+ compact
310
+ type="button"
311
+ data-testid="field-remap-operator-bind-input"
312
+ disabled={!pendingOperatorFieldId}
313
+ onClick={() => {
314
+ if (!pendingOperatorFieldId) return;
315
+ commitOperators(
316
+ updateMappingOperator(operators, operator.id, (current) =>
317
+ bindOperatorInput(current, pendingOperatorFieldId),
318
+ ),
319
+ );
320
+ setPendingOperatorFieldId('');
321
+ }}
322
+ >
323
+ Add
324
+ </Button>
325
+ </WorkbenchPropertyInline>
326
+ </WorkbenchPropertyRow>
327
+ ) : null}
328
+ <WorkbenchPropertyRow label="Output slot" htmlFor={operatorOutputId}>
329
+ <Select
330
+ id={operatorOutputId}
331
+ aria-label="Output slot"
332
+ controlWidth="full"
333
+ data-testid="field-remap-operator-output"
334
+ value={operator.outputSlotId}
335
+ disabled={readOnly}
336
+ onValueChange={(slotId) => {
337
+ commitOperators(
338
+ updateMappingOperator(operators, operator.id, (current) =>
339
+ slotId
340
+ ? bindOperatorOutput(current, slotId)
341
+ : removeOperatorOutput(current, operator.outputSlotId),
342
+ ),
343
+ );
344
+ }}
373
345
  >
374
- <option value="">Select…</option>
346
+ <option value="">Unwired…</option>
375
347
  {flatTargets.map((slot) => (
376
348
  <option key={slot.id} value={slot.id}>
377
349
  {slot.path ?? slot.label}
378
350
  </option>
379
351
  ))}
380
- </select>
381
- </label>
382
- <Button
383
- compact
384
- type="button"
385
- data-testid="field-remap-operator-bind-output"
386
- disabled={!pendingOperatorSlotId}
387
- onClick={() => {
388
- if (!pendingOperatorSlotId) return;
389
- commitOperators(
390
- updateMappingOperator(operators, operator.id, (current) =>
391
- bindOperatorOutput(current, pendingOperatorSlotId),
392
- ),
393
- );
394
- setPendingOperatorSlotId('');
395
- }}
396
- >
397
- Add
398
- </Button>
399
- </div>
400
- ) : null}
401
- </section>
402
- )}
352
+ </Select>
353
+ </WorkbenchPropertyRow>
354
+ </WorkbenchPropertyStack>
355
+ </WorkbenchPropertySection>
356
+ ) : (
357
+ <WorkbenchPropertySection level="group" title="Outputs">
358
+ <WorkbenchPropertyStack gap="sm">
359
+ <WorkbenchPropertyRow label="Input field" htmlFor={operatorInputId}>
360
+ <Select
361
+ id={operatorInputId}
362
+ aria-label="Input field"
363
+ controlWidth="full"
364
+ data-testid="field-remap-operator-input"
365
+ value={operator.inputFieldId}
366
+ disabled={readOnly}
367
+ onValueChange={(fieldId) => {
368
+ commitOperators(
369
+ updateMappingOperator(operators, operator.id, (current) =>
370
+ fieldId
371
+ ? bindOperatorInput(current, fieldId)
372
+ : removeOperatorInput(current, operator.inputFieldId),
373
+ ),
374
+ );
375
+ }}
376
+ >
377
+ <option value="">Unwired…</option>
378
+ {flatSources.map((field) => (
379
+ <option key={field.id} value={field.id}>
380
+ {field.path ?? field.label}
381
+ </option>
382
+ ))}
383
+ </Select>
384
+ </WorkbenchPropertyRow>
385
+ <ul className="workbench-field-remap-detail__item-edges">
386
+ {operator.outputSlotIds.map((slotId) => (
387
+ <li key={slotId}>
388
+ <WorkbenchPropertyInline justify="between">
389
+ <code>{fieldLabel(slotId, sources, targets)}</code>
390
+ {!readOnly ? (
391
+ <Button
392
+ compact
393
+ type="button"
394
+ onClick={() =>
395
+ commitOperators(
396
+ updateMappingOperator(operators, operator.id, (current) =>
397
+ removeOperatorOutput(current, slotId),
398
+ ),
399
+ )
400
+ }
401
+ >
402
+ Remove
403
+ </Button>
404
+ ) : null}
405
+ </WorkbenchPropertyInline>
406
+ </li>
407
+ ))}
408
+ </ul>
409
+ {!readOnly ? (
410
+ <WorkbenchPropertyRow label="Add output" htmlFor={operatorAddOutputId}>
411
+ <WorkbenchPropertyInline>
412
+ <Select
413
+ id={operatorAddOutputId}
414
+ aria-label="Add output"
415
+ controlWidth="full"
416
+ data-testid="field-remap-operator-add-output"
417
+ value={pendingOperatorSlotId}
418
+ onValueChange={setPendingOperatorSlotId}
419
+ >
420
+ <option value="">Select…</option>
421
+ {flatTargets.map((slot) => (
422
+ <option key={slot.id} value={slot.id}>
423
+ {slot.path ?? slot.label}
424
+ </option>
425
+ ))}
426
+ </Select>
427
+ <Button
428
+ compact
429
+ type="button"
430
+ data-testid="field-remap-operator-bind-output"
431
+ disabled={!pendingOperatorSlotId}
432
+ onClick={() => {
433
+ if (!pendingOperatorSlotId) return;
434
+ commitOperators(
435
+ updateMappingOperator(operators, operator.id, (current) =>
436
+ bindOperatorOutput(current, pendingOperatorSlotId),
437
+ ),
438
+ );
439
+ setPendingOperatorSlotId('');
440
+ }}
441
+ >
442
+ Add
443
+ </Button>
444
+ </WorkbenchPropertyInline>
445
+ </WorkbenchPropertyRow>
446
+ ) : null}
447
+ </WorkbenchPropertyStack>
448
+ </WorkbenchPropertySection>
449
+ )}
450
+ </WorkbenchPropertyStack>
403
451
  </aside>
404
452
  );
405
453
  }
@@ -411,24 +459,32 @@ export function FieldRemapDetailPanel({
411
459
  data-testid="field-remap-detail"
412
460
  aria-label="Binding details"
413
461
  >
414
- <p>Select a binding for mapping details, or a convert node to open the convert editor.</p>
462
+ <WorkbenchPropertyStack gap="sm">
463
+ <WorkbenchPropertySection title="Binding">
464
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
465
+ Select a binding for mapping details, or a convert node to open the convert editor.
466
+ </WorkbenchPropertyHint>
467
+ </WorkbenchPropertySection>
468
+ </WorkbenchPropertyStack>
415
469
  </aside>
416
470
  );
417
471
  }
418
472
 
419
473
  if (selection.kind === 'transformStep') {
420
474
  return (
421
- <ConvertNoteEditor
422
- edge={edge}
423
- stepIndex={selection.stepIndex}
424
- sources={sources}
425
- targets={targets}
426
- transforms={transforms}
427
- edges={edges}
428
- onEdgesChange={onEdgesChange}
429
- onSelectionChange={onSelectionChange}
430
- readOnly={readOnly}
431
- />
475
+ <div data-testid="field-remap-detail-transform-step">
476
+ <ConvertNoteEditor
477
+ edge={edge}
478
+ stepIndex={selection.stepIndex}
479
+ sources={sources}
480
+ targets={targets}
481
+ transforms={transforms}
482
+ edges={edges}
483
+ onEdgesChange={onEdgesChange}
484
+ onSelectionChange={onSelectionChange}
485
+ readOnly={readOnly}
486
+ />
487
+ </div>
432
488
  );
433
489
  }
434
490
 
@@ -467,227 +523,239 @@ export function FieldRemapDetailPanel({
467
523
  data-testid="field-remap-detail"
468
524
  aria-label="Binding details"
469
525
  >
470
- <div className="workbench-field-remap-detail__header">
471
- <div>
472
- <h4>Binding</h4>
473
- <code data-testid="field-remap-detail-binding">
474
- {edge.sourceFieldId} → {edge.targetSlotId}
475
- </code>
476
- </div>
477
- <Button
478
- compact
479
- type="button"
480
- data-testid="field-remap-detail-clear"
481
- onClick={() => onSelectionChange(null)}
526
+ <WorkbenchPropertyStack gap="sm">
527
+ <WorkbenchPropertySection
528
+ title="Binding"
529
+ actions={
530
+ <Button
531
+ compact
532
+ type="button"
533
+ data-testid="field-remap-detail-clear"
534
+ onClick={() => onSelectionChange(null)}
535
+ >
536
+ Clear
537
+ </Button>
538
+ }
482
539
  >
483
- Clear
484
- </Button>
485
- </div>
540
+ <WorkbenchPropertyRow label="Route">
541
+ <code data-testid="field-remap-detail-binding">
542
+ {edge.sourceFieldId} → {edge.targetSlotId}
543
+ </code>
544
+ </WorkbenchPropertyRow>
545
+ </WorkbenchPropertySection>
486
546
 
487
- <section
488
- className="workbench-field-remap-detail__section"
489
- aria-labelledby="field-remap-detail-chain"
490
- >
491
- <h5 id="field-remap-detail-chain">Convert chain</h5>
492
- {chain.length === 0 ? (
493
- <p className="workbench-field-remap-detail__muted">Direct (no convert)</p>
494
- ) : (
495
- <ol className="workbench-field-remap-detail__steps">
496
- {chain.map((transformId, index) => {
497
- const definition = transforms.get(transformId);
498
- return (
499
- <li key={`${edge.id}-${index}-${transformId}`}>
500
- <button
547
+ <WorkbenchPropertySection level="group" title="Convert chain">
548
+ <WorkbenchPropertyStack gap="sm">
549
+ {chain.length === 0 ? (
550
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
551
+ Direct (no convert)
552
+ </WorkbenchPropertyHint>
553
+ ) : (
554
+ <ol className="workbench-field-remap-detail__steps">
555
+ {chain.map((transformId, index) => {
556
+ const definition = transforms.get(transformId);
557
+ return (
558
+ <li key={`${edge.id}-${index}-${transformId}`}>
559
+ <WorkbenchPropertyInline justify="between">
560
+ <button
561
+ type="button"
562
+ className="workbench-field-remap-detail__step"
563
+ data-testid={`field-remap-detail-step-${index}`}
564
+ onClick={() =>
565
+ onSelectionChange({
566
+ kind: 'transformStep',
567
+ edgeId: edge.id,
568
+ stepIndex: index,
569
+ })
570
+ }
571
+ >
572
+ <strong>{definition?.label ?? transformId}</strong>
573
+ <code>{transformId}</code>
574
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__step-hint">
575
+ {readOnly ? 'Inspect convert' : 'Edit convert'}
576
+ </WorkbenchPropertyHint>
577
+ </button>
578
+ {!readOnly ? (
579
+ <Button
580
+ compact
581
+ type="button"
582
+ aria-label={`Remove convert step ${index + 1}`}
583
+ data-testid={`field-remap-detail-remove-step-${index}`}
584
+ onClick={() => {
585
+ const next = removeTransformStepFromEdge(edge, index);
586
+ applyEdge(next);
587
+ }}
588
+ >
589
+ ×
590
+ </Button>
591
+ ) : null}
592
+ </WorkbenchPropertyInline>
593
+ </li>
594
+ );
595
+ })}
596
+ </ol>
597
+ )}
598
+
599
+ {!readOnly && chain.length < MAX_TRANSFORM_CHAIN ? (
600
+ <WorkbenchPropertyRow label="Add convert" htmlFor={paletteSelectId}>
601
+ <WorkbenchPropertyInline
602
+ className="workbench-field-remap-detail__palette"
603
+ data-testid="field-remap-transform-palette"
604
+ >
605
+ <Select
606
+ id={paletteSelectId}
607
+ aria-label="Convert palette"
608
+ controlWidth="full"
609
+ data-testid="field-remap-palette-select"
610
+ value={effectivePaletteId}
611
+ disabled={appendCatalog.length === 0}
612
+ onValueChange={setPaletteId}
613
+ >
614
+ {appendCatalog.length === 0 ? (
615
+ <option value="">No compatible converts</option>
616
+ ) : null}
617
+ {appendCatalog.map((item) => (
618
+ <option key={item.id} value={item.id}>
619
+ {item.label}
620
+ </option>
621
+ ))}
622
+ </Select>
623
+ <Button
624
+ compact
501
625
  type="button"
502
- className="workbench-field-remap-detail__step"
503
- data-testid={`field-remap-detail-step-${index}`}
504
- onClick={() =>
626
+ data-testid="field-remap-palette-add"
627
+ disabled={!effectivePaletteId}
628
+ onClick={() => {
629
+ const next = addTransformStepToEdge(edge, effectivePaletteId, {
630
+ registry: transforms,
631
+ sourceType: portTypes.sourceType,
632
+ targetType: portTypes.targetType,
633
+ });
634
+ if (!next) {
635
+ return;
636
+ }
637
+ applyEdge(next);
505
638
  onSelectionChange({
506
639
  kind: 'transformStep',
507
640
  edgeId: edge.id,
508
- stepIndex: index,
509
- })
510
- }
641
+ stepIndex: (next.transformIds?.length ?? 1) - 1,
642
+ });
643
+ }}
511
644
  >
512
- <strong>{definition?.label ?? transformId}</strong>
513
- <code>{transformId}</code>
514
- <span className="workbench-field-remap-detail__step-hint">
515
- {readOnly ? 'Inspect convert' : 'Edit convert'}
516
- </span>
517
- </button>
518
- {!readOnly ? (
519
- <Button
520
- compact
521
- type="button"
522
- aria-label={`Remove convert step ${index + 1}`}
523
- data-testid={`field-remap-detail-remove-step-${index}`}
524
- onClick={() => {
525
- const next = removeTransformStepFromEdge(edge, index);
526
- applyEdge(next);
527
- }}
528
- >
529
- ×
530
- </Button>
531
- ) : null}
532
- </li>
533
- );
534
- })}
535
- </ol>
536
- )}
645
+ Add
646
+ </Button>
647
+ </WorkbenchPropertyInline>
648
+ </WorkbenchPropertyRow>
649
+ ) : null}
650
+ </WorkbenchPropertyStack>
651
+ </WorkbenchPropertySection>
537
652
 
538
- {!readOnly && chain.length < MAX_TRANSFORM_CHAIN ? (
539
- <div
540
- className="workbench-field-remap-detail__palette"
541
- data-testid="field-remap-transform-palette"
653
+ {listContextEnabled ? (
654
+ <WorkbenchPropertySection
655
+ level="group"
656
+ title="List context"
657
+ data-testid="field-remap-list-context"
658
+ actions={
659
+ !readOnly && !edge.itemEdges ? (
660
+ <Button
661
+ compact
662
+ type="button"
663
+ data-testid="field-remap-enable-list-context"
664
+ onClick={() => applyEdge(enableListContextOnEdge(edge))}
665
+ >
666
+ Enable item fields
667
+ </Button>
668
+ ) : null
669
+ }
542
670
  >
543
- <label>
544
- <span>Add convert</span>
545
- <select
546
- aria-label="Convert palette"
547
- data-testid="field-remap-palette-select"
548
- value={effectivePaletteId}
549
- disabled={appendCatalog.length === 0}
550
- onChange={(event) => setPaletteId(event.target.value)}
551
- >
552
- {appendCatalog.length === 0 ? (
553
- <option value="">No compatible converts</option>
671
+ {edge.itemEdges ? (
672
+ <WorkbenchPropertyStack gap="sm">
673
+ {!readOnly ? (
674
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
675
+ {pendingItemSourceId
676
+ ? 'Select a target item field to complete the item binding.'
677
+ : 'Select a source item field, then a target item field.'}
678
+ </WorkbenchPropertyHint>
554
679
  ) : null}
555
- {appendCatalog.map((item) => (
556
- <option key={item.id} value={item.id}>
557
- {item.label}
558
- </option>
559
- ))}
560
- </select>
561
- </label>
562
- <Button
563
- compact
564
- type="button"
565
- data-testid="field-remap-palette-add"
566
- disabled={!effectivePaletteId}
567
- onClick={() => {
568
- const next = addTransformStepToEdge(edge, effectivePaletteId, {
569
- registry: transforms,
570
- sourceType: portTypes.sourceType,
571
- targetType: portTypes.targetType,
572
- });
573
- if (!next) {
574
- return;
575
- }
576
- applyEdge(next);
577
- onSelectionChange({
578
- kind: 'transformStep',
579
- edgeId: edge.id,
580
- stepIndex: (next.transformIds?.length ?? 1) - 1,
581
- });
582
- }}
583
- >
584
- Add
585
- </Button>
586
- </div>
587
- ) : null}
588
- </section>
589
-
590
- {listContextEnabled ? (
591
- <section
592
- className="workbench-field-remap-detail__section"
593
- aria-labelledby="field-remap-detail-list-context"
594
- data-testid="field-remap-list-context"
595
- >
596
- <div className="workbench-field-remap-detail__list-context-bar">
597
- <h5 id="field-remap-detail-list-context">List context</h5>
598
- {!readOnly && !edge.itemEdges ? (
599
- <Button
600
- compact
601
- type="button"
602
- data-testid="field-remap-enable-list-context"
603
- onClick={() => applyEdge(enableListContextOnEdge(edge))}
604
- >
605
- Enable item fields
606
- </Button>
607
- ) : null}
608
- </div>
609
-
610
- {edge.itemEdges ? (
611
- <>
612
- {!readOnly ? (
613
- <p className="workbench-field-remap-detail__muted">
614
- {pendingItemSourceId
615
- ? 'Select a target item field to complete the item binding.'
616
- : 'Select a source item field, then a target item field.'}
617
- </p>
618
- ) : null}
619
- {!readOnly ? (
620
- <div className="workbench-field-remap-detail__item-pickers">
621
- <label>
622
- <span>Source item field</span>
623
- <select
624
- aria-label="Source item field"
625
- data-testid="field-remap-item-source"
626
- value={pendingItemSourceId ?? ''}
627
- onChange={(event) => setPendingItemSourceId(event.target.value || null)}
628
- >
629
- <option value="">Select…</option>
630
- {itemSourceChildren.map((child) => (
631
- <option key={child.id} value={child.id}>
632
- {child.path ?? child.label}
633
- </option>
634
- ))}
635
- </select>
636
- </label>
637
- <label>
638
- <span>Target item field</span>
639
- <select
640
- aria-label="Target item field"
641
- data-testid="field-remap-item-target"
642
- value=""
643
- disabled={!pendingItemSourceId}
644
- onChange={(event) => {
645
- const targetId = event.target.value;
646
- if (!pendingItemSourceId || !targetId) {
647
- return;
648
- }
649
- const itemEdge: MappingEdge = {
650
- id: `ie-${pendingItemSourceId}-${targetId}-${Date.now()}`,
651
- sourceFieldId: pendingItemSourceId,
652
- targetSlotId: targetId,
653
- };
654
- applyEdge(upsertItemEdgeOnParent(edge, itemEdge));
655
- setPendingItemSourceId(null);
656
- }}
657
- >
658
- <option value="">Select…</option>
659
- {itemTargetChildren.map((child) => (
660
- <option key={child.id} value={child.id}>
661
- {child.path ?? child.label}
662
- </option>
663
- ))}
664
- </select>
665
- </label>
666
- </div>
667
- ) : null}
668
- <ul className="workbench-field-remap-detail__item-edges">
669
- {(edge.itemEdges ?? []).map((itemEdge) => (
670
- <li key={itemEdge.id} data-testid={`field-remap-item-edge-${itemEdge.id}`}>
671
- <code>
672
- {fieldLabel(itemEdge.sourceFieldId, sources, targets)} →{' '}
673
- {fieldLabel(itemEdge.targetSlotId, sources, targets)}
674
- </code>
675
- {!readOnly ? (
676
- <Button
677
- compact
678
- type="button"
679
- onClick={() => applyEdge(removeItemEdgeFromParent(edge, itemEdge.id))}
680
+ {!readOnly ? (
681
+ <WorkbenchPropertyStack
682
+ className="workbench-field-remap-detail__item-pickers"
683
+ gap="xs"
684
+ >
685
+ <WorkbenchPropertyRow label="Source item field" htmlFor={itemSourceId}>
686
+ <Select
687
+ id={itemSourceId}
688
+ aria-label="Source item field"
689
+ controlWidth="full"
690
+ data-testid="field-remap-item-source"
691
+ value={pendingItemSourceId ?? ''}
692
+ onValueChange={(sourceId) => setPendingItemSourceId(sourceId || null)}
680
693
  >
681
- Remove
682
- </Button>
683
- ) : null}
684
- </li>
685
- ))}
686
- </ul>
687
- </>
688
- ) : null}
689
- </section>
690
- ) : null}
694
+ <option value="">Select…</option>
695
+ {itemSourceChildren.map((child) => (
696
+ <option key={child.id} value={child.id}>
697
+ {child.path ?? child.label}
698
+ </option>
699
+ ))}
700
+ </Select>
701
+ </WorkbenchPropertyRow>
702
+ <WorkbenchPropertyRow label="Target item field" htmlFor={itemTargetId}>
703
+ <Select
704
+ id={itemTargetId}
705
+ aria-label="Target item field"
706
+ controlWidth="full"
707
+ data-testid="field-remap-item-target"
708
+ value=""
709
+ disabled={!pendingItemSourceId}
710
+ onValueChange={(targetId) => {
711
+ if (!pendingItemSourceId || !targetId) {
712
+ return;
713
+ }
714
+ const itemEdge: MappingEdge = {
715
+ id: `ie-${pendingItemSourceId}-${targetId}-${Date.now()}`,
716
+ sourceFieldId: pendingItemSourceId,
717
+ targetSlotId: targetId,
718
+ };
719
+ applyEdge(upsertItemEdgeOnParent(edge, itemEdge));
720
+ setPendingItemSourceId(null);
721
+ }}
722
+ >
723
+ <option value="">Select…</option>
724
+ {itemTargetChildren.map((child) => (
725
+ <option key={child.id} value={child.id}>
726
+ {child.path ?? child.label}
727
+ </option>
728
+ ))}
729
+ </Select>
730
+ </WorkbenchPropertyRow>
731
+ </WorkbenchPropertyStack>
732
+ ) : null}
733
+ <ul className="workbench-field-remap-detail__item-edges">
734
+ {(edge.itemEdges ?? []).map((itemEdge) => (
735
+ <li key={itemEdge.id} data-testid={`field-remap-item-edge-${itemEdge.id}`}>
736
+ <WorkbenchPropertyInline justify="between">
737
+ <code>
738
+ {fieldLabel(itemEdge.sourceFieldId, sources, targets)} →{' '}
739
+ {fieldLabel(itemEdge.targetSlotId, sources, targets)}
740
+ </code>
741
+ {!readOnly ? (
742
+ <Button
743
+ compact
744
+ type="button"
745
+ onClick={() => applyEdge(removeItemEdgeFromParent(edge, itemEdge.id))}
746
+ >
747
+ Remove
748
+ </Button>
749
+ ) : null}
750
+ </WorkbenchPropertyInline>
751
+ </li>
752
+ ))}
753
+ </ul>
754
+ </WorkbenchPropertyStack>
755
+ ) : null}
756
+ </WorkbenchPropertySection>
757
+ ) : null}
758
+ </WorkbenchPropertyStack>
691
759
  </aside>
692
760
  );
693
761
  }