@adcops/autocore-react 3.5.5 → 3.5.9
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/AssetRegistryTable.d.ts.map +1 -1
- package/dist/components/ams/AssetRegistryTable.js +1 -1
- package/dist/components/forms/useEditDraft.d.ts +49 -0
- package/dist/components/forms/useEditDraft.d.ts.map +1 -0
- package/dist/components/forms/useEditDraft.js +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +8 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
- package/dist/components/tis/TestRawDataView.js +1 -1
- package/dist/components/tis/chartViews.d.ts +57 -0
- package/dist/components/tis/chartViews.d.ts.map +1 -0
- package/dist/components/tis/chartViews.js +1 -0
- package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -1
- 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 +11 -0
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/components/ams/AssetRegistryTable.tsx +40 -9
- package/src/components/forms/useEditDraft.ts +0 -0
- package/src/components/index.ts +3 -0
- package/src/components/tis/TestDataView.tsx +16 -10
- package/src/components/tis/TestRawDataView.tsx +6 -7
- package/src/components/tis/chartViews.ts +104 -0
- package/src/components/tis-editor/editor/ChartViewDialog.tsx +24 -10
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +54 -13
- package/src/components/tis-editor/editor/ViewsEditor.tsx +31 -5
- package/src/components/tis-editor/types.ts +11 -0
- package/tools/tests/dist/tis-editor.entry.css +90 -0
- package/tools/tests/dist/tis-editor.entry.js +41431 -0
- package/tools/tests/tis-editor.entry.tsx +46 -0
- package/tools/tests/tis-editor.test.mjs +133 -0
- package/tools/tests/vite.config.mjs +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adcops/autocore-react",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.9",
|
|
4
4
|
"description": "A React component library for industrial user interfaces.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"generate-docs": "typedoc",
|
|
13
13
|
"build": "npm run clean && tsc && node ./tools/copy-distribution-files.cjs && npm run build-themes && npm run minify",
|
|
14
14
|
"build:dev": "npm run clean && tsc && node ./tools/copy-distribution-files.cjs",
|
|
15
|
+
"test:tis-editor": "vite build --config tools/tests/vite.config.mjs && node tools/tests/tis-editor.test.mjs",
|
|
15
16
|
"dev": "vite",
|
|
16
17
|
"playground": "vite",
|
|
17
18
|
"playground:build": "vite build",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"@types/react-dom": "^18.2.7",
|
|
83
84
|
"@types/react-transition-group": "^4.4.12",
|
|
84
85
|
"@vitejs/plugin-react": "^6.0.2",
|
|
86
|
+
"jsdom": "^22.1.0",
|
|
85
87
|
"primereact": "^10.9.7",
|
|
86
88
|
"rimraf": "^6.0.1",
|
|
87
89
|
"terser": "^5.44.0",
|
|
@@ -164,6 +164,14 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
164
164
|
const [filterType, setFilterType] = useState<string | null>(null);
|
|
165
165
|
const [filterStatus, setFilterStatus] = useState<string | null>(null);
|
|
166
166
|
const [addState, setAddState] = useState<AddDialogState>(EMPTY_ADD);
|
|
167
|
+
/** True from the moment Create is pressed until `ams.create_asset`
|
|
168
|
+
* has answered and the registry has been refreshed. Creation is a
|
|
169
|
+
* server round-trip, and with the buttons still live an operator
|
|
170
|
+
* who saw no feedback would press Create again — registering the
|
|
171
|
+
* same asset two or three times. While this is set the footer
|
|
172
|
+
* shows a spinner instead of the buttons and the dialog refuses to
|
|
173
|
+
* close, so there is no second press to swallow. */
|
|
174
|
+
const [creating, setCreating] = useState(false);
|
|
167
175
|
|
|
168
176
|
// <MissingAssetsBanner> fires the `ams:prefill-add` custom event
|
|
169
177
|
// when an operator clicks Register on one of its rows. Catch it
|
|
@@ -243,7 +251,10 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
243
251
|
}, [assets, filterType, filterStatus]);
|
|
244
252
|
|
|
245
253
|
const onCreate = async () => {
|
|
246
|
-
|
|
254
|
+
// Re-entry guard: belt to the footer's braces. The footer hides
|
|
255
|
+
// the button while `creating`, but a queued click (or a stray
|
|
256
|
+
// Enter key) must not post a second create.
|
|
257
|
+
if (!addState.assetType || creating) return;
|
|
247
258
|
// Coerce the per-field strings to their declared types. Numbers
|
|
248
259
|
// become JSON numbers, bools become bools; empty strings drop
|
|
249
260
|
// out so the asset_json doesn't get noisy with empty values.
|
|
@@ -296,6 +307,7 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
296
307
|
payload.sub_locations = subLocations;
|
|
297
308
|
}
|
|
298
309
|
|
|
310
|
+
setCreating(true);
|
|
299
311
|
try {
|
|
300
312
|
const resp: any = await invoke('ams.create_asset' as any, MessageType.Request, payload);
|
|
301
313
|
if (resp?.success) {
|
|
@@ -309,6 +321,11 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
309
321
|
}
|
|
310
322
|
} catch (e) {
|
|
311
323
|
console.error('[AssetRegistryTable] create_asset threw:', e);
|
|
324
|
+
} finally {
|
|
325
|
+
// Always clear, including on the failure paths above — the
|
|
326
|
+
// dialog stays open with the operator's entries intact and
|
|
327
|
+
// the buttons come back so they can retry or cancel.
|
|
328
|
+
setCreating(false);
|
|
312
329
|
}
|
|
313
330
|
};
|
|
314
331
|
|
|
@@ -466,15 +483,29 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
466
483
|
header="Add New Asset"
|
|
467
484
|
visible={addState.open}
|
|
468
485
|
style={{ width: '32rem' }}
|
|
469
|
-
|
|
486
|
+
/* No escape hatches while the create is in flight: the
|
|
487
|
+
header X, the Escape key and the mask click all route
|
|
488
|
+
through onHide, and closing mid-flight would drop the
|
|
489
|
+
operator back to a table that has not refreshed yet. */
|
|
490
|
+
closable={!creating}
|
|
491
|
+
closeOnEscape={!creating}
|
|
492
|
+
onHide={() => { if (!creating) setAddState(EMPTY_ADD); }}
|
|
470
493
|
footer={
|
|
471
|
-
|
|
472
|
-
<
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
494
|
+
creating ? (
|
|
495
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end',
|
|
496
|
+
alignItems: 'center', gap: '0.5rem' }}>
|
|
497
|
+
<i className="pi pi-spin pi-spinner" />
|
|
498
|
+
<span>Creating asset…</span>
|
|
499
|
+
</div>
|
|
500
|
+
) : (
|
|
501
|
+
<>
|
|
502
|
+
<Button label="Cancel" severity="secondary" onClick={() => setAddState(EMPTY_ADD)} />
|
|
503
|
+
<Button label="Create" icon="pi pi-check"
|
|
504
|
+
onClick={onCreate}
|
|
505
|
+
disabled={createDisabled}
|
|
506
|
+
/>
|
|
507
|
+
</>
|
|
508
|
+
)
|
|
478
509
|
}
|
|
479
510
|
>
|
|
480
511
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem', alignItems: 'center' }}>
|
|
Binary file
|
package/src/components/index.ts
CHANGED
|
@@ -38,6 +38,9 @@ export type { TestSetupFormProps } from './tis/TestSetupForm';
|
|
|
38
38
|
export { TestSetupView } from './tis/TestSetupView';
|
|
39
39
|
export type { TestSetupViewProps } from './tis/TestSetupView';
|
|
40
40
|
|
|
41
|
+
export { orderedViews, renumberViews, moveView } from './tis/chartViews';
|
|
42
|
+
export type { NamedView, OrderableView } from './tis/chartViews';
|
|
43
|
+
|
|
41
44
|
export { TestFieldRow } from './tis/TestFieldRow';
|
|
42
45
|
export type { TestFieldRowProps, CycleConfigScope } from './tis/TestFieldRow';
|
|
43
46
|
|
|
@@ -47,6 +47,7 @@ import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
|
|
|
47
47
|
import { formatScaled } from '../../core/formatScaled';
|
|
48
48
|
import type { ScaleConfig } from '../../core/AutoCoreTagTypes';
|
|
49
49
|
import { useRawCycleData } from './useRawCycleData';
|
|
50
|
+
import { orderedViews } from './chartViews';
|
|
50
51
|
import { ScienceTable } from './ScienceTable';
|
|
51
52
|
import type { ScienceColumn } from './ScienceTable';
|
|
52
53
|
|
|
@@ -171,6 +172,14 @@ export interface ChartView {
|
|
|
171
172
|
* table, CSV export and every calculation still see the full value.
|
|
172
173
|
*/
|
|
173
174
|
x_decimals?: number;
|
|
175
|
+
/**
|
|
176
|
+
* Position in the view picker, ascending — the view with the lowest
|
|
177
|
+
* `order` is the one this panel opens on. Written by the TIS editor's
|
|
178
|
+
* Move up / Move down. See `chartViews.ts`: the keys of `views` arrive
|
|
179
|
+
* sorted by id, so the order an author chose has to be carried
|
|
180
|
+
* explicitly.
|
|
181
|
+
*/
|
|
182
|
+
order?: number;
|
|
174
183
|
/**
|
|
175
184
|
* Optional shaded X-range bands drawn over the plot, declared per view
|
|
176
185
|
* in project.json (static). These are drawn on every cycle.
|
|
@@ -336,16 +345,13 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
336
345
|
const [availableCycles, setAvailableCycles] = useState<number[]>([]);
|
|
337
346
|
const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
|
|
338
347
|
|
|
339
|
-
// All views, any type, in
|
|
340
|
-
// every one; the chart area dispatches on `view.type` to
|
|
341
|
-
// either a scatter or a raw_trace chart from the appropriate data.
|
|
342
|
-
const allViews = useMemo(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
return out;
|
|
348
|
-
}, [schema]);
|
|
348
|
+
// All views, any type, in the author's order (see `orderedViews`). The
|
|
349
|
+
// dropdown lists every one; the chart area dispatches on `view.type` to
|
|
350
|
+
// render either a scatter or a raw_trace chart from the appropriate data.
|
|
351
|
+
const allViews = useMemo(
|
|
352
|
+
() => orderedViews(schema?.views as Record<string, ChartView> | undefined),
|
|
353
|
+
[schema],
|
|
354
|
+
);
|
|
349
355
|
|
|
350
356
|
const [selectedView, setSelectedView] = useState<string | null>(
|
|
351
357
|
allViews.length > 0 ? allViews[0].name : null,
|
|
@@ -24,6 +24,7 @@ import { Line } from 'react-chartjs-2';
|
|
|
24
24
|
import type { ChartView, TestMethod } from './TestDataView';
|
|
25
25
|
import { useTis } from './TisProvider';
|
|
26
26
|
import { useRawCycleData } from './useRawCycleData';
|
|
27
|
+
import { orderedViews } from './chartViews';
|
|
27
28
|
|
|
28
29
|
ChartJS.register(
|
|
29
30
|
CategoryScale, LinearScale, PointElement, LineElement,
|
|
@@ -55,13 +56,11 @@ export const TestRawDataView: React.FC<TestRawDataViewProps> = (props) => {
|
|
|
55
56
|
const chartRef = useRef<any>(null);
|
|
56
57
|
|
|
57
58
|
// raw_trace-capable views only — cycle scatter lives in <TestDataView>.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return out;
|
|
64
|
-
}, [schema]);
|
|
59
|
+
// In the author's order, same as the unified panel's picker.
|
|
60
|
+
const traceViews = useMemo(
|
|
61
|
+
() => orderedViews(schema?.views as Record<string, ChartView> | undefined, 'raw_trace'),
|
|
62
|
+
[schema],
|
|
63
|
+
);
|
|
65
64
|
|
|
66
65
|
const [selectedView, setSelectedView] = useState<string | null>(
|
|
67
66
|
traceViews.length > 0 ? traceViews[0].name : null,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart-view ordering.
|
|
3
|
+
*
|
|
4
|
+
* `views` is a JSON object and the server holds it in a `BTreeMap`, so what
|
|
5
|
+
* reaches the HMI is sorted by view id — never by the order anyone chose. That
|
|
6
|
+
* order is a real setting: the Data tab opens on the FIRST view, so whichever
|
|
7
|
+
* id happens to sort first becomes the default chart. Renaming views to spell
|
|
8
|
+
* an order is not an option (the id is the wire key, and codegen emits it).
|
|
9
|
+
*
|
|
10
|
+
* So the order is carried explicitly, per view, as `order`: an integer,
|
|
11
|
+
* ascending, renumbered densely by the editor's Move up / Move down.
|
|
12
|
+
*
|
|
13
|
+
* `order` is deliberately NOT modelled by the Rust `ChartView`. It rides in
|
|
14
|
+
* that struct's flattened `extra` map, the way `x_decimals`, `smooth` and
|
|
15
|
+
* `regions` already do, so a `tis.put_method` → `tis.save_config` round-trip
|
|
16
|
+
* preserves it with no server-side change to deploy.
|
|
17
|
+
*
|
|
18
|
+
* A view with no `order` sorts after every ordered one, by id — so a method
|
|
19
|
+
* that has never been through the editor keeps exactly the order it shows
|
|
20
|
+
* today, and a newly added view lands last instead of jumping to the front.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Minimal shape this module needs; both the editor's and the runtime's
|
|
24
|
+
* `ChartView` satisfy it. */
|
|
25
|
+
export interface OrderableView {
|
|
26
|
+
order?: number;
|
|
27
|
+
type?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A view together with its id — the id is the wire key, not a field. */
|
|
31
|
+
export interface NamedView<V> {
|
|
32
|
+
name: string;
|
|
33
|
+
view: V;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sort key for a view that declares no `order`: after all ordered views. */
|
|
37
|
+
const UNORDERED = Number.MAX_SAFE_INTEGER;
|
|
38
|
+
|
|
39
|
+
const orderOf = (v: OrderableView | undefined): number =>
|
|
40
|
+
typeof v?.order === 'number' && Number.isFinite(v.order) ? v.order : UNORDERED;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The method's views in display order: by `order` ascending, then by id.
|
|
44
|
+
*
|
|
45
|
+
* @param views The method's `views` map, as received from the server.
|
|
46
|
+
* @param ofType Optional `type` filter (e.g. `'raw_trace'`).
|
|
47
|
+
*/
|
|
48
|
+
export function orderedViews<V extends OrderableView>(
|
|
49
|
+
views?: Record<string, V> | null,
|
|
50
|
+
ofType?: string,
|
|
51
|
+
): NamedView<V>[] {
|
|
52
|
+
const out: NamedView<V>[] = [];
|
|
53
|
+
for (const [name, view] of Object.entries(views ?? {})) {
|
|
54
|
+
if (ofType && view?.type !== ofType) continue;
|
|
55
|
+
out.push({ name, view });
|
|
56
|
+
}
|
|
57
|
+
out.sort((a, b) => {
|
|
58
|
+
const d = orderOf(a.view) - orderOf(b.view);
|
|
59
|
+
return d !== 0 ? d : a.name.localeCompare(b.name);
|
|
60
|
+
});
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stamp `order` 0..n-1 onto `views` following `orderedIds`.
|
|
66
|
+
*
|
|
67
|
+
* Every view is renumbered, not just the moved one: dense integers mean the
|
|
68
|
+
* next move is a swap of two adjacent numbers, and a method that was
|
|
69
|
+
* previously unordered comes out fully ordered after one Move rather than
|
|
70
|
+
* half-and-half.
|
|
71
|
+
*
|
|
72
|
+
* Ids in `views` but missing from `orderedIds` keep their existing `order`
|
|
73
|
+
* and land after the renumbered ones.
|
|
74
|
+
*/
|
|
75
|
+
export function renumberViews<V extends OrderableView>(
|
|
76
|
+
views: Record<string, V>,
|
|
77
|
+
orderedIds: string[],
|
|
78
|
+
): Record<string, V> {
|
|
79
|
+
const out: Record<string, V> = {};
|
|
80
|
+
orderedIds.forEach((id, i) => {
|
|
81
|
+
const v = views[id];
|
|
82
|
+
if (v) out[id] = { ...v, order: i };
|
|
83
|
+
});
|
|
84
|
+
for (const [id, v] of Object.entries(views)) {
|
|
85
|
+
if (!(id in out)) out[id] = v;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Move the view at `index` by `dir` (-1 up, +1 down) and renumber.
|
|
92
|
+
* Out-of-range moves return the map unchanged.
|
|
93
|
+
*/
|
|
94
|
+
export function moveView<V extends OrderableView>(
|
|
95
|
+
views: Record<string, V>,
|
|
96
|
+
index: number,
|
|
97
|
+
dir: -1 | 1,
|
|
98
|
+
): Record<string, V> {
|
|
99
|
+
const ids = orderedViews(views).map(v => v.name);
|
|
100
|
+
const j = index + dir;
|
|
101
|
+
if (index < 0 || index >= ids.length || j < 0 || j >= ids.length) return views;
|
|
102
|
+
[ids[index], ids[j]] = [ids[j], ids[index]];
|
|
103
|
+
return renumberViews(views, ids);
|
|
104
|
+
}
|
|
@@ -5,6 +5,7 @@ import { InputText } from 'primereact/inputtext';
|
|
|
5
5
|
import { Dropdown } from 'primereact/dropdown';
|
|
6
6
|
import { InputNumber } from 'primereact/inputnumber';
|
|
7
7
|
import { FormRow } from '../../forms/FormRow';
|
|
8
|
+
import { useEditDraft } from '../../forms/useEditDraft';
|
|
8
9
|
import { DEFAULT_X_DECIMALS } from '../../tis/TestDataView';
|
|
9
10
|
import type { ChartAxis, ChartSeries, ChartView } from '../types';
|
|
10
11
|
|
|
@@ -43,17 +44,30 @@ const blank = (): ChartView => ({
|
|
|
43
44
|
export const ChartViewDialog: React.FC<ChartViewDialogProps> = ({
|
|
44
45
|
visible, initial, onCancel, onSave, knownKeys, siblingIds,
|
|
45
46
|
}) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// One draft covering both edited values (the id is editable too), seeded
|
|
48
|
+
// once per open. Keyed on the view id rather than on `initial` — the
|
|
49
|
+
// parent rebuilds that object on every render of its own, and this editor
|
|
50
|
+
// re-renders with the live tag traffic underneath it, so an identity-keyed
|
|
51
|
+
// reseed threw away every keystroke as it was typed.
|
|
52
|
+
const { draft: form, setDraft: setForm, token } = useEditDraft(
|
|
53
|
+
visible,
|
|
54
|
+
initial?.viewId ?? '',
|
|
55
|
+
() => ({
|
|
56
|
+
viewId: initial?.viewId ?? '',
|
|
57
|
+
view: initial
|
|
58
|
+
? (JSON.parse(JSON.stringify(initial.view)) as ChartView)
|
|
59
|
+
: blank(),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
const viewId = form.viewId;
|
|
63
|
+
const draft = form.view;
|
|
64
|
+
const setViewId = (id: string) => setForm(f => ({ ...f, viewId: id }));
|
|
65
|
+
const setDraft = (view: ChartView) => setForm(f => ({ ...f, view }));
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
setError(null);
|
|
55
|
-
}
|
|
56
|
-
}, [visible, initial]);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
// Clears on open and on a switch to another view — `token` changes on
|
|
69
|
+
// exactly those transitions and on no other render.
|
|
70
|
+
useEffect(() => { setError(null); }, [token]);
|
|
57
71
|
|
|
58
72
|
const validate = (): string | null => {
|
|
59
73
|
if (!viewId.trim()) return 'View ID is required.';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import { Dialog } from 'primereact/dialog';
|
|
3
3
|
import { Button } from 'primereact/button';
|
|
4
4
|
import { InputText } from 'primereact/inputtext';
|
|
@@ -7,10 +7,12 @@ import { Dropdown } from 'primereact/dropdown';
|
|
|
7
7
|
import { Checkbox } from 'primereact/checkbox';
|
|
8
8
|
import { InputNumber } from 'primereact/inputnumber';
|
|
9
9
|
import { FormRow } from '../../forms/FormRow';
|
|
10
|
+
import { useEditDraft } from '../../forms/useEditDraft';
|
|
10
11
|
import type { TestField, AggFn, AggFrom, AggScope, AggregateSpec } from '../types';
|
|
11
12
|
// One home for bound parsing/formatting — shared with the operator-facing
|
|
12
13
|
// form so the editor and the runtime can never disagree on what a bound is.
|
|
13
14
|
import { boundText, parseBound, boundInvalid } from '../../tis/TestFieldRow';
|
|
15
|
+
import { AutoCoreTagContext } from '../../../core/AutoCoreTagContext';
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
const FIELD_TYPES: { label: string; value: string }[] = [
|
|
@@ -81,19 +83,42 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
|
|
|
81
83
|
isResultsField = false, isCycleConfigField = false,
|
|
82
84
|
cycleFieldNames = [], resultsFieldNames = [],
|
|
83
85
|
}) => {
|
|
84
|
-
|
|
85
|
-
// `default` is edited as free text so a numeric literal, a
|
|
86
|
-
// `${fqdn}` token can all be entered in one field; it's
|
|
87
|
-
|
|
86
|
+
// The field under edit plus its free-text Default, as one draft seeded
|
|
87
|
+
// once per open. `default` is edited as free text so a numeric literal, a
|
|
88
|
+
// string, or an `${fqdn}` token can all be entered in one field; it's
|
|
89
|
+
// coerced on save.
|
|
90
|
+
//
|
|
91
|
+
// Keyed on the field NAME, not on the `initial` object: this editor
|
|
92
|
+
// re-renders with the live tag traffic underneath it, and an
|
|
93
|
+
// identity-keyed reseed discards the operator's typing (see
|
|
94
|
+
// useEditDraft).
|
|
95
|
+
const { draft: form, setDraft: setForm, token } = useEditDraft(
|
|
96
|
+
visible,
|
|
97
|
+
initial?.name ?? '',
|
|
98
|
+
() => ({
|
|
99
|
+
field: initial ? { ...initial } : { ...blank },
|
|
100
|
+
defaultText: initial?.default == null ? '' : String(initial.default),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
const draft = form.field;
|
|
104
|
+
const defaultText = form.defaultText;
|
|
105
|
+
const setDraft = (field: TestField) => setForm(f => ({ ...f, field }));
|
|
106
|
+
const setDefaultText = (defaultText: string) => setForm(f => ({ ...f, defaultText }));
|
|
107
|
+
|
|
88
108
|
const [error, setError] = useState<string | null>(null);
|
|
109
|
+
// Clears on open and on a switch to another field, and on no other render.
|
|
110
|
+
useEffect(() => { setError(null); }, [token]);
|
|
89
111
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
// The quantity names this machine actually declares, from the resolved
|
|
113
|
+
// units table. Offered as a dropdown so an author picks a row that exists
|
|
114
|
+
// instead of typing one that silently never resolves — but `editable`, so a
|
|
115
|
+
// method can still be authored against a quantity the bench machine has yet
|
|
116
|
+
// to declare.
|
|
117
|
+
const { scales } = useContext(AutoCoreTagContext);
|
|
118
|
+
const quantityOptions = useMemo(
|
|
119
|
+
() => Object.keys(scales ?? {}).sort().map(q => ({ label: q, value: q })),
|
|
120
|
+
[scales],
|
|
121
|
+
);
|
|
97
122
|
|
|
98
123
|
// Turn the free-text Default into its stored form: an `${fqdn}` token and
|
|
99
124
|
// other non-numerics stay strings; numeric/bool literals are stored typed.
|
|
@@ -200,7 +225,23 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
|
|
|
200
225
|
editable
|
|
201
226
|
/>
|
|
202
227
|
</FormRow>
|
|
203
|
-
<FormRow
|
|
228
|
+
<FormRow
|
|
229
|
+
label="Quantity"
|
|
230
|
+
hint="Ties this field to a row of the machine's units.json, so it follows the operator's Metric/Imperial choice and takes its unit label from the active system. Set this in preference to Units/Scale, which are fixed."
|
|
231
|
+
>
|
|
232
|
+
<Dropdown
|
|
233
|
+
value={draft.quantity ?? ''}
|
|
234
|
+
options={quantityOptions}
|
|
235
|
+
onChange={(e) => setDraft({ ...draft, quantity: e.value || undefined })}
|
|
236
|
+
placeholder="— none (fixed Units/Scale below) —"
|
|
237
|
+
showClear
|
|
238
|
+
editable
|
|
239
|
+
/>
|
|
240
|
+
</FormRow>
|
|
241
|
+
<FormRow
|
|
242
|
+
label="Units"
|
|
243
|
+
hint="Fixed display label appended to form labels (e.g. m/s). Ignored when Quantity is set — the active unit system supplies the label."
|
|
244
|
+
>
|
|
204
245
|
<InputText
|
|
205
246
|
value={draft.units ?? ''}
|
|
206
247
|
onChange={(e) => setDraft({ ...draft, units: e.target.value || undefined })}
|
|
@@ -3,6 +3,7 @@ import { DataTable } from 'primereact/datatable';
|
|
|
3
3
|
import { Column } from 'primereact/column';
|
|
4
4
|
import { Button } from 'primereact/button';
|
|
5
5
|
import { FormSection } from '../../forms/FormSection';
|
|
6
|
+
import { moveView, orderedViews } from '../../tis/chartViews';
|
|
6
7
|
import { ChartViewDialog } from './ChartViewDialog';
|
|
7
8
|
import type { ChartView, TestField, TestMethod } from '../types';
|
|
8
9
|
|
|
@@ -19,8 +20,11 @@ interface ViewRow {
|
|
|
19
20
|
|
|
20
21
|
export const ViewsEditor: React.FC<ViewsEditorProps> = ({ method, onChange }) => {
|
|
21
22
|
const views = (method.views as Record<string, ChartView>) ?? {};
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Display order, not id order — the operator sets it with the arrows and
|
|
24
|
+
// the runtime reads the same `order` key. The first row is the view the
|
|
25
|
+
// Data tab opens on.
|
|
26
|
+
const rows: ViewRow[] = orderedViews(views).map(({ name, view }) => ({
|
|
27
|
+
id: name, title: view.title ?? '', type: view.type ?? '',
|
|
24
28
|
}));
|
|
25
29
|
|
|
26
30
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
@@ -57,6 +61,14 @@ export const ViewsEditor: React.FC<ViewsEditorProps> = ({ method, onChange }) =>
|
|
|
57
61
|
setDialogOpen(false);
|
|
58
62
|
};
|
|
59
63
|
|
|
64
|
+
// Move up / down renumbers `order` densely across every view, so the next
|
|
65
|
+
// move is a swap of adjacent integers whatever the method started as.
|
|
66
|
+
const handleMove = (idx: number, dir: -1 | 1) => {
|
|
67
|
+
const next = moveView(views, idx, dir);
|
|
68
|
+
if (next === views) return;
|
|
69
|
+
onChange({ ...method, views: next });
|
|
70
|
+
};
|
|
71
|
+
|
|
60
72
|
const handleRemove = (id: string) => {
|
|
61
73
|
if (!window.confirm(`Remove view "${id}"?`)) return;
|
|
62
74
|
const next = { ...views };
|
|
@@ -64,8 +76,22 @@ export const ViewsEditor: React.FC<ViewsEditorProps> = ({ method, onChange }) =>
|
|
|
64
76
|
onChange({ ...method, views: next });
|
|
65
77
|
};
|
|
66
78
|
|
|
67
|
-
const rowActions = (r: ViewRow) => (
|
|
79
|
+
const rowActions = (r: ViewRow, opts: { rowIndex: number }) => (
|
|
68
80
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
|
81
|
+
<Button
|
|
82
|
+
icon="pi pi-arrow-up"
|
|
83
|
+
className="p-button-text p-button-sm"
|
|
84
|
+
disabled={opts.rowIndex === 0}
|
|
85
|
+
onClick={() => handleMove(opts.rowIndex, -1)}
|
|
86
|
+
aria-label="Move up"
|
|
87
|
+
/>
|
|
88
|
+
<Button
|
|
89
|
+
icon="pi pi-arrow-down"
|
|
90
|
+
className="p-button-text p-button-sm"
|
|
91
|
+
disabled={opts.rowIndex === rows.length - 1}
|
|
92
|
+
onClick={() => handleMove(opts.rowIndex, 1)}
|
|
93
|
+
aria-label="Move down"
|
|
94
|
+
/>
|
|
69
95
|
<Button
|
|
70
96
|
icon="pi pi-pencil"
|
|
71
97
|
className="p-button-text p-button-sm"
|
|
@@ -85,14 +111,14 @@ export const ViewsEditor: React.FC<ViewsEditorProps> = ({ method, onChange }) =>
|
|
|
85
111
|
<>
|
|
86
112
|
<FormSection
|
|
87
113
|
title="Views"
|
|
88
|
-
description="Named chart definitions rendered by <TestDataView> (cycle_scatter) and <TestRawDataView> (raw_trace)."
|
|
114
|
+
description="Named chart definitions rendered by <TestDataView> (cycle_scatter) and <TestRawDataView> (raw_trace). Top to bottom is the order the operator sees in the view picker, and the first one is the chart the Data tab opens on."
|
|
89
115
|
actions={<Button label="Add view" icon="pi pi-plus" size="small" onClick={openNew} />}
|
|
90
116
|
>
|
|
91
117
|
<DataTable value={rows} dataKey="id" emptyMessage="No views defined.">
|
|
92
118
|
<Column field="id" header="View ID" />
|
|
93
119
|
<Column field="title" header="Title" />
|
|
94
120
|
<Column field="type" header="Type" style={{ width: '8rem' }} />
|
|
95
|
-
<Column header="" body={rowActions} style={{ width: '
|
|
121
|
+
<Column header="" body={rowActions} style={{ width: '10rem' }} />
|
|
96
122
|
</DataTable>
|
|
97
123
|
</FormSection>
|
|
98
124
|
<ChartViewDialog
|
|
@@ -15,6 +15,11 @@ export type FieldType =
|
|
|
15
15
|
export interface TestField {
|
|
16
16
|
name: string;
|
|
17
17
|
type: FieldType | string;
|
|
18
|
+
/** Scale-group name from the machine's `units.json` ("force", "position",
|
|
19
|
+
* …). What makes this field follow the operator's Metric/Imperial choice;
|
|
20
|
+
* supersedes `units` / `scale`. Declared here so the editor round-trips it
|
|
21
|
+
* — see `TestFieldDialog`'s Quantity row. */
|
|
22
|
+
quantity?: string;
|
|
18
23
|
units?: string;
|
|
19
24
|
required?: boolean;
|
|
20
25
|
source?: string;
|
|
@@ -99,6 +104,12 @@ export interface ChartView {
|
|
|
99
104
|
* prints 17-digit ticks.
|
|
100
105
|
*/
|
|
101
106
|
x_decimals?: number;
|
|
107
|
+
/**
|
|
108
|
+
* Position in the view picker, ascending. Set by the editor's Move
|
|
109
|
+
* up / Move down; see `components/tis/chartViews.ts` for why the order
|
|
110
|
+
* cannot just be the order of the keys.
|
|
111
|
+
*/
|
|
112
|
+
order?: number;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
export type RawColumnSource = 'time' | 'derived' | string; // also `ni.<daq>.channels.<name>`
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
.ac-formsection {
|
|
2
|
+
border: 1px solid var(--surface-d, #e2e8f0);
|
|
3
|
+
border-radius: 6px;
|
|
4
|
+
background: var(--surface-card, #fff);
|
|
5
|
+
margin-bottom: 1rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ac-formsection__header {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: flex-start;
|
|
11
|
+
justify-content: space-between;
|
|
12
|
+
padding: 0.5rem 1rem;
|
|
13
|
+
border-bottom: 1px solid var(--surface-d, #e2e8f0);
|
|
14
|
+
background: var(--surface-b, #f8fafc);
|
|
15
|
+
border-top-left-radius: 6px;
|
|
16
|
+
border-top-right-radius: 6px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.ac-formsection__title {
|
|
20
|
+
margin: 0;
|
|
21
|
+
font-size: 0.95rem;
|
|
22
|
+
font-weight: 600;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ac-formsection__desc {
|
|
26
|
+
color: var(--text-color-secondary, #64748b);
|
|
27
|
+
display: block;
|
|
28
|
+
margin-top: 0.15rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ac-formsection__actions {
|
|
32
|
+
display: flex;
|
|
33
|
+
gap: 0.5rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.ac-formsection__body {
|
|
37
|
+
padding: 0.75rem 1rem;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
gap: 0.5rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.ac-formrow {
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-template-columns: minmax(8rem, 14rem) 1fr;
|
|
46
|
+
gap: 0.5rem 1rem;
|
|
47
|
+
align-items: start;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ac-formrow--error .ac-formrow__field input,
|
|
51
|
+
.ac-formrow--error .ac-formrow__field .p-inputtext {
|
|
52
|
+
border-color: #dc2626;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ac-formrow__label {
|
|
56
|
+
font-weight: 500;
|
|
57
|
+
padding-top: 0.4rem;
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ac-formrow__required {
|
|
63
|
+
color: #dc2626;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ac-formrow__hint {
|
|
67
|
+
color: var(--text-color-secondary, #64748b);
|
|
68
|
+
font-weight: 400;
|
|
69
|
+
font-size: 0.75rem;
|
|
70
|
+
margin-top: 0.15rem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ac-formrow__field {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 0.25rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ac-formrow__field > input,
|
|
80
|
+
.ac-formrow__field > .p-inputtext,
|
|
81
|
+
.ac-formrow__field > .p-dropdown,
|
|
82
|
+
.ac-formrow__field > .p-inputtextarea {
|
|
83
|
+
width: 100%;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ac-formrow__error {
|
|
87
|
+
color: #dc2626;
|
|
88
|
+
font-size: 0.75rem;
|
|
89
|
+
}
|
|
90
|
+
/*$vite$:1*/
|