@adcops/autocore-react 3.3.89 → 3.3.91

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 (96) hide show
  1. package/dist/assets/JogXNeg.d.ts +4 -0
  2. package/dist/assets/JogXNeg.d.ts.map +1 -0
  3. package/dist/assets/JogXNeg.js +1 -0
  4. package/dist/assets/JogXPos.d.ts +4 -0
  5. package/dist/assets/JogXPos.d.ts.map +1 -0
  6. package/dist/assets/JogXPos.js +1 -0
  7. package/dist/assets/JogYNeg.d.ts +4 -0
  8. package/dist/assets/JogYNeg.d.ts.map +1 -0
  9. package/dist/assets/JogYNeg.js +1 -0
  10. package/dist/assets/JogYPos.d.ts +4 -0
  11. package/dist/assets/JogYPos.d.ts.map +1 -0
  12. package/dist/assets/JogYPos.js +1 -0
  13. package/dist/assets/JogZNeg.d.ts +4 -0
  14. package/dist/assets/JogZNeg.d.ts.map +1 -0
  15. package/dist/assets/JogZNeg.js +1 -0
  16. package/dist/assets/JogZPos.d.ts +4 -0
  17. package/dist/assets/JogZPos.d.ts.map +1 -0
  18. package/dist/assets/JogZPos.js +1 -0
  19. package/dist/assets/Off.d.ts +4 -0
  20. package/dist/assets/Off.d.ts.map +1 -0
  21. package/dist/assets/Off.js +1 -0
  22. package/dist/assets/On.d.ts +4 -0
  23. package/dist/assets/On.d.ts.map +1 -0
  24. package/dist/assets/On.js +1 -0
  25. package/dist/assets/index.d.ts +8 -0
  26. package/dist/assets/index.d.ts.map +1 -1
  27. package/dist/assets/index.js +1 -1
  28. package/dist/assets/svg/off.svg +2 -0
  29. package/dist/assets/svg/on.svg +11 -0
  30. package/dist/components/JogPanel.d.ts +2 -2
  31. package/dist/components/JogPanel.d.ts.map +1 -1
  32. package/dist/components/JogPanel.js +1 -1
  33. package/dist/components/ams/AssetDetailView.js +1 -1
  34. package/dist/components/ams/AssetEditDialog.d.ts.map +1 -1
  35. package/dist/components/ams/AssetEditDialog.js +1 -1
  36. package/dist/components/ams/AssetRegistryTable.css +12 -0
  37. package/dist/components/ams/AssetRegistryTable.d.ts +1 -0
  38. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  39. package/dist/components/ams/AssetRegistryTable.js +1 -1
  40. package/dist/components/tis/ConfigurationDialog.d.ts +21 -0
  41. package/dist/components/tis/ConfigurationDialog.d.ts.map +1 -0
  42. package/dist/components/tis/ConfigurationDialog.js +1 -0
  43. package/dist/components/tis/ResultHistoryTable.js +1 -1
  44. package/dist/components/tis/TestDataView.d.ts +47 -0
  45. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  46. package/dist/components/tis/TestDataView.js +1 -1
  47. package/dist/components/tis/TestSetupForm.d.ts +37 -0
  48. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  49. package/dist/components/tis/TestSetupForm.js +1 -1
  50. package/dist/components/tis/TisProvider.d.ts +25 -0
  51. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  52. package/dist/components/tis/TisProvider.js +1 -1
  53. package/dist/components/tis/useRawCycleData.d.ts.map +1 -1
  54. package/dist/components/tis/useRawCycleData.js +1 -1
  55. package/dist/components/tis-editor/TisConfigEditor.css +20 -0
  56. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts +19 -0
  57. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts.map +1 -0
  58. package/dist/components/tis-editor/editor/ConfigurationsEditor.js +1 -0
  59. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  60. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  61. package/dist/components/tis-editor/types.d.ts +13 -0
  62. package/dist/components/tis-editor/types.d.ts.map +1 -1
  63. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  64. package/dist/components/tis-editor/validation.js +1 -1
  65. package/dist/themes/adc-dark/blue/theme.css +17 -2
  66. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  67. package/package.json +2 -1
  68. package/src/assets/JogXNeg.tsx +30 -0
  69. package/src/assets/JogXPos.tsx +30 -0
  70. package/src/assets/JogYNeg.tsx +30 -0
  71. package/src/assets/JogYPos.tsx +30 -0
  72. package/src/assets/JogZNeg.tsx +30 -0
  73. package/src/assets/JogZPos.tsx +30 -0
  74. package/src/assets/Off.tsx +14 -0
  75. package/src/assets/On.tsx +26 -0
  76. package/src/assets/index.ts +8 -0
  77. package/src/assets/svg/off.svg +2 -0
  78. package/src/assets/svg/on.svg +11 -0
  79. package/src/components/JogPanel.tsx +18 -28
  80. package/src/components/ams/AssetDetailView.tsx +1 -1
  81. package/src/components/ams/AssetEditDialog.tsx +25 -10
  82. package/src/components/ams/AssetRegistryTable.css +12 -0
  83. package/src/components/ams/AssetRegistryTable.tsx +15 -4
  84. package/src/components/tis/ConfigurationDialog.tsx +128 -0
  85. package/src/components/tis/ResultHistoryTable.tsx +2 -2
  86. package/src/components/tis/TestDataView.tsx +270 -12
  87. package/src/components/tis/TestSetupForm.tsx +167 -10
  88. package/src/components/tis/TisProvider.tsx +53 -0
  89. package/src/components/tis/useRawCycleData.ts +22 -3
  90. package/src/components/tis-editor/TisConfigEditor.css +20 -0
  91. package/src/components/tis-editor/editor/ConfigurationsEditor.tsx +242 -0
  92. package/src/components/tis-editor/editor/MethodFormEditor.tsx +4 -0
  93. package/src/components/tis-editor/types.ts +14 -0
  94. package/src/components/tis-editor/validation.ts +29 -0
  95. package/src/themes/adc-dark/_extensions.scss +20 -0
  96. package/src/themes/theme-base/components/panel/_fieldset.scss +2 -2
@@ -181,6 +181,33 @@ export interface TisContextValue {
181
181
  * method selection or after a run completes. */
182
182
  clearStagedConfig: () => void;
183
183
 
184
+ /** methodId whose schema defaults have already been seeded into
185
+ * `stagedConfig` (and written to GM for source-bound fields) for
186
+ * the current session. `<TestSetupForm>` gates its one-shot
187
+ * "apply defaults" effect on this value so it only fires on an
188
+ * actual method change — not on form remount when the operator
189
+ * switches tabs. Empty string means "no method seeded yet."
190
+ * Reset to `''` by `clearStagedConfig()` so the next staged test
191
+ * re-seeds correctly. */
192
+ defaultsAppliedForMethod: string;
193
+ /** Mark the given methodId as having been seeded. The form calls
194
+ * this immediately after writing the defaults, so subsequent
195
+ * remounts skip re-application. */
196
+ markDefaultsAppliedForMethod: (methodId: string) => void;
197
+
198
+ /** `name` of the method configuration the operator currently has
199
+ * selected (see `TestMethod.configurations`). Empty string when the
200
+ * active method declares no configurations, or none is chosen yet.
201
+ * Held on the provider — like `stagedConfig` — so the selection
202
+ * survives `<TestSetupForm>` remounting on a tab switch. HMI-only:
203
+ * it scopes which override set is shown; the override *values* it
204
+ * applies are what actually get recorded. */
205
+ configurationName: string;
206
+ /** Set the selected configuration name. The form calls this when the
207
+ * seeding effect auto-applies the first configuration and when the
208
+ * operator accepts a different one in the picker dialog. */
209
+ setConfigurationName: (name: string) => void;
210
+
184
211
  /** Fetch the run list for a (project, method?) pair. Method may be
185
212
  * omitted to aggregate runs across every method in the project —
186
213
  * the History tab uses this. */
@@ -224,6 +251,10 @@ const TisContext = createContext<TisContextValue>({
224
251
  stagedConfig: {},
225
252
  setStagedConfig: () => {},
226
253
  clearStagedConfig: () => {},
254
+ defaultsAppliedForMethod: '',
255
+ markDefaultsAppliedForMethod: () => {},
256
+ configurationName: '',
257
+ setConfigurationName: () => {},
227
258
  fetchRuns: async () => [],
228
259
  fetchRun: async () => null,
229
260
  runCache: {},
@@ -646,8 +677,26 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
646
677
  const setStagedConfig = useCallback((patch: Record<string, any>) => {
647
678
  setStagedConfigState(prev => ({ ...prev, ...patch }));
648
679
  }, []);
680
+ // Tracks which methodId's schema defaults have been seeded into
681
+ // stagedConfig + GM during this session. Owned by the provider (not
682
+ // by <TestSetupForm>) so the marker survives the form unmounting
683
+ // when the operator switches tabs — otherwise the form's seed
684
+ // effect re-fires on every remount and clobbers operator edits.
685
+ const [defaultsAppliedForMethod, setDefaultsAppliedForMethod] = useState<string>('');
686
+ const markDefaultsAppliedForMethod = useCallback((methodId: string) => {
687
+ setDefaultsAppliedForMethod(methodId);
688
+ }, []);
689
+ // Selected method configuration (TestMethod.configurations). Lives
690
+ // alongside stagedConfig so it survives a form remount; reset on a
691
+ // fresh stage so the next test re-applies the method's first config.
692
+ const [configurationName, setConfigurationName] = useState<string>('');
649
693
  const clearStagedConfig = useCallback(() => {
650
694
  setStagedConfigState({});
695
+ // Re-seed defaults on the next stage. Without this, the form
696
+ // would render empty after start_test (config cleared) because
697
+ // the seed gate would still consider the method "applied."
698
+ setDefaultsAppliedForMethod('');
699
+ setConfigurationName('');
651
700
  }, []);
652
701
 
653
702
  const value: TisContextValue = useMemo(() => ({
@@ -656,6 +705,8 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
656
705
  existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
657
706
  projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
658
707
  stagedConfig, setStagedConfig, clearStagedConfig,
708
+ defaultsAppliedForMethod, markDefaultsAppliedForMethod,
709
+ configurationName, setConfigurationName,
659
710
  fetchRuns, fetchRun, runCache,
660
711
  }), [
661
712
  schemas, projectAssetRefs, defaultMethodId, schemasLoaded,
@@ -663,6 +714,8 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
663
714
  existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
664
715
  projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
665
716
  stagedConfig, setStagedConfig, clearStagedConfig,
717
+ defaultsAppliedForMethod, markDefaultsAppliedForMethod,
718
+ configurationName, setConfigurationName,
666
719
  fetchRuns, fetchRun, runCache,
667
720
  ]);
668
721
 
@@ -73,6 +73,10 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
73
73
  const [envelope, setEnvelope] = useState<any | null>(null);
74
74
  const [loading, setLoading] = useState(false);
75
75
  const [error, setError] = useState<string | null>(null);
76
+ // Bumped to force a re-fetch of the *current* cycle's envelope when its
77
+ // on-disk content changes without the cycle selection changing — today
78
+ // that's a `chart_regions_set` patch landing for the viewed cycle.
79
+ const [refreshNonce, setRefreshNonce] = useState(0);
76
80
 
77
81
  // Track whether the user has explicitly pinned a cycle. When false,
78
82
  // the live-follow path is free to advance the picker to the newest
@@ -174,8 +178,23 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
174
178
  setSelectedCycle(newLatest);
175
179
  }
176
180
  };
177
- const id = subscribe('tis.raw_data_added', onRawAdded);
178
- return () => { unsubscribe(id); };
181
+ // Region bands patched onto a cycle that already has a trace file.
182
+ // The cycle list/selection don't change, so re-listing isn't enough —
183
+ // force a re-fetch of the envelope iff the patched cycle is the one
184
+ // on screen, which is all that needs the new `regions`.
185
+ const onRegionsSet = (payload: any) => {
186
+ if (payload?.project_id !== projectId) return;
187
+ if (payload?.method_id !== methodId) return;
188
+ if (payload?.run_id !== runId) return;
189
+ if (payload?.name !== blobName) return;
190
+ if (payload?.cycle_index === selectedRef.current) {
191
+ setRefreshNonce(n => n + 1);
192
+ }
193
+ };
194
+
195
+ const id = subscribe('tis.raw_data_added', onRawAdded);
196
+ const id2 = subscribe('tis.chart_regions_set', onRegionsSet);
197
+ return () => { unsubscribe(id); unsubscribe(id2); };
179
198
  }, [enabled, projectId, methodId, runId, blobName, listCycles, subscribe, unsubscribe]);
180
199
 
181
200
  // Lazy blob fetch — runs whenever identifiers / selectedCycle change.
@@ -221,7 +240,7 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
221
240
  }
222
241
  })();
223
242
  return () => { cancelled = true; };
224
- }, [enabled, projectId, methodId, runId, blobName, selectedCycle, invoke]);
243
+ }, [enabled, projectId, methodId, runId, blobName, selectedCycle, invoke, refreshNonce]);
225
244
 
226
245
  // Public setter wraps setSelectedCycle and flips userPinnedRef so
227
246
  // an operator's manual pick freezes the picker for the rest of the
@@ -119,3 +119,23 @@
119
119
  gap: 0.25rem;
120
120
  margin-bottom: 0.25rem;
121
121
  }
122
+
123
+ /* Portrait displays (e.g. wall-mounted HMIs rotated to portrait) don't
124
+ have the horizontal room for a fixed 320px selection pane beside the
125
+ content pane — the content pane gets squeezed and cut off. Stack the
126
+ two panes instead: the method-selection list becomes a capped,
127
+ scrollable band on top, and the editor takes the remaining height. */
128
+ @media (orientation: portrait) {
129
+ .tis-editor__body {
130
+ flex-direction: column;
131
+ }
132
+
133
+ .tis-editor__sidebar {
134
+ /* Cap the list at ~35% of the body height (it scrolls internally
135
+ via the DataTable's scrollHeight="flex"); the editor below gets
136
+ the rest. Drop the fixed 320px width — full width when stacked. */
137
+ flex: 0 1 35%;
138
+ border-right: none;
139
+ border-bottom: 1px solid var(--surface-d, #e2e8f0);
140
+ }
141
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * ConfigurationsEditor — manages a method's named `configurations`
3
+ * (e.g. translational_traction's "Plaque" vs "Shoe"). Each configuration
4
+ * carries a sparse set of config_field overrides; only the fields whose
5
+ * value is specific to that configuration need to be listed.
6
+ *
7
+ * Mirrors <AssetRefsEditor>: a DataTable of the declared configurations
8
+ * plus an add/edit Dialog. The override sub-editor is a small key/value
9
+ * table whose key is a dropdown of the method's own config_fields, so the
10
+ * author can only target fields that actually exist, and values are
11
+ * coerced to the field's declared type on save.
12
+ */
13
+
14
+ import { useMemo, useState } from 'react';
15
+ import { Dialog } from 'primereact/dialog';
16
+ import { Button } from 'primereact/button';
17
+ import { InputText } from 'primereact/inputtext';
18
+ import { InputTextarea } from 'primereact/inputtextarea';
19
+ import { Dropdown } from 'primereact/dropdown';
20
+ import { DataTable } from 'primereact/datatable';
21
+ import { Column } from 'primereact/column';
22
+ import { FormSection } from '../../forms/FormSection';
23
+ import { FormRow } from '../../forms/FormRow';
24
+ import type { TestConfiguration, TestField, TestMethod } from '../types';
25
+
26
+ export interface ConfigurationsEditorProps {
27
+ method: TestMethod;
28
+ onChange: (next: TestMethod) => void;
29
+ }
30
+
31
+ const NUMERIC_TYPES = new Set(['i32', 'i64', 'u32', 'u64', 'f32', 'f64']);
32
+
33
+ /** Coerce a raw text entry to the declared field type. Non-numeric text
34
+ * for a numeric field is left as the string so validation can flag it. */
35
+ const coerceValue = (field: TestField | undefined, raw: string): unknown => {
36
+ const t = field?.type ?? 'string';
37
+ if (t === 'bool') return raw === 'true' || raw === '1';
38
+ if (NUMERIC_TYPES.has(t)) {
39
+ const n = Number(raw);
40
+ return raw.trim() !== '' && Number.isFinite(n) ? n : raw;
41
+ }
42
+ return raw;
43
+ };
44
+
45
+ interface OverrideRow { field: string; value: string }
46
+
47
+ const blank = (): TestConfiguration => ({ name: '', defaults: {} });
48
+
49
+ const toRows = (defaults: Record<string, unknown> | undefined): OverrideRow[] =>
50
+ Object.entries(defaults ?? {}).map(([field, value]) => ({ field, value: String(value) }));
51
+
52
+ export const ConfigurationsEditor: React.FC<ConfigurationsEditorProps> = ({ method, onChange }) => {
53
+ const configs: TestConfiguration[] = (method.configurations as TestConfiguration[]) ?? [];
54
+ const configFields: TestField[] = (method.config_fields as TestField[]) ?? [];
55
+
56
+ const [dialogOpen, setDialogOpen] = useState(false);
57
+ const [editingIdx, setEditingIdx] = useState<number | null>(null);
58
+ const [draft, setDraft] = useState<TestConfiguration>(blank());
59
+ const [rows, setRows] = useState<OverrideRow[]>([]);
60
+ const [error, setError] = useState<string | null>(null);
61
+
62
+ // Fields the author can override (skip the implicit sample_id).
63
+ const fieldOptions = useMemo(
64
+ () => configFields
65
+ .filter(f => f.name && f.name !== 'sample_id')
66
+ .map(f => ({ label: f.units ? `${f.name} [${f.units}]` : f.name, value: f.name })),
67
+ [configFields],
68
+ );
69
+ const fieldByName = useMemo(() => {
70
+ const m = new Map<string, TestField>();
71
+ configFields.forEach(f => m.set(f.name, f));
72
+ return m;
73
+ }, [configFields]);
74
+
75
+ const openNew = () => {
76
+ setEditingIdx(null);
77
+ setDraft(blank());
78
+ setRows([]);
79
+ setError(null);
80
+ setDialogOpen(true);
81
+ };
82
+ const openEdit = (i: number) => {
83
+ setEditingIdx(i);
84
+ setDraft({ ...configs[i] });
85
+ setRows(toRows(configs[i].defaults));
86
+ setError(null);
87
+ setDialogOpen(true);
88
+ };
89
+
90
+ const validate = (c: TestConfiguration): string | null => {
91
+ if (!c.name.trim()) return 'Name is required.';
92
+ if (configs.some((o, i) => o.name === c.name && i !== editingIdx)) {
93
+ return `A configuration named "${c.name}" already exists.`;
94
+ }
95
+ const seen = new Set<string>();
96
+ for (const r of rows) {
97
+ if (!r.field) return 'Every override row must pick a field.';
98
+ if (seen.has(r.field)) return `Field "${r.field}" is overridden more than once.`;
99
+ seen.add(r.field);
100
+ if (!fieldByName.has(r.field)) return `"${r.field}" is not a config_field on this method.`;
101
+ }
102
+ return null;
103
+ };
104
+
105
+ const handleSave = () => {
106
+ const err = validate(draft);
107
+ if (err) { setError(err); return; }
108
+ const defaults: Record<string, unknown> = {};
109
+ for (const r of rows) {
110
+ defaults[r.field] = coerceValue(fieldByName.get(r.field), r.value);
111
+ }
112
+ const saved: TestConfiguration = {
113
+ name: draft.name.trim(),
114
+ ...(draft.label?.trim() ? { label: draft.label.trim() } : {}),
115
+ ...(draft.description?.trim() ? { description: draft.description.trim() } : {}),
116
+ defaults,
117
+ };
118
+ const next = [...configs];
119
+ if (editingIdx === null) next.push(saved);
120
+ else next[editingIdx] = saved;
121
+ onChange({ ...method, configurations: next });
122
+ setDialogOpen(false);
123
+ };
124
+
125
+ const handleRemove = (i: number) => {
126
+ if (!window.confirm(`Remove configuration "${configs[i].name}"?`)) return;
127
+ onChange({ ...method, configurations: configs.filter((_, idx) => idx !== i) });
128
+ };
129
+
130
+ const addRow = () => setRows([...rows, { field: '', value: '' }]);
131
+ const updateRow = (i: number, patch: Partial<OverrideRow>) =>
132
+ setRows(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
133
+ const removeRow = (i: number) => setRows(rows.filter((_, idx) => idx !== i));
134
+
135
+ const overridesSummary = (c: TestConfiguration): string => {
136
+ const entries = Object.entries(c.defaults ?? {});
137
+ if (entries.length === 0) return '—';
138
+ return entries.map(([k, v]) => `${k}=${String(v)}`).join(', ');
139
+ };
140
+
141
+ const rowActions = (_r: TestConfiguration, opts: { rowIndex: number }) => (
142
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
143
+ <Button icon="pi pi-pencil" className="p-button-text p-button-sm" onClick={() => openEdit(opts.rowIndex)} />
144
+ <Button icon="pi pi-trash" className="p-button-text p-button-danger p-button-sm" onClick={() => handleRemove(opts.rowIndex)} />
145
+ </div>
146
+ );
147
+
148
+ return (
149
+ <>
150
+ <FormSection
151
+ title="Configurations"
152
+ description="Named, ready-to-use override sets (e.g. Plaque vs Shoe). Selecting one in Test Setup writes its values into the matching config fields; the method's first configuration is applied automatically."
153
+ actions={<Button label="Add configuration" icon="pi pi-plus" size="small" onClick={openNew} />}
154
+ >
155
+ <DataTable value={configs} dataKey="name" emptyMessage="No configurations declared.">
156
+ <Column field="name" header="Name" style={{ width: '10rem' }} />
157
+ <Column header="Label" body={(c: TestConfiguration) => c.label ?? '—'} style={{ width: '10rem' }} />
158
+ <Column header="Overrides" body={overridesSummary} />
159
+ <Column header="" body={rowActions} style={{ width: '6rem' }} />
160
+ </DataTable>
161
+ </FormSection>
162
+
163
+ <Dialog
164
+ header={editingIdx === null ? 'New configuration' : `Edit configuration: ${configs[editingIdx ?? 0]?.name}`}
165
+ visible={dialogOpen}
166
+ onHide={() => setDialogOpen(false)}
167
+ style={{ width: '44rem' }}
168
+ >
169
+ {error && <div style={{ color: '#dc2626', marginBottom: '0.5rem' }}>{error}</div>}
170
+ <FormRow label="Name" required hint="Canonical key — unique within the method.">
171
+ <InputText value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. plaque" />
172
+ </FormRow>
173
+ <FormRow label="Label" hint="Shown in the Configuration picker. Falls back to Name.">
174
+ <InputText value={draft.label ?? ''} onChange={(e) => setDraft({ ...draft, label: e.target.value || undefined })} placeholder="e.g. Plaque" />
175
+ </FormRow>
176
+ <FormRow label="Description" hint="Shown beside the dropdown in the picker dialog.">
177
+ <InputTextarea rows={2} value={draft.description ?? ''} onChange={(e) => setDraft({ ...draft, description: e.target.value || undefined })} />
178
+ </FormRow>
179
+
180
+ <FormSection
181
+ title="Field overrides"
182
+ description="Only list fields whose value is specific to this configuration. Values are authored in display units, same as a field's default."
183
+ actions={
184
+ <Button
185
+ label="Add override"
186
+ icon="pi pi-plus"
187
+ size="small"
188
+ onClick={addRow}
189
+ disabled={fieldOptions.length === 0}
190
+ />
191
+ }
192
+ >
193
+ {fieldOptions.length === 0 ? (
194
+ <small style={{ color: 'var(--text-secondary-color)' }}>
195
+ This method has no config_fields to override yet — add some on the Fields tab first.
196
+ </small>
197
+ ) : rows.length === 0 ? (
198
+ <small style={{ color: 'var(--text-secondary-color)' }}>No overrides — this configuration uses every field's base default.</small>
199
+ ) : (
200
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
201
+ {rows.map((r, i) => {
202
+ const f = fieldByName.get(r.field);
203
+ return (
204
+ <div key={i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
205
+ <Dropdown
206
+ value={r.field || null}
207
+ options={fieldOptions}
208
+ onChange={(e) => updateRow(i, { field: e.value })}
209
+ placeholder="Field"
210
+ style={{ flex: '0 0 16rem' }}
211
+ />
212
+ {f?.type === 'bool' ? (
213
+ <Dropdown
214
+ value={r.value === 'true' || r.value === '1' ? 'true' : 'false'}
215
+ options={[{ label: 'true', value: 'true' }, { label: 'false', value: 'false' }]}
216
+ onChange={(e) => updateRow(i, { value: e.value })}
217
+ style={{ flex: 1 }}
218
+ />
219
+ ) : (
220
+ <InputText
221
+ value={r.value}
222
+ onChange={(e) => updateRow(i, { value: e.target.value })}
223
+ placeholder={f?.type && NUMERIC_TYPES.has(f.type) ? 'number' : 'value'}
224
+ style={{ flex: 1 }}
225
+ />
226
+ )}
227
+ <Button icon="pi pi-trash" className="p-button-text p-button-danger p-button-sm" onClick={() => removeRow(i)} />
228
+ </div>
229
+ );
230
+ })}
231
+ </div>
232
+ )}
233
+ </FormSection>
234
+
235
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' }}>
236
+ <Button label="Cancel" className="p-button-text" onClick={() => setDialogOpen(false)} />
237
+ <Button label="Save" onClick={handleSave} />
238
+ </div>
239
+ </Dialog>
240
+ </>
241
+ );
242
+ };
@@ -13,6 +13,7 @@ import { Button } from 'primereact/button';
13
13
  import { CodeEditor } from '../../CodeEditor';
14
14
  import { IdentitySection } from './IdentitySection';
15
15
  import { FieldArrayEditor } from './FieldArrayEditor';
16
+ import { ConfigurationsEditor } from './ConfigurationsEditor';
16
17
  import { ViewsEditor } from './ViewsEditor';
17
18
  import { RawDataEditor } from './RawDataEditor';
18
19
  import { AssetRefsEditor } from './AssetRefsEditor';
@@ -140,6 +141,9 @@ export const MethodFormEditor: React.FC<MethodFormEditorProps> = ({
140
141
  <FieldArrayEditor arrayKey="cycle_fields" method={draft} onChange={handleFormChange} />
141
142
  <FieldArrayEditor arrayKey="results_fields" method={draft} onChange={handleFormChange} />
142
143
  </TabPanel>
144
+ <TabPanel header="Configurations">
145
+ <ConfigurationsEditor method={draft} onChange={handleFormChange} />
146
+ </TabPanel>
143
147
  <TabPanel header="Views">
144
148
  <ViewsEditor method={draft} onChange={handleFormChange} />
145
149
  </TabPanel>
@@ -78,6 +78,19 @@ export interface AnalysisShape {
78
78
  function: string;
79
79
  }
80
80
 
81
+ /**
82
+ * One named configuration for a method — a sparse set of config_field
83
+ * overrides (e.g. "Plaque" vs "Shoe"). `defaults` only needs to carry the
84
+ * fields whose value is specific to this configuration; everything else
85
+ * falls back to the field's own `default`.
86
+ */
87
+ export interface TestConfiguration {
88
+ name: string;
89
+ label?: string;
90
+ description?: string;
91
+ defaults?: Record<string, unknown>;
92
+ }
93
+
81
94
  export interface TestMethod {
82
95
  label?: string;
83
96
  description?: string;
@@ -89,6 +102,7 @@ export interface TestMethod {
89
102
  views?: Record<string, ChartView>;
90
103
  asset_refs?: AssetRef[];
91
104
  analysis?: AnalysisShape | null;
105
+ configurations?: TestConfiguration[];
92
106
  [key: string]: unknown; // tolerate unknown server-side fields
93
107
  }
94
108
 
@@ -87,6 +87,35 @@ export function validateMethod(methodId: string, m: TestMethod): ValidationError
87
87
  }
88
88
  }
89
89
 
90
+ // Configurations: name non-empty + unique, and every override key
91
+ // must name a real config_field. `knownConfigFields` is the subset of
92
+ // knownFields declared under config_fields specifically — overrides
93
+ // can only target config fields.
94
+ const configFieldNames = new Set<string>(
95
+ ((m.config_fields as TestField[] | undefined) ?? []).map(f => f.name).filter(Boolean),
96
+ );
97
+ const configs = (m.configurations as Array<any> | undefined) ?? [];
98
+ const seenConfig = new Set<string>();
99
+ configs.forEach((c, i) => {
100
+ const name = typeof c?.name === 'string' ? c.name.trim() : '';
101
+ if (!name) {
102
+ errs.push({ path: `configurations.${i}.name`, message: `${methodId}.configurations[${i}]: empty configuration name` });
103
+ } else if (seenConfig.has(name)) {
104
+ errs.push({ path: `configurations.${i}.name`, message: `${methodId}.configurations: duplicate configuration name "${name}"` });
105
+ } else {
106
+ seenConfig.add(name);
107
+ }
108
+ const defaults = (c?.defaults ?? {}) as Record<string, unknown>;
109
+ for (const key of Object.keys(defaults)) {
110
+ if (!configFieldNames.has(key)) {
111
+ errs.push({
112
+ path: `configurations.${i}.defaults.${key}`,
113
+ message: `${methodId}.configurations.${name || i}: override "${key}" does not match any config_field`,
114
+ });
115
+ }
116
+ }
117
+ });
118
+
90
119
  return errs;
91
120
  }
92
121
 
@@ -215,6 +215,7 @@
215
215
  .ac-toolbar-group {
216
216
  display: flex;
217
217
  flex-direction: row;
218
+ align-items: center;
218
219
  gap: 2px;
219
220
  }
220
221
 
@@ -328,4 +329,23 @@
328
329
 
329
330
  .p-dialog .p-dialog-content {
330
331
  padding: 4mm !important;
332
+ }
333
+
334
+
335
+ /* Padding for fieldset */
336
+
337
+ .p-fieldset {
338
+ .p-fieldset-legend {
339
+ padding: 0.5mm;
340
+ }
341
+
342
+ .p-fieldset-content {
343
+ padding: 1mm;
344
+ }
345
+
346
+ border-width: 0.5mm;
347
+
348
+ .p-fieldset-legend {
349
+ border-width: 0.5mm;
350
+ }
331
351
  }
@@ -5,7 +5,7 @@
5
5
  border-radius: $borderRadius;
6
6
 
7
7
  .p-fieldset-legend {
8
- padding: $panelHeaderPadding;
8
+ padding: 0.5mm;
9
9
  border: $panelHeaderBorder;
10
10
  color: $panelHeaderTextColor;
11
11
  background: $panelHeaderBg;
@@ -42,6 +42,6 @@
42
42
  }
43
43
 
44
44
  .p-fieldset-content {
45
- padding: $panelContentPadding;
45
+ padding: 1mm;
46
46
  }
47
47
  }