@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.
- package/dist/assets/JogXNeg.d.ts +4 -0
- package/dist/assets/JogXNeg.d.ts.map +1 -0
- package/dist/assets/JogXNeg.js +1 -0
- package/dist/assets/JogXPos.d.ts +4 -0
- package/dist/assets/JogXPos.d.ts.map +1 -0
- package/dist/assets/JogXPos.js +1 -0
- package/dist/assets/JogYNeg.d.ts +4 -0
- package/dist/assets/JogYNeg.d.ts.map +1 -0
- package/dist/assets/JogYNeg.js +1 -0
- package/dist/assets/JogYPos.d.ts +4 -0
- package/dist/assets/JogYPos.d.ts.map +1 -0
- package/dist/assets/JogYPos.js +1 -0
- package/dist/assets/JogZNeg.d.ts +4 -0
- package/dist/assets/JogZNeg.d.ts.map +1 -0
- package/dist/assets/JogZNeg.js +1 -0
- package/dist/assets/JogZPos.d.ts +4 -0
- package/dist/assets/JogZPos.d.ts.map +1 -0
- package/dist/assets/JogZPos.js +1 -0
- package/dist/assets/Off.d.ts +4 -0
- package/dist/assets/Off.d.ts.map +1 -0
- package/dist/assets/Off.js +1 -0
- package/dist/assets/On.d.ts +4 -0
- package/dist/assets/On.d.ts.map +1 -0
- package/dist/assets/On.js +1 -0
- package/dist/assets/index.d.ts +8 -0
- package/dist/assets/index.d.ts.map +1 -1
- package/dist/assets/index.js +1 -1
- package/dist/assets/svg/off.svg +2 -0
- package/dist/assets/svg/on.svg +11 -0
- package/dist/components/JogPanel.d.ts +2 -2
- package/dist/components/JogPanel.d.ts.map +1 -1
- package/dist/components/JogPanel.js +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetEditDialog.d.ts.map +1 -1
- package/dist/components/ams/AssetEditDialog.js +1 -1
- package/dist/components/ams/AssetRegistryTable.css +12 -0
- package/dist/components/ams/AssetRegistryTable.d.ts +1 -0
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
- package/dist/components/ams/AssetRegistryTable.js +1 -1
- package/dist/components/tis/ConfigurationDialog.d.ts +21 -0
- package/dist/components/tis/ConfigurationDialog.d.ts.map +1 -0
- package/dist/components/tis/ConfigurationDialog.js +1 -0
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +47 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +37 -0
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +25 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis/useRawCycleData.d.ts.map +1 -1
- package/dist/components/tis/useRawCycleData.js +1 -1
- package/dist/components/tis-editor/TisConfigEditor.css +20 -0
- package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts +19 -0
- package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts.map +1 -0
- package/dist/components/tis-editor/editor/ConfigurationsEditor.js +1 -0
- package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
- package/dist/components/tis-editor/types.d.ts +13 -0
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/components/tis-editor/validation.d.ts.map +1 -1
- package/dist/components/tis-editor/validation.js +1 -1
- package/dist/themes/adc-dark/blue/theme.css +17 -2
- package/dist/themes/adc-dark/blue/theme.css.map +1 -1
- package/package.json +2 -1
- package/src/assets/JogXNeg.tsx +30 -0
- package/src/assets/JogXPos.tsx +30 -0
- package/src/assets/JogYNeg.tsx +30 -0
- package/src/assets/JogYPos.tsx +30 -0
- package/src/assets/JogZNeg.tsx +30 -0
- package/src/assets/JogZPos.tsx +30 -0
- package/src/assets/Off.tsx +14 -0
- package/src/assets/On.tsx +26 -0
- package/src/assets/index.ts +8 -0
- package/src/assets/svg/off.svg +2 -0
- package/src/assets/svg/on.svg +11 -0
- package/src/components/JogPanel.tsx +18 -28
- package/src/components/ams/AssetDetailView.tsx +1 -1
- package/src/components/ams/AssetEditDialog.tsx +25 -10
- package/src/components/ams/AssetRegistryTable.css +12 -0
- package/src/components/ams/AssetRegistryTable.tsx +15 -4
- package/src/components/tis/ConfigurationDialog.tsx +128 -0
- package/src/components/tis/ResultHistoryTable.tsx +2 -2
- package/src/components/tis/TestDataView.tsx +270 -12
- package/src/components/tis/TestSetupForm.tsx +167 -10
- package/src/components/tis/TisProvider.tsx +53 -0
- package/src/components/tis/useRawCycleData.ts +22 -3
- package/src/components/tis-editor/TisConfigEditor.css +20 -0
- package/src/components/tis-editor/editor/ConfigurationsEditor.tsx +242 -0
- package/src/components/tis-editor/editor/MethodFormEditor.tsx +4 -0
- package/src/components/tis-editor/types.ts +14 -0
- package/src/components/tis-editor/validation.ts +29 -0
- package/src/themes/adc-dark/_extensions.scss +20 -0
- 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
|
-
|
|
178
|
-
|
|
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:
|
|
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:
|
|
45
|
+
padding: 1mm;
|
|
46
46
|
}
|
|
47
47
|
}
|