@adcops/autocore-react 3.3.67 → 3.3.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,31 @@
1
1
  import React, { useState, useEffect, useContext, useMemo } from 'react';
2
2
  import { Button } from 'primereact/button';
3
3
  import { InputText } from 'primereact/inputtext';
4
- import { Tooltip } from 'primereact/tooltip';
4
+ import { Dropdown } from 'primereact/dropdown';
5
+ import { Dialog } from 'primereact/dialog';
5
6
  import { EventEmitterContext } from '../../core/EventEmitterContext';
6
7
  import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
7
8
  import { MessageType } from '../../hub/CommandMessage';
8
9
  import { ValueInput } from '../ValueInput';
9
10
  import { TextInput } from '../TextInput';
10
11
  import { useTis } from './TisProvider';
12
+ import { useAmsAssets, useAmsRoles, type AmsAssetEntry } from '../ams/AmsProvider';
11
13
  import { TestMethodDialog } from './TestMethodDialog';
12
14
 
15
+ /**
16
+ * One asset_ref declared on a test method. We only consume the subset
17
+ * the form cares about — `field`, `asset_type`, `select`, `from`,
18
+ * `location`. Other fields (calibration_required, label, description)
19
+ * exist on the wire but aren't form-relevant.
20
+ */
21
+ interface TisAssetRef {
22
+ field: string;
23
+ asset_type: string;
24
+ select: 'by_location' | 'by_id_field';
25
+ from?: string;
26
+ location?: string;
27
+ }
28
+
13
29
  export interface TestFieldDef {
14
30
  /** Canonical key — wire format, generated code, on-disk JSON. */
15
31
  name: string;
@@ -29,12 +45,30 @@ export interface TestMethod {
29
45
  config_fields: TestFieldDef[];
30
46
  cycle_fields: TestFieldDef[];
31
47
  results_fields: TestFieldDef[];
48
+ /**
49
+ * AMS asset references resolved at start_test. The form scans these
50
+ * for `select=by_id_field` entries pointing at a config field
51
+ * (`from: "config.<name>"`) and renders that field as a Dropdown of
52
+ * matching AMS assets instead of a free-form text input. No
53
+ * project.json change required — the form derives this from the
54
+ * existing schema.
55
+ */
56
+ asset_refs?: TisAssetRef[];
32
57
  /** Optional pretty label for the Test Method picker. Falls back
33
58
  * to the canonical method_id key. */
34
59
  label?: string;
35
60
  /** Optional long-form description shown in the picker dialog
36
61
  * when this method is highlighted. */
37
62
  description?: string;
63
+ /** Optional post-cycle analysis dispatch (`{ script, function }`).
64
+ * Consumed server-side by the codegen; the form doesn't render
65
+ * anything based on it but accepts it so generated schema
66
+ * literals from `acctl codegen-tags` typecheck cleanly. */
67
+ analysis?: any;
68
+ /** Free-form view declarations for chart components. Same rationale
69
+ * as `analysis` — the form ignores them; the type just has to
70
+ * accept them so generated schemas typecheck. */
71
+ views?: Record<string, any>;
38
72
  }
39
73
 
40
74
  /**
@@ -69,6 +103,60 @@ const hasDescription = (f: TestFieldDef): boolean =>
69
103
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
70
104
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
71
105
 
106
+ /**
107
+ * Dropdown over the active subset of AMS assets of a given type. Used
108
+ * for config fields that an `asset_ref` resolves via
109
+ * `select=by_id_field` — the operator picks an asset by ID from the
110
+ * registry rather than typing it. The dropdown's option labels
111
+ * include the asset's role (when the asset has a known role) and
112
+ * serial so the operator can identify "the right one" without leaving
113
+ * the form.
114
+ *
115
+ * Falls back to a graceful empty state when the AMS provider isn't
116
+ * mounted (no assets) — reads as "No matching assets registered" so
117
+ * the operator knows the gap and can go register one in Settings.
118
+ */
119
+ const AssetIdPicker: React.FC<{
120
+ assetType: string;
121
+ value: string;
122
+ onChange: (val: string) => void;
123
+ invalid?: boolean;
124
+ }> = ({ assetType, value, onChange, invalid }) => {
125
+ const assets = useAmsAssets();
126
+ const roles = useAmsRoles();
127
+
128
+ const options = useMemo(() => {
129
+ const filtered = (assets as AmsAssetEntry[])
130
+ .filter(a => a.asset_type === assetType && a.status === 'active');
131
+ return filtered.map(a => {
132
+ const roleLabel = roles[a.asset_type]
133
+ ?.find(r => r.location === a.location)?.label;
134
+ const labelParts = [a.asset_id];
135
+ if (roleLabel) labelParts.push(`— ${roleLabel}`);
136
+ else if (a.location) labelParts.push(`— ${a.location}`);
137
+ if (a.serial) labelParts.push(`(s/n ${a.serial})`);
138
+ return { label: labelParts.join(' '), value: a.asset_id };
139
+ });
140
+ }, [assets, roles, assetType]);
141
+
142
+ return (
143
+ <Dropdown
144
+ value={value}
145
+ options={options}
146
+ onChange={(e) => onChange(e.value ?? '')}
147
+ placeholder={
148
+ options.length === 0
149
+ ? `No active ${assetType} assets registered — add one in Settings → Assets`
150
+ : `Select ${assetType}…`
151
+ }
152
+ className={invalid ? 'p-invalid' : ''}
153
+ filter
154
+ showClear
155
+ disabled={options.length === 0}
156
+ />
157
+ );
158
+ };
159
+
72
160
  // -------------------------------------------------------------------------
73
161
 
74
162
  export const TestSetupForm: React.FC<TestSetupFormProps> = ({
@@ -92,8 +180,18 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
92
180
  const [methodId, setMethodIdLocal] = useState<string>(
93
181
  tis.selection.methodId || defaultMethodId || tis.defaultMethodId || ''
94
182
  );
95
- const [sampleId, setSampleIdLocal] = useState<string>('');
96
- const [config, setConfig] = useState<any>({});
183
+ // Sample ID + config_field values come from the TisProvider rather
184
+ // than local React state so they survive form unmount when the
185
+ // operator switches tabs. Initial value reads from selection /
186
+ // stagedConfig; subsequent edits write back to the provider.
187
+ const [sampleId, setSampleIdLocal] = useState<string>(tis.selection.sampleId || '');
188
+ const config = tis.stagedConfig;
189
+ const setConfig = (updater: any) => {
190
+ const next = typeof updater === 'function' ? updater(tis.stagedConfig) : updater;
191
+ if (next !== tis.stagedConfig) {
192
+ tis.setStagedConfig(next);
193
+ }
194
+ };
97
195
 
98
196
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
99
197
 
@@ -132,6 +230,16 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
132
230
  const [isValid, setIsValid] = useState(false);
133
231
  const [methodPickerOpen, setMethodPickerOpen] = useState(false);
134
232
 
233
+ // Single shared "info" dialog used by:
234
+ // - the Test Setup status button (shows what's wrong, including
235
+ // server-side last_start_error)
236
+ // - the per-field info buttons (touch-friendly replacement for
237
+ // the old hover-tooltip)
238
+ const [infoDialog, setInfoDialog] = useState<{ open: boolean; title: string; body: React.ReactNode }>(
239
+ { open: false, title: '', body: null },
240
+ );
241
+ const closeInfoDialog = () => setInfoDialog({ open: false, title: '', body: null });
242
+
135
243
  // Seed and live-update config_fields that declare a `source`.
136
244
  useEffect(() => {
137
245
  if (!schema) return;
@@ -218,15 +326,38 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
218
326
  }
219
327
  };
220
328
 
329
+ /**
330
+ * If the test method has an `asset_ref` whose `select=by_id_field`
331
+ * pulls from this config field, return the asset_type the field
332
+ * should pick from. The form renders that field as a dropdown of
333
+ * AMS assets instead of a free-form text input.
334
+ *
335
+ * The schema already encodes the relationship via `from:
336
+ * "config.<field_name>"` — no project.json change required.
337
+ */
338
+ const assetTypeForField = (field: TestFieldDef): string | null => {
339
+ const refs = (schema?.asset_refs ?? []) as TisAssetRef[];
340
+ const expectedFrom = `config.${field.name}`;
341
+ const hit = refs.find(r => r.select === 'by_id_field' && r.from === expectedFrom);
342
+ return hit ? hit.asset_type : null;
343
+ };
344
+
221
345
  const renderConfigField = (field: TestFieldDef) => {
222
346
  if (field.name === 'sample_id') return null;
223
347
  const valid = isFieldValid(field);
224
- const isNum = field.type !== 'string' && field.type !== 'bool';
225
- const tooltipId = `acFormInfo_${field.name}`;
348
+ const assetType = assetTypeForField(field);
349
+ const isNum = !assetType && field.type !== 'string' && field.type !== 'bool';
226
350
  return (
227
351
  <React.Fragment key={field.name}>
228
352
  <span className="ac-form-label">{labelOf(field)}</span>
229
- {isNum ? (
353
+ {assetType ? (
354
+ <AssetIdPicker
355
+ assetType={assetType}
356
+ value={config[field.name] != null ? String(config[field.name]) : ''}
357
+ onChange={(val) => handleFieldChange(field, val)}
358
+ invalid={!valid}
359
+ />
360
+ ) : isNum ? (
230
361
  <ValueInput
231
362
  label={undefined}
232
363
  value={config[field.name] != null ? Number(config[field.name]) : null}
@@ -242,16 +373,19 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
242
373
  />
243
374
  )}
244
375
  {hasDescription(field) ? (
245
- <>
246
- <Tooltip target={`#${tooltipId}`} position="left" />
247
- <span
248
- id={tooltipId}
249
- data-pr-tooltip={field.description}
250
- style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
251
- >
252
- <i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
253
- </span>
254
- </>
376
+ <Button
377
+ icon="pi pi-info-circle"
378
+ text
379
+ rounded
380
+ aria-label={`About ${labelOf(field)}`}
381
+ onClick={() => setInfoDialog({
382
+ open: true,
383
+ title: labelOf(field),
384
+ body: <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{field.description}</p>,
385
+ })}
386
+ // Replaces a hover Tooltip — touch-friendly. The
387
+ // description is shown in the shared info dialog.
388
+ />
255
389
  ) : (
256
390
  <span aria-hidden="true" />
257
391
  )}
@@ -295,13 +429,97 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
295
429
  gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
296
430
  };
297
431
 
432
+ // Build the list of reasons the form is invalid. Used by the
433
+ // status button to show actionable diagnostics, not just a red icon.
434
+ const validationIssues = (): string[] => {
435
+ const issues: string[] = [];
436
+ if (!projectExists) {
437
+ issues.push('No project is selected. Pick or create one on the Project tab.');
438
+ }
439
+ if (!tis.projectFieldsLoaded) {
440
+ issues.push('Project fields are still loading.');
441
+ }
442
+ if (!methodId.trim()) {
443
+ issues.push('No test method selected.');
444
+ }
445
+ if (!sampleId.trim()) {
446
+ issues.push('Sample ID is required.');
447
+ }
448
+ if (schema) {
449
+ for (const field of schema.config_fields) {
450
+ if (field.name === 'sample_id') continue;
451
+ if (!field.required) continue;
452
+ const v = config[field.name];
453
+ if (v === undefined || v === '' || v === null) {
454
+ issues.push(`Required field "${labelOf(field)}" is empty.`);
455
+ }
456
+ }
457
+ }
458
+ return issues;
459
+ };
460
+
461
+ const openStatusDialog = () => {
462
+ const issues = validationIssues();
463
+ const serverErr = tis.state.lastStartError?.trim() ?? '';
464
+ let body: React.ReactNode;
465
+ if (issues.length === 0 && !serverErr) {
466
+ body = (
467
+ <p style={{ margin: 0 }}>
468
+ All required fields are complete. The test is staged and ready to start.
469
+ </p>
470
+ );
471
+ } else {
472
+ body = (
473
+ <div>
474
+ {issues.length > 0 && (
475
+ <>
476
+ <p style={{ margin: '0 0 0.5rem 0' }}>
477
+ The form is incomplete:
478
+ </p>
479
+ <ul style={{ margin: '0 0 1rem 1.25rem' }}>
480
+ {issues.map((m, i) => <li key={i}>{m}</li>)}
481
+ </ul>
482
+ </>
483
+ )}
484
+ {serverErr && (
485
+ <>
486
+ <p style={{ margin: '0 0 0.25rem 0', fontWeight: 600 }}>
487
+ Last start_test error from the server:
488
+ </p>
489
+ <pre style={{
490
+ margin: 0,
491
+ padding: '0.5rem',
492
+ background: 'rgba(0,0,0,0.25)',
493
+ borderRadius: '4px',
494
+ whiteSpace: 'pre-wrap',
495
+ fontSize: '0.875rem',
496
+ }}>{serverErr}</pre>
497
+ </>
498
+ )}
499
+ </div>
500
+ );
501
+ }
502
+ setInfoDialog({ open: true, title: 'Test Setup Status', body });
503
+ };
504
+
505
+ // The icon doubles as a button so the operator can drill into the
506
+ // *reason* the form isn't ready, including server-side errors that
507
+ // don't have a corresponding red field (most importantly, AMS
508
+ // resolution failures from the last Start attempt).
509
+ const statusValid = isValid && !tis.state.lastStartError;
510
+
298
511
  return (
299
512
  <div className="ac-form-grid" style={gridStyle}>
300
513
  <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
301
514
  Test Setup
302
- <span style={{ color: isValid ? 'var(--green-500)' : 'var(--red-500)' }}>
303
- <i className={isValid ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'} />
304
- </span>
515
+ <Button
516
+ icon={statusValid ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'}
517
+ severity={statusValid ? 'success' : 'danger'}
518
+ text
519
+ rounded
520
+ aria-label="Test Setup status"
521
+ onClick={openStatusDialog}
522
+ />
305
523
  <span style={{
306
524
  fontSize: '0.85em',
307
525
  color: 'var(--text-secondary-color)',
@@ -360,6 +578,21 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
360
578
  currentMethodId={methodId}
361
579
  onSelected={(picked) => setMethodIdLocal(picked)}
362
580
  />
581
+
582
+ {/* Shared info dialog. Driven by the Test Setup status
583
+ button (validation + server errors) and by per-field
584
+ info buttons (replaces the old hover Tooltip). One
585
+ Dialog instance keeps the surface stack shallow. */}
586
+ <Dialog
587
+ header={infoDialog.title}
588
+ visible={infoDialog.open}
589
+ onHide={closeInfoDialog}
590
+ style={{ width: '32rem', maxWidth: '90vw' }}
591
+ modal
592
+ dismissableMask
593
+ >
594
+ {infoDialog.body}
595
+ </Dialog>
363
596
  </div>
364
597
  );
365
598
  };
@@ -55,6 +55,17 @@ export interface TisLiveState {
55
55
  activeMethodId: string;
56
56
  activeSampleId: string;
57
57
  activeRunId: string;
58
+
59
+ /**
60
+ * Most recent `tis.start_test` failure message broadcast by the
61
+ * server, or empty string when the last start succeeded (or none
62
+ * has been attempted). Used by `<TestSetupForm>` to surface AMS
63
+ * resolution errors and other server-side rejections inline,
64
+ * instead of leaving the operator staring at a non-responsive
65
+ * Start button. Cleared by the server on the next successful
66
+ * start_test.
67
+ */
68
+ lastStartError: string;
58
69
  }
59
70
 
60
71
  export interface TisSelection {
@@ -129,6 +140,22 @@ export interface TisContextValue {
129
140
  * by the create / edit dialogs after a successful submit. */
130
141
  setProjectFields: (id: string, fields: Record<string, any>) => void;
131
142
 
143
+ /**
144
+ * In-progress test draft — the operator's pending Sample ID and
145
+ * config_field values for the active method. Held on the provider
146
+ * (not in `<TestSetupForm>`'s local React state) so the values
147
+ * survive remount when the operator switches tabs. Wiped via
148
+ * `clearStagedConfig()` after a successful start_test or when the
149
+ * operator cancels the staged record.
150
+ */
151
+ stagedConfig: Record<string, any>;
152
+ /** Merge a partial patch into `stagedConfig`. Existing fields not
153
+ * mentioned in the patch are preserved. */
154
+ setStagedConfig: (patch: Record<string, any>) => void;
155
+ /** Reset `stagedConfig` to `{}`. Called by the form on a fresh
156
+ * method selection or after a run completes. */
157
+ clearStagedConfig: () => void;
158
+
132
159
  /** Fetch the run list for a (project, method?) pair. Method may be
133
160
  * omitted to aggregate runs across every method in the project —
134
161
  * the History tab uses this. */
@@ -146,6 +173,7 @@ export interface TisContextValue {
146
173
  const EMPTY_STATE: TisLiveState = {
147
174
  staged: false, stagedProjectId: '', stagedMethodId: '', stagedSampleId: '',
148
175
  active: false, activeProjectId: '', activeMethodId: '', activeSampleId: '', activeRunId: '',
176
+ lastStartError: '',
149
177
  };
150
178
 
151
179
  const EMPTY_SELECTION: TisSelection = {
@@ -167,6 +195,9 @@ const TisContext = createContext<TisContextValue>({
167
195
  projectFieldsLoaded: false,
168
196
  loadProjectFields: async () => null,
169
197
  setProjectFields: () => {},
198
+ stagedConfig: {},
199
+ setStagedConfig: () => {},
200
+ clearStagedConfig: () => {},
170
201
  fetchRuns: async () => [],
171
202
  fetchRun: async () => null,
172
203
  runCache: {},
@@ -185,7 +216,8 @@ type StateAction =
185
216
  | { kind: 'active_project_id'; value: string }
186
217
  | { kind: 'active_method_id'; value: string }
187
218
  | { kind: 'active_sample_id'; value: string }
188
- | { kind: 'active_run_id'; value: string };
219
+ | { kind: 'active_run_id'; value: string }
220
+ | { kind: 'last_start_error'; value: string };
189
221
 
190
222
  function liveReducer(s: TisLiveState, a: StateAction): TisLiveState {
191
223
  switch (a.kind) {
@@ -198,6 +230,7 @@ function liveReducer(s: TisLiveState, a: StateAction): TisLiveState {
198
230
  case 'active_method_id': return { ...s, activeMethodId: a.value };
199
231
  case 'active_sample_id': return { ...s, activeSampleId: a.value };
200
232
  case 'active_run_id': return { ...s, activeRunId: a.value };
233
+ case 'last_start_error': return { ...s, lastStartError: a.value };
201
234
  }
202
235
  }
203
236
 
@@ -279,6 +312,7 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
279
312
  subscribe('tis.active_method_id', (v: any) => dispatch({ kind: 'active_method_id', value: String(v ?? '') })),
280
313
  subscribe('tis.active_sample_id', (v: any) => dispatch({ kind: 'active_sample_id', value: String(v ?? '') })),
281
314
  subscribe('tis.active_run_id', (v: any) => dispatch({ kind: 'active_run_id', value: String(v ?? '') })),
315
+ subscribe('tis.last_start_error', (v: any) => dispatch({ kind: 'last_start_error', value: String(v ?? '') })),
282
316
  ];
283
317
  return () => { subs.forEach(unsubscribe); };
284
318
  }, [subscribe, unsubscribe]);
@@ -506,17 +540,32 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
506
540
  const projectFields = projectFieldsCache[selection.projectId] ?? {};
507
541
  const projectFieldsLoaded = projectFieldsCache[selection.projectId] !== undefined;
508
542
 
543
+ // Operator's pending Sample ID and config_field values live here
544
+ // (rather than as local state in <TestSetupForm>) so they survive
545
+ // the form unmounting when the user switches to another tab. The
546
+ // form re-hydrates from this on remount, so leaving the Test tab
547
+ // and coming back is transparent.
548
+ const [stagedConfig, setStagedConfigState] = useState<Record<string, any>>({});
549
+ const setStagedConfig = useCallback((patch: Record<string, any>) => {
550
+ setStagedConfigState(prev => ({ ...prev, ...patch }));
551
+ }, []);
552
+ const clearStagedConfig = useCallback(() => {
553
+ setStagedConfigState({});
554
+ }, []);
555
+
509
556
  const value: TisContextValue = useMemo(() => ({
510
557
  schemas, defaultMethodId, schemasLoaded,
511
558
  state, selection, setSelection,
512
559
  existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
513
560
  projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
561
+ stagedConfig, setStagedConfig, clearStagedConfig,
514
562
  fetchRuns, fetchRun, runCache,
515
563
  }), [
516
564
  schemas, defaultMethodId, schemasLoaded,
517
565
  state, selection, setSelection,
518
566
  existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
519
567
  projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
568
+ stagedConfig, setStagedConfig, clearStagedConfig,
520
569
  fetchRuns, fetchRun, runCache,
521
570
  ]);
522
571
 
@@ -156,8 +156,8 @@
156
156
  }
157
157
 
158
158
  .ac-toolbar-icon-btn {
159
- width: 40px;
160
- height: 40px;
159
+ width: 19mm;
160
+ height: 12.7mm;
161
161
  padding: 0;
162
162
  display: inline-flex;
163
163
  align-items: center;
@@ -170,8 +170,8 @@
170
170
  }
171
171
 
172
172
  .ac-toolbar-icon-lg {
173
- width: 48px;
174
- height: 48px;
173
+ width: 25.4mm;
174
+ height: 19mm;
175
175
  padding: 0;
176
176
  display: inline-flex;
177
177
  align-items: center;
@@ -184,8 +184,8 @@
184
184
  }
185
185
 
186
186
  .ac-toolbar-icon-panic {
187
- width: 48px;
188
- height: 48px;
187
+ width: 25.4mm;
188
+ height: 25.4mm;
189
189
  padding: 0;
190
190
  display: inline-flex;
191
191
  align-items: center;
@@ -208,6 +208,18 @@
208
208
  padding: 2mm;
209
209
  }
210
210
 
211
+ /* ---- Dialog: comfortable interior padding ----
212
+ *
213
+ * Same problem the OverlayPanel had — PrimeReact's `.p-dialog-content`
214
+ * lands with near-zero padding so dialog bodies feel squished
215
+ * against the title bar and side borders. Adding 2mm here gives
216
+ * dialogs a consistent inner gutter without each consumer having
217
+ * to wrap their content in their own padded container.
218
+ */
219
+ .p-dialog-content {
220
+ padding: 2mm;
221
+ }
222
+
211
223
  .ac-toolbar-group {
212
224
  display: flex;
213
225
  flex-direction: row;