@adcops/autocore-react 3.3.113 → 3.3.116
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/index.d.ts +10 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
- package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
- package/dist/components/tis/CycleConfigDialog.js +1 -0
- package/dist/components/tis/CycleConfigForm.d.ts +22 -0
- package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
- package/dist/components/tis/CycleConfigForm.js +1 -0
- package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
- package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
- package/dist/components/tis/CycleStatusStrip.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 +14 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestFieldRow.d.ts +151 -0
- package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
- package/dist/components/tis/TestFieldRow.js +1 -0
- package/dist/components/tis/TestSetupForm.d.ts +15 -51
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TestSetupView.d.ts.map +1 -1
- package/dist/components/tis/TestSetupView.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +80 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
- package/dist/components/tis-editor/TisConfigEditor.js +1 -1
- package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
- package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
- package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
- package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
- 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/editor/TestFieldDialog.d.ts +3 -0
- package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
- package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
- package/dist/components/tis-editor/types.d.ts +28 -1
- 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/package.json +110 -110
- package/src/components/index.ts +14 -0
- package/src/components/tis/CycleConfigDialog.tsx +173 -0
- package/src/components/tis/CycleConfigForm.tsx +192 -0
- package/src/components/tis/CycleStatusStrip.tsx +84 -0
- package/src/components/tis/ResultHistoryTable.tsx +141 -1
- package/src/components/tis/ScienceTable.tsx +71 -12
- package/src/components/tis/TestDataView.tsx +82 -7
- package/src/components/tis/TestFieldRow.tsx +297 -0
- package/src/components/tis/TestSetupForm.tsx +52 -212
- package/src/components/tis/TestSetupView.tsx +60 -3
- package/src/components/tis/TisProvider.tsx +191 -0
- package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
- package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
- package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
- package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
- package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
- package/src/components/tis-editor/types.ts +34 -1
- package/src/components/tis-editor/validation.ts +20 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <CycleConfigForm> — the Cycle tab. Operator-entered annotation that is
|
|
5
|
+
* recorded onto each cycle row: "which of the six positions is this?"
|
|
6
|
+
*
|
|
7
|
+
* Renders the selected method's `cycle_config_fields` with the same field
|
|
8
|
+
* rendering, unit handling and validation rules as Test Configuration (see
|
|
9
|
+
* TestFieldRow.tsx). What differs is the commit path and the lifecycle:
|
|
10
|
+
*
|
|
11
|
+
* - Every edit round-trips through `tis.set_cycle_config` immediately.
|
|
12
|
+
* There is no local draft: the value the operator sees must be the
|
|
13
|
+
* value the server would stamp on the next row, because that is what
|
|
14
|
+
* the `tis.cycle_ready` gate is computed from.
|
|
15
|
+
* - While a cycle is running its annotation is LATCHED. The fields stay
|
|
16
|
+
* visible but read-only, with a note saying edits apply to the next
|
|
17
|
+
* cycle — an edit that silently relabelled the row being measured
|
|
18
|
+
* would be worse than not being able to type at all.
|
|
19
|
+
* - Under `cycle` scope the values clear after each recorded row
|
|
20
|
+
* (`autoclear`), the gate re-arms, and the operator annotates again.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useContext, useState } from 'react';
|
|
24
|
+
import { Button } from 'primereact/button';
|
|
25
|
+
import { Dialog } from 'primereact/dialog';
|
|
26
|
+
import { Message } from 'primereact/message';
|
|
27
|
+
import { useTis } from './TisProvider';
|
|
28
|
+
import { TestFieldRow, labelOf, type TestFieldDef } from './TestFieldRow';
|
|
29
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
30
|
+
|
|
31
|
+
export interface CycleConfigFormProps {
|
|
32
|
+
/** Extra class names applied to the root element. */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Inline style merged onto the root element. */
|
|
35
|
+
style?: React.CSSProperties;
|
|
36
|
+
/** Hide the explanatory header (used inside the prompt dialog, where
|
|
37
|
+
* the dialog title already says what this is). */
|
|
38
|
+
compact?: boolean;
|
|
39
|
+
/** Called after each accepted edit — the dialog uses it to know the
|
|
40
|
+
* operator has engaged with the form. */
|
|
41
|
+
onValueCommitted?: (field: TestFieldDef, raw: any) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One-line summary of what the next recorded row will be labelled with.
|
|
46
|
+
* Shared by the Cycle tab header and <CycleStatusStrip> so the two can't
|
|
47
|
+
* describe the same state differently.
|
|
48
|
+
*/
|
|
49
|
+
export const cycleConfigSummary = (
|
|
50
|
+
fields: TestFieldDef[],
|
|
51
|
+
values: Record<string, any>,
|
|
52
|
+
): string => {
|
|
53
|
+
if (fields.length === 0) return '';
|
|
54
|
+
return fields
|
|
55
|
+
.map(f => {
|
|
56
|
+
const v = values?.[f.name];
|
|
57
|
+
const shown = v === undefined || v === null || v === '' ? '—' : String(v);
|
|
58
|
+
return `${f.label ?? f.name}: ${shown}`;
|
|
59
|
+
})
|
|
60
|
+
.join(' ');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const CycleConfigForm: React.FC<CycleConfigFormProps> = ({
|
|
64
|
+
className, style, compact, onValueCommitted,
|
|
65
|
+
}) => {
|
|
66
|
+
const tis = useTis();
|
|
67
|
+
const { write } = useContext(EventEmitterContext);
|
|
68
|
+
const cfg = tis.cycleConfig;
|
|
69
|
+
const fields = tis.cycleConfigFields;
|
|
70
|
+
const [infoField, setInfoField] = useState<TestFieldDef | null>(null);
|
|
71
|
+
|
|
72
|
+
// A latch exists exactly while a cycle is running against these values.
|
|
73
|
+
const locked = cfg.latched !== null;
|
|
74
|
+
|
|
75
|
+
const commit = async (field: TestFieldDef, raw: any) => {
|
|
76
|
+
await tis.setCycleConfigValues({ [field.name]: raw ?? null });
|
|
77
|
+
// A `source`-bound annotation also drives the machine (e.g. the
|
|
78
|
+
// position the fixture should move to), so mirror it to GM the way
|
|
79
|
+
// Test Configuration does. Storage on both sides stays RAW.
|
|
80
|
+
if (field.source) {
|
|
81
|
+
try { await write(field.source, raw); }
|
|
82
|
+
catch (e) { console.error('[CycleConfigForm] failed to write source:', e); }
|
|
83
|
+
}
|
|
84
|
+
onValueCommitted?.(field, raw);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (fields.length === 0) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={className} style={{ padding: '1.25rem', ...style }}>
|
|
90
|
+
<h3 className="ac-form-section">No Cycle Configuration</h3>
|
|
91
|
+
<p style={{ color: 'var(--text-secondary-color)', marginTop: '0.5rem' }}>
|
|
92
|
+
This test method records no operator-entered columns. Cycle rows
|
|
93
|
+
are written entirely from measured values.
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const gridStyle: React.CSSProperties = {
|
|
100
|
+
padding: '1.25rem',
|
|
101
|
+
gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={className} style={style}>
|
|
106
|
+
{!compact && (
|
|
107
|
+
<div style={{ padding: '1.25rem 1.25rem 0' }}>
|
|
108
|
+
<h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
109
|
+
Cycle Configuration
|
|
110
|
+
{/* Status is stated in full by the messages below;
|
|
111
|
+
this is the at-a-glance marker beside the title. */}
|
|
112
|
+
<i
|
|
113
|
+
className={cfg.ready ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'}
|
|
114
|
+
style={{ color: cfg.ready ? 'var(--green-500)' : 'var(--red-500)' }}
|
|
115
|
+
aria-label={cfg.ready ? 'Cycle Configuration complete' : 'Cycle Configuration incomplete'}
|
|
116
|
+
/>
|
|
117
|
+
</h3>
|
|
118
|
+
<p style={{ color: 'var(--text-secondary-color)', margin: '0.25rem 0 0' }}>
|
|
119
|
+
{cfg.scope === 'cycle'
|
|
120
|
+
? 'Recorded on the next cycle. Entered again for every cycle.'
|
|
121
|
+
: 'Recorded on every cycle of this run.'}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{locked && (
|
|
127
|
+
<div style={{ padding: '0.75rem 1.25rem 0' }}>
|
|
128
|
+
<Message
|
|
129
|
+
severity="info"
|
|
130
|
+
text="A cycle is running with these values. Edits apply to the next cycle."
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{!cfg.ready && !locked && (
|
|
136
|
+
<div style={{ padding: '0.75rem 1.25rem 0' }}>
|
|
137
|
+
<Message
|
|
138
|
+
severity="warn"
|
|
139
|
+
text={
|
|
140
|
+
'Required: ' +
|
|
141
|
+
cfg.missing
|
|
142
|
+
.map(n => fields.find(f => f.name === n))
|
|
143
|
+
.map((f, i) => (f ? labelOf(f) : cfg.missing[i]))
|
|
144
|
+
.join(', ') +
|
|
145
|
+
'. The machine will not start until these are filled in.'
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<div className="ac-form-grid" style={gridStyle}>
|
|
152
|
+
{fields.map(field => (
|
|
153
|
+
<TestFieldRow
|
|
154
|
+
key={field.name}
|
|
155
|
+
field={field}
|
|
156
|
+
// While latched, show what the running cycle is
|
|
157
|
+
// actually recording rather than the pending entry.
|
|
158
|
+
value={locked ? cfg.latched?.[field.name] : cfg.values?.[field.name]}
|
|
159
|
+
disabled={locked}
|
|
160
|
+
onChange={(raw) => { void commit(field, raw); }}
|
|
161
|
+
onInfo={(f) => setInfoField(f)}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{!compact && (
|
|
167
|
+
<div style={{ padding: '0 1.25rem 1.25rem' }}>
|
|
168
|
+
<Button
|
|
169
|
+
label="Clear"
|
|
170
|
+
icon="pi pi-eraser"
|
|
171
|
+
text
|
|
172
|
+
disabled={locked}
|
|
173
|
+
onClick={() => { void tis.clearCycleConfig(); }}
|
|
174
|
+
tooltip="Clear every entry, including fields that don't autoclear"
|
|
175
|
+
tooltipOptions={{ position: 'top' }}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<Dialog
|
|
181
|
+
header={infoField ? labelOf(infoField) : ''}
|
|
182
|
+
visible={infoField !== null}
|
|
183
|
+
onHide={() => setInfoField(null)}
|
|
184
|
+
style={{ width: '32rem', maxWidth: '90vw' }}
|
|
185
|
+
modal
|
|
186
|
+
dismissableMask
|
|
187
|
+
>
|
|
188
|
+
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{infoField?.description}</p>
|
|
189
|
+
</Dialog>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <CycleStatusStrip> — the Cycle Configuration state, rendered where the
|
|
5
|
+
* operator is already looking: next to the Start button.
|
|
6
|
+
*
|
|
7
|
+
* The Cycle tab is three panes away from the run screen. An operator
|
|
8
|
+
* about to press Start is looking at the lower-right corner of the
|
|
9
|
+
* footer, not at a tab they may never open, so the annotation that is
|
|
10
|
+
* about to be written onto the row has to be visible from there. This is
|
|
11
|
+
* the same idea as the specimen number Bluehill keeps beside its run
|
|
12
|
+
* control.
|
|
13
|
+
*
|
|
14
|
+
* Drop it into the footer beside Start:
|
|
15
|
+
*
|
|
16
|
+
* <div className="footer">
|
|
17
|
+
* <CycleStatusStrip />
|
|
18
|
+
* <Button label="Start" onClick={...} />
|
|
19
|
+
* </div>
|
|
20
|
+
*
|
|
21
|
+
* Clicking it opens the entry form in a dialog, so the operator can fill
|
|
22
|
+
* the fields in without leaving the run screen.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import React, { useState } from 'react';
|
|
26
|
+
import { Button } from 'primereact/button';
|
|
27
|
+
import { Dialog } from 'primereact/dialog';
|
|
28
|
+
import { useTis } from './TisProvider';
|
|
29
|
+
import { CycleConfigForm, cycleConfigSummary } from './CycleConfigForm';
|
|
30
|
+
|
|
31
|
+
export interface CycleStatusStripProps {
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
/** Render nothing when the method declares no annotation columns.
|
|
35
|
+
* Default true — an empty chip beside Start is just noise. */
|
|
36
|
+
hideWhenUnused?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const CycleStatusStrip: React.FC<CycleStatusStripProps> = ({
|
|
40
|
+
className, style, hideWhenUnused = true,
|
|
41
|
+
}) => {
|
|
42
|
+
const tis = useTis();
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const cfg = tis.cycleConfig;
|
|
45
|
+
const fields = tis.cycleConfigFields;
|
|
46
|
+
|
|
47
|
+
if (fields.length === 0 && hideWhenUnused) return null;
|
|
48
|
+
|
|
49
|
+
// Latched values are what the running cycle will actually record;
|
|
50
|
+
// pending values are what the next one will get.
|
|
51
|
+
const shown = cfg.latched ?? cfg.values;
|
|
52
|
+
const summary = cycleConfigSummary(fields, shown);
|
|
53
|
+
const incomplete = !cfg.ready;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<Button
|
|
58
|
+
className={className}
|
|
59
|
+
style={style}
|
|
60
|
+
text
|
|
61
|
+
severity={incomplete ? 'danger' : undefined}
|
|
62
|
+
icon={incomplete ? 'pi pi-exclamation-triangle' : 'pi pi-tag'}
|
|
63
|
+
// Not disabled and not silent: the operator can always open
|
|
64
|
+
// it, and when something is missing the chip says so in
|
|
65
|
+
// words rather than making them hunt for a red field.
|
|
66
|
+
label={incomplete
|
|
67
|
+
? `${summary} — entry required`
|
|
68
|
+
: summary}
|
|
69
|
+
aria-label="Cycle Configuration"
|
|
70
|
+
onClick={() => setOpen(true)}
|
|
71
|
+
/>
|
|
72
|
+
<Dialog
|
|
73
|
+
header="Cycle Configuration"
|
|
74
|
+
visible={open}
|
|
75
|
+
onHide={() => setOpen(false)}
|
|
76
|
+
style={{ width: '34rem', maxWidth: '95vw' }}
|
|
77
|
+
modal
|
|
78
|
+
dismissableMask
|
|
79
|
+
>
|
|
80
|
+
<CycleConfigForm />
|
|
81
|
+
</Dialog>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -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>
|