@adcops/autocore-react 3.3.87 → 3.3.90

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 (112) hide show
  1. package/dist/assets/AxisC.d.ts +4 -0
  2. package/dist/assets/AxisC.d.ts.map +1 -0
  3. package/dist/assets/AxisC.js +1 -0
  4. package/dist/assets/AxisX.js +1 -1
  5. package/dist/assets/AxisY.js +1 -1
  6. package/dist/assets/AxisZ.js +1 -1
  7. package/dist/assets/JogXNeg.d.ts +4 -0
  8. package/dist/assets/JogXNeg.d.ts.map +1 -0
  9. package/dist/assets/JogXNeg.js +1 -0
  10. package/dist/assets/JogXPos.d.ts +4 -0
  11. package/dist/assets/JogXPos.d.ts.map +1 -0
  12. package/dist/assets/JogXPos.js +1 -0
  13. package/dist/assets/JogYNeg.d.ts +4 -0
  14. package/dist/assets/JogYNeg.d.ts.map +1 -0
  15. package/dist/assets/JogYNeg.js +1 -0
  16. package/dist/assets/JogYPos.d.ts +4 -0
  17. package/dist/assets/JogYPos.d.ts.map +1 -0
  18. package/dist/assets/JogYPos.js +1 -0
  19. package/dist/assets/JogZNeg.d.ts +4 -0
  20. package/dist/assets/JogZNeg.d.ts.map +1 -0
  21. package/dist/assets/JogZNeg.js +1 -0
  22. package/dist/assets/JogZPos.d.ts +4 -0
  23. package/dist/assets/JogZPos.d.ts.map +1 -0
  24. package/dist/assets/JogZPos.js +1 -0
  25. package/dist/assets/Off.d.ts +4 -0
  26. package/dist/assets/Off.d.ts.map +1 -0
  27. package/dist/assets/Off.js +1 -0
  28. package/dist/assets/On.d.ts +4 -0
  29. package/dist/assets/On.d.ts.map +1 -0
  30. package/dist/assets/On.js +1 -0
  31. package/dist/assets/index.d.ts +6 -0
  32. package/dist/assets/index.d.ts.map +1 -1
  33. package/dist/assets/index.js +1 -1
  34. package/dist/assets/svg/off.svg +2 -0
  35. package/dist/assets/svg/on.svg +11 -0
  36. package/dist/components/JogPanel.d.ts +2 -2
  37. package/dist/components/JogPanel.d.ts.map +1 -1
  38. package/dist/components/JogPanel.js +1 -1
  39. package/dist/components/ams/AmsProvider.d.ts +10 -0
  40. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  41. package/dist/components/ams/AssetDetailView.js +1 -1
  42. package/dist/components/ams/AssetEditDialog.d.ts.map +1 -1
  43. package/dist/components/ams/AssetEditDialog.js +1 -1
  44. package/dist/components/ams/AssetRegistryTable.css +12 -0
  45. package/dist/components/ams/AssetRegistryTable.d.ts +1 -0
  46. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  47. package/dist/components/ams/AssetRegistryTable.js +1 -1
  48. package/dist/components/forms/FormRow.js +1 -1
  49. package/dist/components/forms/FormSection.js +1 -1
  50. package/dist/components/forms/forms.css +18 -18
  51. package/dist/components/tis/ConfigurationDialog.d.ts +21 -0
  52. package/dist/components/tis/ConfigurationDialog.d.ts.map +1 -0
  53. package/dist/components/tis/ConfigurationDialog.js +1 -0
  54. package/dist/components/tis/ResultHistoryTable.js +1 -1
  55. package/dist/components/tis/TestDataView.d.ts +27 -0
  56. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  57. package/dist/components/tis/TestDataView.js +1 -1
  58. package/dist/components/tis/TestSetupForm.d.ts +37 -0
  59. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  60. package/dist/components/tis/TestSetupForm.js +1 -1
  61. package/dist/components/tis/TisProvider.d.ts +25 -0
  62. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  63. package/dist/components/tis/TisProvider.js +1 -1
  64. package/dist/components/tis-editor/TisConfigEditor.css +20 -0
  65. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts +19 -0
  66. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts.map +1 -0
  67. package/dist/components/tis-editor/editor/ConfigurationsEditor.js +1 -0
  68. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  69. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  70. package/dist/components/tis-editor/types.d.ts +13 -0
  71. package/dist/components/tis-editor/types.d.ts.map +1 -1
  72. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  73. package/dist/components/tis-editor/validation.js +1 -1
  74. package/dist/themes/adc-dark/blue/theme.css +3 -2
  75. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  76. package/package.json +2 -1
  77. package/src/assets/AxisC.tsx +38 -0
  78. package/src/assets/AxisX.tsx +32 -32
  79. package/src/assets/AxisY.tsx +34 -34
  80. package/src/assets/AxisZ.tsx +31 -31
  81. package/src/assets/JogXNeg.tsx +30 -0
  82. package/src/assets/JogXPos.tsx +30 -0
  83. package/src/assets/JogYNeg.tsx +30 -0
  84. package/src/assets/JogYPos.tsx +30 -0
  85. package/src/assets/JogZNeg.tsx +30 -0
  86. package/src/assets/JogZPos.tsx +30 -0
  87. package/src/assets/Off.tsx +14 -0
  88. package/src/assets/On.tsx +26 -0
  89. package/src/assets/index.ts +6 -0
  90. package/src/assets/svg/off.svg +2 -0
  91. package/src/assets/svg/on.svg +11 -0
  92. package/src/components/JogPanel.tsx +18 -28
  93. package/src/components/ams/AmsProvider.tsx +10 -0
  94. package/src/components/ams/AssetDetailView.tsx +1 -1
  95. package/src/components/ams/AssetEditDialog.tsx +25 -10
  96. package/src/components/ams/AssetRegistryTable.css +12 -0
  97. package/src/components/ams/AssetRegistryTable.tsx +68 -12
  98. package/src/components/forms/FormRow.tsx +6 -6
  99. package/src/components/forms/FormSection.tsx +6 -6
  100. package/src/components/forms/forms.css +18 -18
  101. package/src/components/tis/ConfigurationDialog.tsx +128 -0
  102. package/src/components/tis/ResultHistoryTable.tsx +2 -2
  103. package/src/components/tis/TestDataView.tsx +83 -1
  104. package/src/components/tis/TestSetupForm.tsx +167 -10
  105. package/src/components/tis/TisProvider.tsx +53 -0
  106. package/src/components/tis-editor/TisConfigEditor.css +20 -0
  107. package/src/components/tis-editor/editor/ConfigurationsEditor.tsx +242 -0
  108. package/src/components/tis-editor/editor/MethodFormEditor.tsx +4 -0
  109. package/src/components/tis-editor/types.ts +14 -0
  110. package/src/components/tis-editor/validation.ts +29 -0
  111. package/src/themes/adc-dark/_extensions.scss +1 -0
  112. package/src/themes/theme-base/components/panel/_fieldset.scss +2 -2
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
1
+ import React, { useState, useEffect, useContext, useMemo } from 'react';
2
2
  import { Button } from 'primereact/button';
3
3
  import { InputText } from 'primereact/inputtext';
4
4
  import { Dropdown } from 'primereact/dropdown';
@@ -11,6 +11,7 @@ import { TextInput } from '../TextInput';
11
11
  import { useTis } from './TisProvider';
12
12
  import { useAmsAssets, useAmsRoles, type AmsAssetEntry } from '../ams/AmsProvider';
13
13
  import { TestMethodDialog } from './TestMethodDialog';
14
+ import { ConfigurationDialog, configLabelOf } from './ConfigurationDialog';
14
15
 
15
16
  /**
16
17
  * One asset_ref declared on a test method. We only consume the subset
@@ -59,6 +60,19 @@ export interface TestFieldDef {
59
60
  * Cycle and results values are scaled by the corresponding paths
60
61
  * in TestDataView; the server scales CSV exports too. */
61
62
  scale?: number;
63
+ /** Optional fixed set of choices. When present, the field renders
64
+ * as a dropdown and the operator must pick one of the declared
65
+ * values rather than typing freely. Each entry is either a bare
66
+ * scalar (label === value) or an explicit `{ label, value }` pair
67
+ * when the displayed text should differ from the stored value.
68
+ * Works with any `type`; like `default`, values are authored in
69
+ * display units when `scale` is set. */
70
+ options?: Array<
71
+ | string
72
+ | number
73
+ | boolean
74
+ | { label?: string; value: string | number | boolean }
75
+ >;
62
76
  }
63
77
 
64
78
  export interface TestMethod {
@@ -90,6 +104,33 @@ export interface TestMethod {
90
104
  * as `analysis` — the form ignores them; the type just has to
91
105
  * accept them so generated schemas typecheck. */
92
106
  views?: Record<string, any>;
107
+ /**
108
+ * Optional named configurations for this method. When one or more
109
+ * are declared, the form renders a Configuration selector beneath
110
+ * the Test Method picker. Selecting (or accepting) a configuration
111
+ * writes its `defaults` into the matching config_fields. Each
112
+ * configuration only needs to declare the fields whose value is
113
+ * specific to it — unlisted fields keep the method's base default.
114
+ */
115
+ configurations?: TestConfiguration[];
116
+ }
117
+
118
+ /**
119
+ * One named, ready-to-use set of config_field overrides for a method —
120
+ * e.g. translational_traction's "Plaque" vs "Shoe". `defaults` is a
121
+ * sparse map of `config_field.name → value`; values are authored in
122
+ * display units (the form converts via the field's `scale` before
123
+ * writing to stagedConfig / GM, same convention as `TestFieldDef.default`).
124
+ */
125
+ export interface TestConfiguration {
126
+ /** Canonical key — unique within the method. */
127
+ name: string;
128
+ /** Pretty label shown in the Configuration picker. Falls back to `name`. */
129
+ label?: string;
130
+ /** Long-form guidance shown beside the dropdown in the picker dialog. */
131
+ description?: string;
132
+ /** Sparse `config_field.name → value` overrides applied on selection. */
133
+ defaults?: Record<string, any>;
93
134
  }
94
135
 
95
136
  /**
@@ -145,6 +186,20 @@ const displayToRaw = (display: any, scale: number | undefined): any => {
145
186
  const hasDescription = (f: TestFieldDef): boolean =>
146
187
  typeof f.description === 'string' && f.description.length > 0;
147
188
 
189
+ /**
190
+ * Normalise a field's `options` (bare scalars and/or `{label, value}`
191
+ * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown
192
+ * wants. Bare scalars use their stringified form as the label.
193
+ */
194
+ const normalizeOptions = (
195
+ opts: TestFieldDef['options'],
196
+ ): { label: string; value: string | number | boolean }[] =>
197
+ (opts ?? []).map((o) =>
198
+ o !== null && typeof o === 'object'
199
+ ? { label: String(o.label ?? o.value), value: o.value }
200
+ : { label: String(o), value: o },
201
+ );
202
+
148
203
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
149
204
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
150
205
 
@@ -240,6 +295,28 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
240
295
 
241
296
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
242
297
 
298
+ // Write one configuration's sparse `defaults` overrides into a config
299
+ // object in place (returns the same object for chaining). Mirrors the
300
+ // base-default seeding below: values are authored in display units, so
301
+ // we convert to raw before storing; source-bound fields are also
302
+ // pushed to GM so the control program sees them. Used both by the
303
+ // auto-apply-first effect and by the picker dialog's accept handler.
304
+ const applyConfigOverridesInto = (cfg: TestConfiguration, target: any): any => {
305
+ for (const [fieldName, val] of Object.entries(cfg.defaults ?? {})) {
306
+ if (fieldName === 'sample_id') continue;
307
+ const field = schema?.config_fields.find((f: TestFieldDef) => f.name === fieldName);
308
+ const rawVal = displayToRaw(val, field?.scale);
309
+ target[fieldName] = rawVal;
310
+ if (field?.source) {
311
+ void Promise.resolve()
312
+ .then(() => write(field.source!, rawVal))
313
+ .catch(e => console.error(
314
+ `[TestSetupForm] Failed to apply configuration override for ${fieldName}:`, e));
315
+ }
316
+ }
317
+ return target;
318
+ };
319
+
243
320
  useEffect(() => {
244
321
  if (tis.selection.methodId !== methodId && methodId) {
245
322
  tis.setSelection({ methodId });
@@ -274,6 +351,19 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
274
351
 
275
352
  const [isValid, setIsValid] = useState(false);
276
353
  const [methodPickerOpen, setMethodPickerOpen] = useState(false);
354
+ const [configPickerOpen, setConfigPickerOpen] = useState(false);
355
+
356
+ const configurations = (schema?.configurations ?? []) as TestConfiguration[];
357
+ const selectedConfig = configurations.find(c => c.name === tis.configurationName);
358
+
359
+ // Operator accepted a configuration in the picker dialog — write its
360
+ // overrides into the fields and remember the selection.
361
+ const handleConfigurationSelected = (configName: string) => {
362
+ const cfg = configurations.find(c => c.name === configName);
363
+ if (!cfg) return;
364
+ setConfig((prev: any) => applyConfigOverridesInto(cfg, { ...prev }));
365
+ tis.setConfigurationName(configName);
366
+ };
277
367
 
278
368
  // Single shared "info" dialog used by:
279
369
  // - the Test Setup status button (shows what's wrong, including
@@ -288,14 +378,23 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
288
378
  // Apply schema-declared defaults when the operator picks a method.
289
379
  // Source-bound fields write the default to GM (the control program
290
380
  // is the consumer of record); non-source fields land directly in
291
- // stagedConfig. We track the last method we seeded so subsequent
292
- // re-renders don't clobber operator edits only an actual method
293
- // change re-applies the defaults.
294
- const defaultsAppliedFor = useRef<string>('');
381
+ // stagedConfig. The "already seeded" marker lives on the provider
382
+ // (tis.defaultsAppliedForMethod), not in a local ref, so it
383
+ // survives this form unmounting when the operator switches tabs —
384
+ // otherwise defaults would re-apply on every Test-tab return and
385
+ // clobber the operator's edits. Only an actual method change (or
386
+ // clearStagedConfig() after start_test) re-applies the defaults.
295
387
  useEffect(() => {
296
388
  if (!schema || !methodId) return;
297
- if (defaultsAppliedFor.current === methodId) return;
298
- defaultsAppliedFor.current = methodId;
389
+ if (tis.defaultsAppliedForMethod === methodId) return;
390
+ tis.markDefaultsAppliedForMethod(methodId);
391
+
392
+ // Auto-apply the method's first configuration (if any) on top of
393
+ // the base defaults. '' when the method declares none, which also
394
+ // clears any stale selection carried over from a previous method.
395
+ const firstConfig = (schema.configurations && schema.configurations.length > 0)
396
+ ? schema.configurations[0] as TestConfiguration
397
+ : undefined;
299
398
 
300
399
  setConfig((prev: any) => {
301
400
  let next = prev;
@@ -321,9 +420,17 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
321
420
  `[TestSetupForm] Failed to seed default for ${field.name}:`, e));
322
421
  }
323
422
  }
423
+ // Configuration overrides win over base defaults for the
424
+ // fields they name (e.g. Plaque's z_start_position).
425
+ if (firstConfig) {
426
+ if (next === prev) next = { ...prev };
427
+ next = applyConfigOverridesInto(firstConfig, next);
428
+ }
324
429
  return next;
325
430
  });
326
- }, [schema, methodId, write]);
431
+
432
+ tis.setConfigurationName(firstConfig ? firstConfig.name : '');
433
+ }, [schema, methodId, write, tis.defaultsAppliedForMethod, tis.markDefaultsAppliedForMethod, tis.setConfigurationName]);
327
434
 
328
435
  // Seed and live-update config_fields that declare a `source`.
329
436
  useEffect(() => {
@@ -451,7 +558,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
451
558
  if (field.name === 'sample_id') return null;
452
559
  const valid = isFieldValid(field);
453
560
  const assetType = assetTypeForField(field);
454
- const isNum = !assetType && field.type !== 'string' && field.type !== 'bool';
561
+ // A schema-declared `options` list turns the field into a
562
+ // dropdown (skipped when an asset_ref already claims the field
563
+ // — that picker is sourced from AMS, not the static list).
564
+ const dropdownOptions = !assetType && Array.isArray(field.options) && field.options.length > 0
565
+ ? normalizeOptions(field.options)
566
+ : null;
567
+ const isNum = !assetType && !dropdownOptions
568
+ && field.type !== 'string' && field.type !== 'bool';
455
569
  return (
456
570
  <React.Fragment key={field.name}>
457
571
  <span className="ac-form-label">{labelOf(field)}</span>
@@ -462,6 +576,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
462
576
  onChange={(val) => handleFieldChange(field, val)}
463
577
  invalid={!valid}
464
578
  />
579
+ ) : dropdownOptions ? (
580
+ <Dropdown
581
+ value={config[field.name] ?? null}
582
+ options={dropdownOptions}
583
+ onChange={(e) => handleFieldChange(field, e.value)}
584
+ placeholder={`Select ${field.label ?? field.name}…`}
585
+ className={!valid ? 'p-invalid' : ''}
586
+ showClear={!field.required}
587
+ />
465
588
  ) : isNum ? (
466
589
  <ValueInput
467
590
  label={undefined}
@@ -664,7 +787,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
664
787
  tabIndex={-1}
665
788
  />
666
789
  <Button
667
- icon="pi pi-pencil"
790
+ icon="pi pi-folder"
668
791
  type="button"
669
792
  onClick={() => setMethodPickerOpen(true)}
670
793
  tooltip={methodIds.length > 1
@@ -680,6 +803,32 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
680
803
  </>
681
804
  )}
682
805
 
806
+ {configurations.length > 0 && (
807
+ <>
808
+ <span className="ac-form-label">Configuration</span>
809
+ <div className="p-inputgroup" style={{ flex: 1 }}>
810
+ <InputText
811
+ value={selectedConfig ? configLabelOf(selectedConfig) : ''}
812
+ placeholder="No configuration selected"
813
+ readOnly
814
+ style={{ flex: 1 }}
815
+ tabIndex={-1}
816
+ />
817
+ <Button
818
+ icon="pi pi-folder"
819
+ type="button"
820
+ onClick={() => setConfigPickerOpen(true)}
821
+ tooltip="Change configuration"
822
+ tooltipOptions={{ position: 'top' }}
823
+ />
824
+ </div>
825
+ <span aria-hidden="true" />
826
+ <span style={{ color: selectedConfig ? 'var(--green-500)' : 'var(--text-secondary-color)', display: 'flex', alignItems: 'center' }}>
827
+ <i className={selectedConfig ? 'pi pi-check' : 'pi pi-minus'} />
828
+ </span>
829
+ </>
830
+ )}
831
+
683
832
  <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
684
833
  {schema.config_fields.map(renderConfigField)}
685
834
 
@@ -690,6 +839,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
690
839
  onSelected={(picked) => setMethodIdLocal(picked)}
691
840
  />
692
841
 
842
+ <ConfigurationDialog
843
+ visible={configPickerOpen}
844
+ onHide={() => setConfigPickerOpen(false)}
845
+ configurations={configurations}
846
+ currentConfigName={tis.configurationName}
847
+ onSelected={handleConfigurationSelected}
848
+ />
849
+
693
850
  {/* Shared info dialog. Driven by the Test Setup status
694
851
  button (validation + server errors) and by per-field
695
852
  info buttons (replaces the old hover Tooltip). One
@@ -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
 
@@ -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>