@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.
Files changed (40) hide show
  1. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  2. package/dist/components/ams/AssetRegistryTable.js +1 -1
  3. package/dist/components/forms/useEditDraft.d.ts +49 -0
  4. package/dist/components/forms/useEditDraft.d.ts.map +1 -0
  5. package/dist/components/forms/useEditDraft.js +1 -0
  6. package/dist/components/index.d.ts +2 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +1 -1
  9. package/dist/components/tis/TestDataView.d.ts +8 -0
  10. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  11. package/dist/components/tis/TestDataView.js +1 -1
  12. package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
  13. package/dist/components/tis/TestRawDataView.js +1 -1
  14. package/dist/components/tis/chartViews.d.ts +57 -0
  15. package/dist/components/tis/chartViews.d.ts.map +1 -0
  16. package/dist/components/tis/chartViews.js +1 -0
  17. package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -1
  18. package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -1
  19. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  20. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  21. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  22. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  23. package/dist/components/tis-editor/types.d.ts +11 -0
  24. package/dist/components/tis-editor/types.d.ts.map +1 -1
  25. package/package.json +3 -1
  26. package/src/components/ams/AssetRegistryTable.tsx +40 -9
  27. package/src/components/forms/useEditDraft.ts +0 -0
  28. package/src/components/index.ts +3 -0
  29. package/src/components/tis/TestDataView.tsx +16 -10
  30. package/src/components/tis/TestRawDataView.tsx +6 -7
  31. package/src/components/tis/chartViews.ts +104 -0
  32. package/src/components/tis-editor/editor/ChartViewDialog.tsx +24 -10
  33. package/src/components/tis-editor/editor/TestFieldDialog.tsx +54 -13
  34. package/src/components/tis-editor/editor/ViewsEditor.tsx +31 -5
  35. package/src/components/tis-editor/types.ts +11 -0
  36. package/tools/tests/dist/tis-editor.entry.css +90 -0
  37. package/tools/tests/dist/tis-editor.entry.js +41431 -0
  38. package/tools/tests/tis-editor.entry.tsx +46 -0
  39. package/tools/tests/tis-editor.test.mjs +133 -0
  40. 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.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
- if (!addState.assetType) return;
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
- onHide={() => setAddState(EMPTY_ADD)}
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
- <Button label="Cancel" severity="secondary" onClick={() => setAddState(EMPTY_ADD)} />
473
- <Button label="Create" icon="pi pi-check"
474
- onClick={onCreate}
475
- disabled={createDisabled}
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' }}>
@@ -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 declaration order. The dropdown lists
340
- // every one; the chart area dispatches on `view.type` to render
341
- // either a scatter or a raw_trace chart from the appropriate data.
342
- const allViews = useMemo(() => {
343
- const out: { name: string; view: ChartView }[] = [];
344
- for (const [name, v] of Object.entries(schema?.views ?? {})) {
345
- out.push({ name, view: v as ChartView });
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
- const traceViews = useMemo(() => {
59
- const out: { name: string; view: ChartView }[] = [];
60
- for (const [name, v] of Object.entries(schema?.views ?? {})) {
61
- if ((v as ChartView).type === 'raw_trace') out.push({ name, view: v as ChartView });
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
- const [viewId, setViewId] = useState<string>('');
47
- const [draft, setDraft] = useState<ChartView>(blank());
48
- const [error, setError] = useState<string | null>(null);
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
- useEffect(() => {
51
- if (visible) {
52
- setViewId(initial?.viewId ?? '');
53
- setDraft(initial ? JSON.parse(JSON.stringify(initial.view)) : blank());
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
- const [draft, setDraft] = useState<TestField>(blank);
85
- // `default` is edited as free text so a numeric literal, a string, or an
86
- // `${fqdn}` token can all be entered in one field; it's coerced on save.
87
- const [defaultText, setDefaultText] = useState<string>('');
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
- useEffect(() => {
91
- if (visible) {
92
- setDraft(initial ? { ...initial } : { ...blank });
93
- setDefaultText(initial?.default == null ? '' : String(initial.default));
94
- setError(null);
95
- }
96
- }, [visible, initial]);
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 label="Units" hint="Display label, appended to form labels (e.g. m/s).">
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
- const rows: ViewRow[] = Object.entries(views).map(([id, v]) => ({
23
- id, title: v.title ?? '', type: v.type ?? '',
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: '6rem' }} />
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*/