@adcops/autocore-react 3.3.112 → 3.3.115
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/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.js +1 -1
- package/dist/components/ams-editor/AssetFieldArray.d.ts +21 -0
- package/dist/components/ams-editor/AssetFieldArray.d.ts.map +1 -0
- package/dist/components/ams-editor/AssetFieldArray.js +1 -0
- package/dist/components/ams-editor/AssetTypeEditor.css +122 -0
- package/dist/components/ams-editor/AssetTypeEditor.d.ts +26 -0
- package/dist/components/ams-editor/AssetTypeEditor.d.ts.map +1 -0
- package/dist/components/ams-editor/AssetTypeEditor.js +1 -0
- package/dist/components/ams-editor/AssetTypeFormEditor.d.ts +21 -0
- package/dist/components/ams-editor/AssetTypeFormEditor.d.ts.map +1 -0
- package/dist/components/ams-editor/AssetTypeFormEditor.js +1 -0
- package/dist/components/ams-editor/SubLocationsEditor.d.ts +17 -0
- package/dist/components/ams-editor/SubLocationsEditor.d.ts.map +1 -0
- package/dist/components/ams-editor/SubLocationsEditor.js +1 -0
- package/dist/components/ams-editor/index.d.ts +7 -0
- package/dist/components/ams-editor/index.d.ts.map +1 -0
- package/dist/components/ams-editor/index.js +1 -0
- package/dist/components/ams-editor/types.d.ts +34 -0
- package/dist/components/ams-editor/types.d.ts.map +1 -0
- package/dist/components/ams-editor/types.js +1 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/ScienceTable.d.ts +15 -0
- package/dist/components/tis/ScienceTable.d.ts.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +10 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis-editor/TisConfigEditor.d.ts +1 -1
- package/dist/components/tis-editor/editor/SaveDiffDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/SaveDiffDialog.js +1 -1
- package/dist/hooks/useAmsSchemaConfig.d.ts +39 -0
- package/dist/hooks/useAmsSchemaConfig.d.ts.map +1 -0
- package/dist/hooks/useAmsSchemaConfig.js +1 -0
- package/dist/hooks/useTisConfig.d.ts +1 -1
- package/package.json +110 -110
- package/src/components/ams/AmsProvider.tsx +20 -15
- package/src/components/ams-editor/AssetFieldArray.tsx +103 -0
- package/src/components/ams-editor/AssetTypeEditor.css +122 -0
- package/src/components/ams-editor/AssetTypeEditor.tsx +295 -0
- package/src/components/ams-editor/AssetTypeFormEditor.tsx +178 -0
- package/src/components/ams-editor/SubLocationsEditor.tsx +151 -0
- package/src/components/ams-editor/index.ts +16 -0
- package/src/components/ams-editor/types.ts +40 -0
- package/src/components/tis/ResultHistoryTable.tsx +141 -1
- package/src/components/tis/ScienceTable.tsx +71 -12
- package/src/components/tis/TestDataView.tsx +65 -6
- package/src/components/tis-editor/TisConfigEditor.tsx +1 -1
- package/src/components/tis-editor/editor/SaveDiffDialog.tsx +4 -3
- package/src/hooks/useAmsSchemaConfig.ts +142 -0
- package/src/hooks/useTisConfig.ts +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubLocationsEditor — edits the keyed-fields (multi-axis) `sub_locations`
|
|
3
|
+
* shape of an asset type: a fixed set of keys (e.g. x/y/z axes) plus
|
|
4
|
+
* per-key nameplate and calibration fields.
|
|
5
|
+
*
|
|
6
|
+
* The positional (surface-lanes) shape — `count` + `per_location_state` —
|
|
7
|
+
* is not editable here; it's left to the form's JSON tab. Assets without
|
|
8
|
+
* any sub_locations can add a keyed schema with one click.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Button } from 'primereact/button';
|
|
12
|
+
import { InputText } from 'primereact/inputtext';
|
|
13
|
+
import { FormSection } from '../forms/FormSection';
|
|
14
|
+
import { FormRow } from '../forms/FormRow';
|
|
15
|
+
import { AssetFieldArray } from './AssetFieldArray';
|
|
16
|
+
import { isKeyedSubLocations, type AssetTypeDef, type KeyedSubLocations, type TestField } from './types';
|
|
17
|
+
|
|
18
|
+
export interface SubLocationsEditorProps {
|
|
19
|
+
def: AssetTypeDef;
|
|
20
|
+
onChange: (next: AssetTypeDef) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const EMPTY_KEYED: KeyedSubLocations = { keys: [], fields: [], calibration_fields: [] };
|
|
24
|
+
|
|
25
|
+
export const SubLocationsEditor: React.FC<SubLocationsEditorProps> = ({ def, onChange }) => {
|
|
26
|
+
const sl = def.sub_locations;
|
|
27
|
+
|
|
28
|
+
// No sub_locations yet — offer to add a keyed schema.
|
|
29
|
+
if (sl == null) {
|
|
30
|
+
return (
|
|
31
|
+
<FormSection
|
|
32
|
+
title="Sub-locations"
|
|
33
|
+
description="Optional. For multi-axis assets (e.g. a triaxial transducer), declare a fixed set of keys with per-key fields and calibration."
|
|
34
|
+
>
|
|
35
|
+
<Button
|
|
36
|
+
label="Add multi-axis sub-locations"
|
|
37
|
+
icon="pi pi-plus"
|
|
38
|
+
size="small"
|
|
39
|
+
onClick={() => onChange({ ...def, sub_locations: { ...EMPTY_KEYED } })}
|
|
40
|
+
/>
|
|
41
|
+
</FormSection>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Present but positional (surface-lanes) — not editable in the form.
|
|
46
|
+
if (!isKeyedSubLocations(sl)) {
|
|
47
|
+
return (
|
|
48
|
+
<FormSection title="Sub-locations">
|
|
49
|
+
<p style={{ color: 'var(--text-color-secondary, #64748b)' }}>
|
|
50
|
+
This type uses the positional (surface-lanes) sub-locations shape,
|
|
51
|
+
which isn't editable in the form. Use the <strong>JSON</strong> tab
|
|
52
|
+
to edit it.
|
|
53
|
+
</p>
|
|
54
|
+
</FormSection>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keyed = sl as KeyedSubLocations;
|
|
59
|
+
const setKeyed = (next: KeyedSubLocations) => onChange({ ...def, sub_locations: next });
|
|
60
|
+
|
|
61
|
+
const setKey = (idx: number, value: string) => {
|
|
62
|
+
const keys = [...keyed.keys];
|
|
63
|
+
keys[idx] = value;
|
|
64
|
+
setKeyed({ ...keyed, keys });
|
|
65
|
+
};
|
|
66
|
+
const addKey = () => setKeyed({ ...keyed, keys: [...keyed.keys, ''] });
|
|
67
|
+
const removeKey = (idx: number) =>
|
|
68
|
+
setKeyed({ ...keyed, keys: keyed.keys.filter((_, i) => i !== idx) });
|
|
69
|
+
const moveKey = (idx: number, dir: -1 | 1) => {
|
|
70
|
+
const j = idx + dir;
|
|
71
|
+
if (j < 0 || j >= keyed.keys.length) return;
|
|
72
|
+
const keys = [...keyed.keys];
|
|
73
|
+
[keys[idx], keys[j]] = [keys[j], keys[idx]];
|
|
74
|
+
setKeyed({ ...keyed, keys });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<FormSection
|
|
80
|
+
title="Sub-locations (multi-axis)"
|
|
81
|
+
description="A fixed, ordered set of keys. Each asset carries per-key values; calibration records carry a per-key sub-object."
|
|
82
|
+
actions={
|
|
83
|
+
<Button
|
|
84
|
+
label="Remove sub-locations"
|
|
85
|
+
icon="pi pi-trash"
|
|
86
|
+
className="p-button-text p-button-danger"
|
|
87
|
+
size="small"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
if (window.confirm('Remove the sub-locations schema from this type?')) {
|
|
90
|
+
const next = { ...def };
|
|
91
|
+
delete next.sub_locations;
|
|
92
|
+
onChange(next);
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
<FormRow label="Group label" hint="Pretty label for the set (e.g. Axes).">
|
|
99
|
+
<InputText
|
|
100
|
+
value={keyed.label ?? ''}
|
|
101
|
+
onChange={(e) => setKeyed({ ...keyed, label: e.target.value || undefined })}
|
|
102
|
+
/>
|
|
103
|
+
</FormRow>
|
|
104
|
+
<FormRow label="Key label" hint="Pretty label for one key (e.g. Axis).">
|
|
105
|
+
<InputText
|
|
106
|
+
value={keyed.key_label ?? ''}
|
|
107
|
+
onChange={(e) => setKeyed({ ...keyed, key_label: e.target.value || undefined })}
|
|
108
|
+
/>
|
|
109
|
+
</FormRow>
|
|
110
|
+
<FormRow label="Keys" hint="Ordered. Each becomes a per-key column on assets & calibrations.">
|
|
111
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
112
|
+
{keyed.keys.map((k, i) => (
|
|
113
|
+
<div key={i} style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
|
|
114
|
+
<InputText
|
|
115
|
+
value={k}
|
|
116
|
+
placeholder="e.g. x"
|
|
117
|
+
onChange={(e) => setKey(i, e.target.value)}
|
|
118
|
+
style={{ flex: 1 }}
|
|
119
|
+
/>
|
|
120
|
+
<Button icon="pi pi-arrow-up" className="p-button-text p-button-sm"
|
|
121
|
+
disabled={i === 0} onClick={() => moveKey(i, -1)} aria-label="Move up" />
|
|
122
|
+
<Button icon="pi pi-arrow-down" className="p-button-text p-button-sm"
|
|
123
|
+
disabled={i === keyed.keys.length - 1} onClick={() => moveKey(i, 1)} aria-label="Move down" />
|
|
124
|
+
<Button icon="pi pi-trash" className="p-button-text p-button-danger p-button-sm"
|
|
125
|
+
onClick={() => removeKey(i)} aria-label="Remove key" />
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
<div>
|
|
129
|
+
<Button label="Add key" icon="pi pi-plus" size="small" className="p-button-secondary" onClick={addKey} />
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</FormRow>
|
|
133
|
+
</FormSection>
|
|
134
|
+
|
|
135
|
+
<AssetFieldArray
|
|
136
|
+
title="Per-key fields"
|
|
137
|
+
description="Nameplate fields recorded once per key on each asset."
|
|
138
|
+
fields={(keyed.fields ?? []) as TestField[]}
|
|
139
|
+
onChange={(next) => setKeyed({ ...keyed, fields: next })}
|
|
140
|
+
/>
|
|
141
|
+
<AssetFieldArray
|
|
142
|
+
title="Per-key calibration fields"
|
|
143
|
+
description="Calibration values captured per key. When present, each calibration record must carry a per-key sub-object."
|
|
144
|
+
fields={(keyed.calibration_fields ?? []) as TestField[]}
|
|
145
|
+
onChange={(next) => setKeyed({ ...keyed, calibration_fields: next })}
|
|
146
|
+
/>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export default SubLocationsEditor;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* AMS asset-TYPE editor. Operator-facing HMI screen for authoring the
|
|
3
|
+
* custom asset types that AssetRegistryTable / AssetEditDialog then create
|
|
4
|
+
* instances of. Mount inside a Settings screen (e.g. a PrimeReact
|
|
5
|
+
* AccordionTab), like the TIS TisConfigEditor.
|
|
6
|
+
*
|
|
7
|
+
* Deep-imported by consumer apps:
|
|
8
|
+
* import { AssetTypeEditor } from '@adcops/autocore-react/components/ams-editor/AssetTypeEditor';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { AssetTypeEditor } from './AssetTypeEditor';
|
|
12
|
+
export type { AssetTypeEditorProps } from './AssetTypeEditor';
|
|
13
|
+
export { AssetTypeFormEditor } from './AssetTypeFormEditor';
|
|
14
|
+
export { AssetFieldArray } from './AssetFieldArray';
|
|
15
|
+
export { SubLocationsEditor } from './SubLocationsEditor';
|
|
16
|
+
export type { AssetTypeDef, KeyedSubLocations, TestField } from './types';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side mirror of the autocore-ams `AssetTypeConfig` (see
|
|
3
|
+
* autocore-ams/src/config.rs). Kept loose so unknown server-side
|
|
4
|
+
* extensions don't trip the editor at parse time. Reuses the shared
|
|
5
|
+
* `TestField` shape from the TIS editor — the server reuses the same
|
|
6
|
+
* Rust `TestField` for both test methods and asset/calibration fields.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TestField } from '../tis-editor/types';
|
|
10
|
+
|
|
11
|
+
export type { TestField };
|
|
12
|
+
|
|
13
|
+
/** One custom asset type. `sub_locations` is deliberately untyped: two
|
|
14
|
+
* shapes exist (keyed-fields multi-axis, discriminated by a `keys` array;
|
|
15
|
+
* and positional surface-lanes). The form edits the keyed shape; the
|
|
16
|
+
* positional shape is left to the JSON tab. */
|
|
17
|
+
export interface AssetTypeDef {
|
|
18
|
+
extends?: string;
|
|
19
|
+
id_prefix?: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
fields?: TestField[];
|
|
23
|
+
calibration_fields?: TestField[];
|
|
24
|
+
sub_locations?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The keyed-fields (multi-axis) `sub_locations` shape. */
|
|
28
|
+
export interface KeyedSubLocations {
|
|
29
|
+
label?: string;
|
|
30
|
+
key_label?: string;
|
|
31
|
+
keys: string[];
|
|
32
|
+
fields?: TestField[];
|
|
33
|
+
calibration_fields?: TestField[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when a `sub_locations` value is the keyed-fields (multi-axis)
|
|
37
|
+
* shape the form can edit — discriminated by a `keys` array. */
|
|
38
|
+
export function isKeyedSubLocations(v: unknown): v is KeyedSubLocations {
|
|
39
|
+
return !!v && typeof v === 'object' && Array.isArray((v as any).keys);
|
|
40
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, { useState, useEffect, useContext } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useContext, useRef } from 'react';
|
|
2
2
|
import { DataTable } from 'primereact/datatable';
|
|
3
3
|
import { Column } from 'primereact/column';
|
|
4
4
|
import { Button } from 'primereact/button';
|
|
5
5
|
import { Badge } from 'primereact/badge';
|
|
6
|
+
import { Menu } from 'primereact/menu';
|
|
7
|
+
import { confirmDialog } from 'primereact/confirmdialog';
|
|
6
8
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
7
9
|
import { MessageType } from '../../hub/CommandMessage';
|
|
8
10
|
import { useTis } from './TisProvider';
|
|
@@ -75,6 +77,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
75
77
|
const [downloading, setDownloading] = useState<InFlight | null>(null);
|
|
76
78
|
const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
|
|
77
79
|
const [excludingRunId, setExcludingRunId] = useState<string | null>(null);
|
|
80
|
+
// Per-row lifecycle menu (complete / abandon / reopen). One Menu instance is
|
|
81
|
+
// reused; `menuRow` is the row it was opened for.
|
|
82
|
+
const rowMenuRef = useRef<Menu>(null);
|
|
83
|
+
const [menuRow, setMenuRow] = useState<any>(null);
|
|
84
|
+
const [lifecycleBusy, setLifecycleBusy] = useState<string | null>(null);
|
|
78
85
|
const { invoke } = useContext(EventEmitterContext);
|
|
79
86
|
|
|
80
87
|
const loadTests = async () => {
|
|
@@ -176,6 +183,112 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
176
183
|
// kept on disk (not deleted) but dropped from avg/min/max/stddev/count
|
|
177
184
|
// results for their sample group. The server recomputes + rebroadcasts
|
|
178
185
|
// the sample summary on success.
|
|
186
|
+
/**
|
|
187
|
+
* Run-lifecycle action on one row: complete, abandon, or reopen.
|
|
188
|
+
*
|
|
189
|
+
* All three are RECORD operations — they change what the stored run says
|
|
190
|
+
* about itself, never what the machine is doing. Reopening makes a run the
|
|
191
|
+
* active one so the control program's next cycle appends to it; it does not
|
|
192
|
+
* command motion, and the machine still decides when to run. That
|
|
193
|
+
* distinction is why this is safe to expose in a browser: a click here can
|
|
194
|
+
* retarget where data lands, but cannot start a machine.
|
|
195
|
+
*
|
|
196
|
+
* `tis.finish_test` / `reset_test` / `resume_test` all take an explicit
|
|
197
|
+
* `run_id`, so the row's own run is addressed rather than "whatever is
|
|
198
|
+
* newest".
|
|
199
|
+
*/
|
|
200
|
+
const handleLifecycle = async (
|
|
201
|
+
rowData: any,
|
|
202
|
+
action: 'complete' | 'abandon' | 'reopen',
|
|
203
|
+
) => {
|
|
204
|
+
const runId = rowData?.run_id;
|
|
205
|
+
const rowMethodId = rowData?.method_id ?? methodId;
|
|
206
|
+
if (!runId || !rowMethodId || !projectId) return;
|
|
207
|
+
const topic =
|
|
208
|
+
action === 'complete' ? 'tis.finish_test'
|
|
209
|
+
: action === 'abandon' ? 'tis.reset_test'
|
|
210
|
+
: 'tis.resume_test';
|
|
211
|
+
setLifecycleBusy(runId);
|
|
212
|
+
try {
|
|
213
|
+
const resp: any = await invoke(topic as any, MessageType.Request, {
|
|
214
|
+
project_id: projectId, method_id: rowMethodId, run_id: runId,
|
|
215
|
+
} as any);
|
|
216
|
+
if (!resp?.success) {
|
|
217
|
+
// The most common rejection is reopening while another test is
|
|
218
|
+
// already open for this project+method — the server refuses so
|
|
219
|
+
// two runs can't both be "active".
|
|
220
|
+
alert(`Could not ${action} ${runId}` +
|
|
221
|
+
(resp?.error_message ? `:\n\n${resp.error_message}` : ''));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await loadTests();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(`Failed to ${action} test`, err);
|
|
227
|
+
alert(`${action} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
228
|
+
} finally {
|
|
229
|
+
setLifecycleBusy(null);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Menu items for a row, gated on its lifecycle state:
|
|
235
|
+
* - open (in_progress / paused) → Complete, Abandon
|
|
236
|
+
* - completed but not finalized → Reopen (add more cycles under the same
|
|
237
|
+
* Test ID — the multi-specimen case where a set turned out to be bigger)
|
|
238
|
+
* - finalized (abandoned) → nothing; `finalized` exists precisely to
|
|
239
|
+
* stop a deliberately-abandoned run being resurrected.
|
|
240
|
+
*/
|
|
241
|
+
const rowMenuItems = (rowData: any) => {
|
|
242
|
+
const status = rowData?.status;
|
|
243
|
+
const finalized = rowData?.finalized === true;
|
|
244
|
+
const isOpen = status === 'in_progress' || status === 'paused';
|
|
245
|
+
const items: any[] = [];
|
|
246
|
+
|
|
247
|
+
if (isOpen) {
|
|
248
|
+
items.push({
|
|
249
|
+
label: 'Complete Test',
|
|
250
|
+
icon: 'pi pi-check-circle',
|
|
251
|
+
command: () => handleLifecycle(rowData, 'complete'),
|
|
252
|
+
});
|
|
253
|
+
items.push({
|
|
254
|
+
label: 'Abandon Test',
|
|
255
|
+
icon: 'pi pi-ban',
|
|
256
|
+
command: () => confirmDialog({
|
|
257
|
+
header: 'Abandon Test',
|
|
258
|
+
message:
|
|
259
|
+
`Mark ${rowData?.sample_id || rowData?.run_id} as abandoned? ` +
|
|
260
|
+
`Recorded data is kept, but the test can no longer be reopened.`,
|
|
261
|
+
icon: 'pi pi-exclamation-triangle',
|
|
262
|
+
acceptClassName: 'p-button-danger',
|
|
263
|
+
acceptLabel: 'Abandon',
|
|
264
|
+
accept: () => handleLifecycle(rowData, 'abandon'),
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
} else if (status === 'completed' && !finalized) {
|
|
268
|
+
items.push({
|
|
269
|
+
label: 'Reopen Test',
|
|
270
|
+
icon: 'pi pi-replay',
|
|
271
|
+
command: () => confirmDialog({
|
|
272
|
+
header: 'Reopen Test',
|
|
273
|
+
message:
|
|
274
|
+
`Reopen ${rowData?.sample_id || rowData?.run_id} so new cycles are added to ` +
|
|
275
|
+
`it? Cycle numbering continues from where it stopped. This changes where ` +
|
|
276
|
+
`data is recorded — it does not start the machine.`,
|
|
277
|
+
icon: 'pi pi-replay',
|
|
278
|
+
acceptLabel: 'Reopen',
|
|
279
|
+
accept: () => handleLifecycle(rowData, 'reopen'),
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
items.push({
|
|
284
|
+
label: finalized ? 'Abandoned — cannot reopen' : 'No actions available',
|
|
285
|
+
icon: 'pi pi-info-circle',
|
|
286
|
+
disabled: true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return items;
|
|
290
|
+
};
|
|
291
|
+
|
|
179
292
|
const handleToggleExclude = async (rowData: any) => {
|
|
180
293
|
const runId = rowData?.run_id;
|
|
181
294
|
const rowMethodId = rowData?.method_id ?? methodId;
|
|
@@ -440,7 +553,34 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
440
553
|
);
|
|
441
554
|
}}
|
|
442
555
|
/>
|
|
556
|
+
{/* Thin per-row lifecycle menu. Kept narrow and iconic so it
|
|
557
|
+
doesn't compete with the download actions. */}
|
|
558
|
+
<Column
|
|
559
|
+
header=""
|
|
560
|
+
style={{ width: '3rem' }}
|
|
561
|
+
body={(rowData) => (
|
|
562
|
+
<Button
|
|
563
|
+
icon={
|
|
564
|
+
lifecycleBusy === rowData.run_id
|
|
565
|
+
? 'pi pi-spin pi-spinner'
|
|
566
|
+
: 'pi pi-ellipsis-v'
|
|
567
|
+
}
|
|
568
|
+
text
|
|
569
|
+
rounded
|
|
570
|
+
size="small"
|
|
571
|
+
disabled={lifecycleBusy !== null}
|
|
572
|
+
aria-label="Test actions"
|
|
573
|
+
tooltip="Complete / abandon / reopen this test"
|
|
574
|
+
tooltipOptions={{ position: 'left' }}
|
|
575
|
+
onClick={(e) => {
|
|
576
|
+
setMenuRow(rowData);
|
|
577
|
+
rowMenuRef.current?.toggle(e);
|
|
578
|
+
}}
|
|
579
|
+
/>
|
|
580
|
+
)}
|
|
581
|
+
/>
|
|
443
582
|
</DataTable>
|
|
583
|
+
<Menu model={menuRow ? rowMenuItems(menuRow) : []} popup ref={rowMenuRef} />
|
|
444
584
|
</div>
|
|
445
585
|
);
|
|
446
586
|
};
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import React from 'react';
|
|
17
17
|
import { DataTable } from 'primereact/datatable';
|
|
18
18
|
import { Column } from 'primereact/column';
|
|
19
|
+
import { InputText } from 'primereact/inputtext';
|
|
19
20
|
import type { ColumnBodyOptions } from 'primereact/column';
|
|
20
21
|
import './ScienceTable.css';
|
|
21
22
|
|
|
@@ -32,6 +33,14 @@ export interface ScienceColumn {
|
|
|
32
33
|
body?: (row: any) => React.ReactNode;
|
|
33
34
|
/** Optional per-column style (e.g. minWidth). */
|
|
34
35
|
style?: React.CSSProperties;
|
|
36
|
+
/**
|
|
37
|
+
* Operator-editable cell. Requires the table's `onCellEdit`. Intended for
|
|
38
|
+
* ANNOTATION columns only (a specimen or lot identifier noted against an
|
|
39
|
+
* automatically-captured row) — never for measured values, which the server
|
|
40
|
+
* refuses to rewrite anyway (`tis.update_cycle` only accepts cycle_fields
|
|
41
|
+
* declared `editable: true`).
|
|
42
|
+
*/
|
|
43
|
+
editable?: boolean;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export interface ScienceTableProps {
|
|
@@ -49,6 +58,13 @@ export interface ScienceTableProps {
|
|
|
49
58
|
scrollHeight?: string;
|
|
50
59
|
/** Virtual-scroll row height in px. Default 38. */
|
|
51
60
|
virtualItemSize?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Commit handler for cells in columns marked `editable`. Called with the
|
|
63
|
+
* edited row, the column field and the new value. Without it, `editable`
|
|
64
|
+
* columns stay read-only — there is no local-only edit mode, because a value
|
|
65
|
+
* that looks saved but isn't is worse than one that can't be typed.
|
|
66
|
+
*/
|
|
67
|
+
onCellEdit?: (row: any, field: string, value: any) => void | Promise<void>;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
const HeaderCell: React.FC<{ column: ScienceColumn }> = ({ column }) => (
|
|
@@ -68,8 +84,12 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
68
84
|
scrollable = false,
|
|
69
85
|
scrollHeight,
|
|
70
86
|
virtualItemSize = 38,
|
|
87
|
+
onCellEdit,
|
|
71
88
|
}) => {
|
|
72
89
|
const virtual = scrollable && !!scrollHeight;
|
|
90
|
+
// Cell edit mode is switched on only when a column asks for it AND a commit
|
|
91
|
+
// handler exists, so a plain report table keeps DataTable's read-only path.
|
|
92
|
+
const anyEditable = !!onCellEdit && columns.some((c) => c.editable);
|
|
73
93
|
return (
|
|
74
94
|
<div className="ac-science-table-wrap">
|
|
75
95
|
{title != null && <div className="ac-science-table-title">{title}</div>}
|
|
@@ -81,6 +101,7 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
81
101
|
scrollHeight={scrollHeight}
|
|
82
102
|
virtualScrollerOptions={virtual ? { itemSize: virtualItemSize } : undefined}
|
|
83
103
|
emptyMessage={emptyMessage}
|
|
104
|
+
editMode={anyEditable ? 'cell' : undefined}
|
|
84
105
|
>
|
|
85
106
|
{showIndex ? (
|
|
86
107
|
<Column
|
|
@@ -93,18 +114,56 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
93
114
|
body={(_data: any, options: ColumnBodyOptions) => options.rowIndex + 1}
|
|
94
115
|
/>
|
|
95
116
|
) : null}
|
|
96
|
-
{columns.map((c) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
{columns.map((c) => {
|
|
118
|
+
const cellEditable = !!onCellEdit && !!c.editable;
|
|
119
|
+
return (
|
|
120
|
+
<Column
|
|
121
|
+
key={c.field}
|
|
122
|
+
field={c.field}
|
|
123
|
+
header={<HeaderCell column={c} />}
|
|
124
|
+
align={c.align ?? 'center'}
|
|
125
|
+
alignHeader="center"
|
|
126
|
+
style={c.style}
|
|
127
|
+
bodyClassName={
|
|
128
|
+
cellEditable ? 'ac-sci-cell ac-sci-cell-editable' : 'ac-sci-cell'
|
|
129
|
+
}
|
|
130
|
+
body={c.body ? (row: any) => c.body!(row) : undefined}
|
|
131
|
+
editor={
|
|
132
|
+
cellEditable
|
|
133
|
+
? (options: any) => (
|
|
134
|
+
<InputText
|
|
135
|
+
value={options.value ?? ''}
|
|
136
|
+
autoFocus
|
|
137
|
+
style={{ width: '100%' }}
|
|
138
|
+
onChange={(e) => options.editorCallback?.(e.target.value)}
|
|
139
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
: undefined
|
|
143
|
+
}
|
|
144
|
+
onCellEditComplete={
|
|
145
|
+
cellEditable
|
|
146
|
+
? (e: any) => {
|
|
147
|
+
// Numeric annotation columns come back
|
|
148
|
+
// as a string from the text editor;
|
|
149
|
+
// coerce so the stored type matches the
|
|
150
|
+
// schema instead of silently turning a
|
|
151
|
+
// number column into strings.
|
|
152
|
+
const prev = e.rowData?.[c.field];
|
|
153
|
+
let next: any = e.newValue;
|
|
154
|
+
if (typeof prev === 'number' && next !== '' && next != null) {
|
|
155
|
+
const n = Number(next);
|
|
156
|
+
if (!Number.isNaN(n)) next = n;
|
|
157
|
+
}
|
|
158
|
+
if (next !== prev) {
|
|
159
|
+
void onCellEdit!(e.rowData, c.field, next);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
: undefined
|
|
163
|
+
}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
108
167
|
</DataTable>
|
|
109
168
|
</div>
|
|
110
169
|
</div>
|
|
@@ -72,6 +72,12 @@ export interface TestFieldDef {
|
|
|
72
72
|
* (label === stored value) or an explicit { label, value } pair.
|
|
73
73
|
* Mirrors the Rust TestField::options (untagged FieldOption). */
|
|
74
74
|
options?: Array<string | number | boolean | { label: string; value: any }>;
|
|
75
|
+
/** Operator-editable after recording — annotation columns only (a specimen
|
|
76
|
+
* or lot id noted against a captured row). Meaningful on `cycle_fields`;
|
|
77
|
+
* the server refuses `tis.update_cycle` for any field without it, so
|
|
78
|
+
* measured columns can never be typed over. Mirrors Rust
|
|
79
|
+
* `TestField::editable`. */
|
|
80
|
+
editable?: boolean;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
export interface ChartAxis { field?: string; column?: string; label?: string; }
|
|
@@ -155,10 +161,16 @@ export interface TestConfiguration {
|
|
|
155
161
|
defaults?: { [field: string]: any };
|
|
156
162
|
}
|
|
157
163
|
export interface TestMethod {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
/* All four field lists are OPTIONAL by design. Server-side each is
|
|
165
|
+
* `#[serde(default)]`, so a method that declares only `config_fields` is
|
|
166
|
+
* perfectly valid and the other keys simply aren't in the JSON. Typing
|
|
167
|
+
* them as required let `schema.cycle_fields.map(...)` compile and then
|
|
168
|
+
* blank the operator's page at runtime (gen4_ac, 2026-08-11). Keep them
|
|
169
|
+
* optional so the compiler demands a `?? []` at every use. */
|
|
170
|
+
project_fields?: TestFieldDef[];
|
|
171
|
+
config_fields?: TestFieldDef[];
|
|
172
|
+
cycle_fields?: TestFieldDef[];
|
|
173
|
+
results_fields?: TestFieldDef[];
|
|
162
174
|
raw_data?: RawDataShape | null;
|
|
163
175
|
views?: { [name: string]: ChartView };
|
|
164
176
|
/** Optional pretty label for the Test Method picker. */
|
|
@@ -208,6 +220,46 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
208
220
|
const [rawOpen, setRawOpen] = useState(false);
|
|
209
221
|
const [configOpen, setConfigOpen] = useState(false);
|
|
210
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Commit an operator edit to an annotation cell (`tis.update_cycle`).
|
|
225
|
+
*
|
|
226
|
+
* The server is the gate: it accepts a field only if the method declares it
|
|
227
|
+
* `editable: true`, so a measured column is refused even if a caller asks.
|
|
228
|
+
* We update local state only after the round trip succeeds — showing an edit
|
|
229
|
+
* that didn't persist is worse than showing the old value.
|
|
230
|
+
*/
|
|
231
|
+
const saveCycleField = useCallback(
|
|
232
|
+
async (row: any, field: string, value: any) => {
|
|
233
|
+
const cycleIndex = row?.cycle_index;
|
|
234
|
+
if (!projectId || !methodId || !runId || !cycleIndex) return;
|
|
235
|
+
try {
|
|
236
|
+
const resp: any = await invoke(
|
|
237
|
+
'tis.update_cycle' as any,
|
|
238
|
+
MessageType.Request,
|
|
239
|
+
{
|
|
240
|
+
project_id: projectId,
|
|
241
|
+
method_id: methodId,
|
|
242
|
+
run_id: runId,
|
|
243
|
+
cycle_index: cycleIndex,
|
|
244
|
+
fields: { [field]: value },
|
|
245
|
+
} as any,
|
|
246
|
+
);
|
|
247
|
+
if (resp?.success === false) {
|
|
248
|
+
console.error('tis.update_cycle rejected:', resp?.error_message);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
setCycles((prev) =>
|
|
252
|
+
prev.map((c) =>
|
|
253
|
+
c?.cycle_index === cycleIndex ? { ...c, [field]: value } : c,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.error('tis.update_cycle failed:', e);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[invoke, projectId, methodId, runId],
|
|
261
|
+
);
|
|
262
|
+
|
|
211
263
|
// Direct handle on the chart.js instance so the toolbar's reset-
|
|
212
264
|
// zoom button can call chart.resetZoom() — the zoom plugin's only
|
|
213
265
|
// imperative API. Customers like the wheel/pinch/drag zoom but
|
|
@@ -742,10 +794,16 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
742
794
|
doesn't render 1000 row elements at once. */}
|
|
743
795
|
{(() => {
|
|
744
796
|
const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
|
|
745
|
-
|
|
797
|
+
// `?? []` is load-bearing: cycle_fields is `#[serde(default)]`
|
|
798
|
+
// server-side, so a method that declares only config_fields is
|
|
799
|
+
// valid and arrives with the key absent. Without the guard the
|
|
800
|
+
// `.map` throws inside render and React unmounts the whole
|
|
801
|
+
// app — the operator sees a blank page, not a missing table.
|
|
802
|
+
const cycleColumns: ScienceColumn[] = (schema.cycle_fields ?? []).map(f => ({
|
|
746
803
|
field: f.name,
|
|
747
804
|
label: f.name,
|
|
748
805
|
units: f.units,
|
|
806
|
+
editable: f.editable,
|
|
749
807
|
body: (row) => formatCell(row[f.name], f.type, f.scale),
|
|
750
808
|
}));
|
|
751
809
|
return (
|
|
@@ -756,6 +814,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
756
814
|
scrollable={useVirtual}
|
|
757
815
|
scrollHeight={useVirtual ? cycleTableHeight : undefined}
|
|
758
816
|
emptyMessage="No cycles yet."
|
|
817
|
+
onCellEdit={saveCycleField}
|
|
759
818
|
/>
|
|
760
819
|
);
|
|
761
820
|
})()}
|
|
@@ -767,7 +826,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
767
826
|
{(() => {
|
|
768
827
|
const values = { ...results, ...sampleSummary };
|
|
769
828
|
const hasValues = Object.keys(values).length > 0;
|
|
770
|
-
const resultColumns: ScienceColumn[] = schema.results_fields.map(f => ({
|
|
829
|
+
const resultColumns: ScienceColumn[] = (schema.results_fields ?? []).map(f => ({
|
|
771
830
|
field: f.name,
|
|
772
831
|
label: f.name,
|
|
773
832
|
units: f.units,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Action bar: New / Duplicate / Delete / Apply / Save / Revert.
|
|
8
8
|
*
|
|
9
9
|
* "Apply" pushes the local Monaco buffer to the server-side stage
|
|
10
|
-
* (`tis.put_method`). "Save" persists the entire stage to
|
|
10
|
+
* (`tis.put_method`). "Save" persists the entire stage to test_methods.json
|
|
11
11
|
* (`tis.save_config`). "Revert" drops the stage (`tis.discard_config_changes`).
|
|
12
12
|
*
|
|
13
13
|
* Save is disabled (with a tooltip) when active tests are open against
|
|
@@ -79,9 +79,10 @@ export const SaveDiffDialog: React.FC<SaveDiffDialogProps> = ({
|
|
|
79
79
|
{diff && (
|
|
80
80
|
<div>
|
|
81
81
|
<p>
|
|
82
|
-
About to
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
About to save the test methods to{' '}
|
|
83
|
+
<code>test_methods.json</code>. A backup of the
|
|
84
|
+
previous file will be written to{' '}
|
|
85
|
+
<code>test_methods.json.bak</code>.
|
|
85
86
|
</p>
|
|
86
87
|
{diff.added.length === 0 && diff.removed.length === 0 && diff.modified.length === 0 && (
|
|
87
88
|
<p><em>No changes detected.</em></p>
|