@adcops/autocore-react 3.5.15 → 3.6.5

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 (45) hide show
  1. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  2. package/dist/components/tis/TestDataView.js +1 -1
  3. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  4. package/dist/components/tis/TisProvider.js +1 -1
  5. package/dist/components/tis-editor/DeleteMethodDialog.d.ts +43 -0
  6. package/dist/components/tis-editor/DeleteMethodDialog.d.ts.map +1 -0
  7. package/dist/components/tis-editor/DeleteMethodDialog.js +1 -0
  8. package/dist/components/tis-editor/MethodTransfer.d.ts +69 -0
  9. package/dist/components/tis-editor/MethodTransfer.d.ts.map +1 -0
  10. package/dist/components/tis-editor/MethodTransfer.js +1 -0
  11. package/dist/components/tis-editor/TisConfigEditor.css +31 -0
  12. package/dist/components/tis-editor/TisConfigEditor.d.ts +9 -1
  13. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  14. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  15. package/dist/components/tis-editor/editor/IdentitySection.d.ts.map +1 -1
  16. package/dist/components/tis-editor/editor/IdentitySection.js +1 -1
  17. package/dist/components/tis-editor/types.d.ts +11 -0
  18. package/dist/components/tis-editor/types.d.ts.map +1 -1
  19. package/dist/components/varpick/VariablePicker.d.ts.map +1 -1
  20. package/dist/components/varpick/VariablePicker.js +1 -1
  21. package/dist/components/varpick/varpick.css +84 -30
  22. package/dist/hooks/index.d.ts +2 -0
  23. package/dist/hooks/index.d.ts.map +1 -1
  24. package/dist/hooks/index.js +1 -1
  25. package/dist/hooks/useMethodSequence.d.ts +60 -0
  26. package/dist/hooks/useMethodSequence.d.ts.map +1 -0
  27. package/dist/hooks/useMethodSequence.js +1 -0
  28. package/package.json +1 -1
  29. package/src/components/tis/TestDataView.tsx +39 -1
  30. package/src/components/tis/TisProvider.tsx +54 -9
  31. package/src/components/tis-editor/DeleteMethodDialog.tsx +162 -0
  32. package/src/components/tis-editor/MethodTransfer.ts +138 -0
  33. package/src/components/tis-editor/TisConfigEditor.css +31 -0
  34. package/src/components/tis-editor/TisConfigEditor.tsx +305 -7
  35. package/src/components/tis-editor/editor/IdentitySection.tsx +33 -1
  36. package/src/components/tis-editor/types.ts +11 -0
  37. package/src/components/varpick/VariablePicker.tsx +4 -0
  38. package/src/components/varpick/varpick.css +84 -30
  39. package/src/hooks/index.ts +4 -0
  40. package/src/hooks/useMethodSequence.ts +128 -0
  41. package/tools/tests/dist/{TestFieldDialog-BvHAr2GQ.js → TestFieldDialog-Dph2oPfe.js} +1 -0
  42. package/tools/tests/dist/autocore-react.css +84 -30
  43. package/tools/tests/dist/tis-editor.entry.js +1 -1
  44. package/tools/tests/dist/varpick.entry.js +1 -1
  45. package/tools/tests/varpick.test.mjs +5 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * MethodTransfer — a whole test method as one portable file.
3
+ *
4
+ * ## Why the method and not the sequence
5
+ *
6
+ * A sequence-backed method is two halves with a contract between them: the
7
+ * schema declares the cycle columns, and the procedure's `store_value` steps
8
+ * fill them by name. Exporting either half alone produces something that looks
9
+ * like a method and silently is not — a schema with no producer records blank
10
+ * rows on every cycle forever, and a procedure with no schema stores values
11
+ * nothing displays. Neither failure announces itself.
12
+ *
13
+ * So the exportable unit is the pair. This is the same reasoning that made
14
+ * `acctl push methods` move both files with one verb.
15
+ *
16
+ * ## The file
17
+ *
18
+ * Plain JSON, human-readable, no archive container: it goes on a USB stick
19
+ * between companies and someone will open it in a text editor.
20
+ *
21
+ * {
22
+ * "format": "autocore-method",
23
+ * "version": 1,
24
+ * "method_id": "golf_compression",
25
+ * "schema": { ...test_methods.json entry... },
26
+ * "sequence": { ...seq.json..., or null for a control-backed method }
27
+ * }
28
+ *
29
+ * `method_id` is a SUGGESTION, not an instruction. The importer offers it and
30
+ * lets the operator rename, because a method list that fills up with
31
+ * `golf_compression_copy_3` is the complaint that follows otherwise.
32
+ */
33
+
34
+ import type { TestMethod } from './types';
35
+
36
+ export const METHOD_FILE_FORMAT = 'autocore-method';
37
+ export const METHOD_FILE_VERSION = 1;
38
+
39
+ export interface MethodTransferFile {
40
+ format: typeof METHOD_FILE_FORMAT;
41
+ version: number;
42
+ method_id: string;
43
+ schema: TestMethod;
44
+ /** The procedure, or null for a control-backed method. */
45
+ sequence: any | null;
46
+ }
47
+
48
+ /** Build the export payload. */
49
+ export function buildTransfer(
50
+ methodId: string,
51
+ schema: TestMethod,
52
+ sequence: any | null,
53
+ ): MethodTransferFile {
54
+ return {
55
+ format: METHOD_FILE_FORMAT,
56
+ version: METHOD_FILE_VERSION,
57
+ method_id: methodId,
58
+ schema,
59
+ sequence: sequence ?? null,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Legal method ids, and why the rule is this strict.
65
+ *
66
+ * The id becomes a path segment (`methods/<id>.seq.json`) and a directory name
67
+ * under the project (`<project>/<method_id>/<run_id>/`), both joined
68
+ * server-side, so anything that could traverse or quote-escape is refused at
69
+ * entry rather than sanitised silently — an id that comes back different from
70
+ * what was typed is how you end up with two methods you believe are one.
71
+ *
72
+ * It is also a Rust identifier stem in generated code, hence the leading-letter
73
+ * rule: `2point_bend` would emit `2pointBendTestManager`, which does not
74
+ * compile.
75
+ */
76
+ export const METHOD_ID_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
77
+ export const METHOD_ID_RULE =
78
+ 'Letters, digits and underscore only; must start with a letter.';
79
+
80
+ /** Parse and validate an imported file. Returns the file or a reason. */
81
+ export function parseTransfer(text: string): { file: MethodTransferFile } | { error: string } {
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(text);
85
+ } catch (e) {
86
+ return { error: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
87
+ }
88
+ if (typeof parsed !== 'object' || parsed === null) {
89
+ return { error: 'File does not contain a method object.' };
90
+ }
91
+ const f = parsed as Partial<MethodTransferFile>;
92
+ if (f.format !== METHOD_FILE_FORMAT) {
93
+ // Name what it looks like. A `.seq.json` dropped here is the most
94
+ // likely wrong file, and "unrecognised format" would leave the operator
95
+ // with no idea which of their two files to reach for.
96
+ const looksLikeSequence = Array.isArray((parsed as any)?.steps);
97
+ return {
98
+ error: looksLikeSequence
99
+ ? 'This is a sequence file, not a whole method. Import it from a method export, '
100
+ + 'or create the method first and edit its sequence on the Method screen.'
101
+ : 'Not an autocore method export (missing "format": "autocore-method").',
102
+ };
103
+ }
104
+ if (typeof f.version !== 'number' || f.version > METHOD_FILE_VERSION) {
105
+ return {
106
+ error: `This file was written by a newer version (v${f.version}); `
107
+ + `this machine understands up to v${METHOD_FILE_VERSION}.`,
108
+ };
109
+ }
110
+ if (typeof f.method_id !== 'string' || !METHOD_ID_RE.test(f.method_id)) {
111
+ return { error: `File has no usable method id. ${METHOD_ID_RULE}` };
112
+ }
113
+ if (typeof f.schema !== 'object' || f.schema === null) {
114
+ return { error: 'File has no method schema.' };
115
+ }
116
+ // A sequence-backed method with no procedure in the file is a half export;
117
+ // refuse it rather than importing a method that cannot run.
118
+ if ((f.schema as TestMethod).procedure === 'sequence' && !Array.isArray(f.sequence?.steps)) {
119
+ return {
120
+ error: 'This method declares a sequence procedure but the file carries no sequence. '
121
+ + 'Re-export it from the machine it came from.',
122
+ };
123
+ }
124
+ return { file: f as MethodTransferFile };
125
+ }
126
+
127
+ /** Hand the browser a method export to save. */
128
+ export function downloadTransfer(file: MethodTransferFile): void {
129
+ const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
130
+ const url = URL.createObjectURL(blob);
131
+ const a = document.createElement('a');
132
+ a.href = url;
133
+ a.download = `${file.method_id}.method.json`;
134
+ a.click();
135
+ // Revoke on the next tick: revoking synchronously can beat the download in
136
+ // Chromium, which is the browser on every panel.
137
+ setTimeout(() => URL.revokeObjectURL(url), 0);
138
+ }
@@ -150,3 +150,34 @@
150
150
  border-bottom: 1px solid var(--surface-d, #e2e8f0);
151
151
  }
152
152
  }
153
+
154
+ /*
155
+ * The three-step method deletion. Numbered because the steps are ordered and
156
+ * gated: the archive download unlocks the confirmation phrase, which unlocks
157
+ * the button. See DeleteMethodDialog for why deleting a method earns this.
158
+ */
159
+ .tis-editor__delete-steps {
160
+ margin: 0 0 1rem;
161
+ padding-left: 1.4rem;
162
+ }
163
+
164
+ .tis-editor__delete-steps > li {
165
+ margin-bottom: 1rem;
166
+ }
167
+
168
+ .tis-editor__delete-steps p {
169
+ margin: 0.35rem 0 0.5rem;
170
+ }
171
+
172
+ .tis-editor__delete-steps ul {
173
+ margin: 0.35rem 0 0;
174
+ padding-left: 1.1rem;
175
+ }
176
+
177
+ .tis-editor__delete-steps code {
178
+ padding: 0.1rem 0.35rem;
179
+ border-radius: 3px;
180
+ background: var(--surface-ground);
181
+ font-family: var(--font-family-mono, monospace);
182
+ letter-spacing: 0.04em;
183
+ }
@@ -4,7 +4,15 @@
4
4
  * Left: PrimeReact DataTable listing methods (method_id + label).
5
5
  * Right: tabbed editor for the selected method. Phase 1 ships a single
6
6
  * JSON-via-Monaco tab; Phase 2 layers form editors on top of it.
7
- * Action bar: New / Duplicate / Delete / Apply / Save / Revert.
7
+ * Action bar: New / Duplicate / Delete / Import / Export / Apply / Save / Revert.
8
+ *
9
+ * ## A method is two files
10
+ *
11
+ * A method whose `procedure` is `"sequence"` also owns
12
+ * `methods/<method_id>.seq.json`. This editor is the only place a method is
13
+ * created, copied, deleted or moved between machines, so it is the only place
14
+ * that can keep the two halves together — see `useMethodSequence` for what goes
15
+ * wrong when they drift. Every lifecycle action below therefore moves both.
8
16
  *
9
17
  * "Apply" pushes the local Monaco buffer to the server-side stage
10
18
  * (`tis.put_method`). "Save" persists the entire stage to test_methods.json
@@ -15,12 +23,14 @@
15
23
  * client gives clearer UX.
16
24
  */
17
25
 
18
- import { useEffect, useMemo, useState } from 'react';
26
+ import { useEffect, useMemo, useRef, useState } from 'react';
19
27
  import { DataTable } from 'primereact/datatable';
20
28
  import { Column } from 'primereact/column';
21
29
  import { Button } from 'primereact/button';
22
30
  import { InputText } from 'primereact/inputtext';
23
31
  import { Dialog } from 'primereact/dialog';
32
+ import { Message } from 'primereact/message';
33
+ import { SelectButton } from 'primereact/selectbutton';
24
34
  import { useContext } from 'react';
25
35
  import { EventEmitterContext } from '../../core/EventEmitterContext';
26
36
  import { MessageType } from '../../hub/CommandMessage';
@@ -28,7 +38,13 @@ import { useTisConfig, type TisIpcInvoker } from '../../hooks/useTisConfig';
28
38
  import { useAmsAssetTypes } from '../../hooks/useAmsAssetTypes';
29
39
  import { MethodFormEditor } from './editor/MethodFormEditor';
30
40
  import { SaveDiffDialog } from './editor/SaveDiffDialog';
41
+ import { DeleteMethodDialog } from './DeleteMethodDialog';
42
+ import {
43
+ buildTransfer, downloadTransfer, METHOD_ID_RE, METHOD_ID_RULE, parseTransfer,
44
+ type MethodTransferFile,
45
+ } from './MethodTransfer';
31
46
  import type { TestMethod } from './types';
47
+ import { useMethodSequence, emptySequence } from '../../hooks/useMethodSequence';
32
48
 
33
49
  import './TisConfigEditor.css';
34
50
  import { useGmVariables } from '../../hooks/useGmVariables';
@@ -88,6 +104,28 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
88
104
  // New-method dialog state.
89
105
  const [newDialogOpen, setNewDialogOpen] = useState<boolean>(false);
90
106
  const [newId, setNewId] = useState<string>('');
107
+ // A new method declares its procedure up front, because the choice decides
108
+ // whether creating it also scaffolds a sequence file. Defaulting to
109
+ // `sequence` is deliberate on a machine that has a sequencer: the other
110
+ // option cannot be made to work without a rebuild, so offering it as the
111
+ // default would be offering the operator a dead end.
112
+ const [newProcedure, setNewProcedure] = useState<'control' | 'sequence'>('sequence');
113
+
114
+ // Delete flow (archive + typed confirmation) — see DeleteMethodDialog.
115
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
116
+
117
+ // Import flow. `pendingImport` holds a parsed file waiting on the operator
118
+ // to resolve a name collision.
119
+ const fileInput = useRef<HTMLInputElement>(null);
120
+ const [pendingImport, setPendingImport] = useState<MethodTransferFile | null>(null);
121
+ const [importId, setImportId] = useState<string>('');
122
+ const [importError, setImportError] = useState<string | null>(null);
123
+
124
+ // The sequence half of a method. All method lifecycle actions move both.
125
+ // No `invoker` override: TisIpcInvoker speaks the `tis.*` request shape
126
+ // (topic, payload), and these are raw datastore reads and writes that need
127
+ // the message type as well.
128
+ const seqIo = useMethodSequence();
91
129
 
92
130
  const rows: MethodRow[] = useMemo(() => {
93
131
  if (!tis.config) return [];
@@ -135,13 +173,31 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
135
173
  const onCreate = async () => {
136
174
  const id = newId.trim();
137
175
  if (!id) return;
176
+ // Validated here and not just in the dialog: the id becomes a path
177
+ // segment and a Rust identifier stem downstream, and neither of those
178
+ // failures is legible where they surface.
179
+ if (!METHOD_ID_RE.test(id)) {
180
+ setDraftError(`"${id}" is not a valid method id. ${METHOD_ID_RULE}`);
181
+ return;
182
+ }
138
183
  if (tis.config?.methods[id]) {
139
184
  setDraftError(`A method named "${id}" already exists.`);
140
185
  return;
141
186
  }
142
187
  setBusy(true);
143
188
  try {
144
- await tis.putMethod(id, EMPTY_METHOD);
189
+ const method: TestMethod = newProcedure === 'sequence'
190
+ ? { ...EMPTY_METHOD, procedure: 'sequence' }
191
+ : { ...EMPTY_METHOD };
192
+ // Sequence FIRST. A method whose schema exists without its procedure
193
+ // refuses to start with "has no sequence on this machine"; the
194
+ // reverse leaves an unreferenced file that the next method of the
195
+ // same name would silently inherit. Of the two half-states, the
196
+ // orphan file is the one that cannot mislead a run.
197
+ if (newProcedure === 'sequence') {
198
+ await seqIo.write(id, emptySequence(id));
199
+ }
200
+ await tis.putMethod(id, method as any);
145
201
  setSelectedId(id);
146
202
  setNewDialogOpen(false);
147
203
  setNewId('');
@@ -163,6 +219,10 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
163
219
  }
164
220
  setBusy(true);
165
221
  try {
222
+ // The procedure comes with it. `seqIo.copy` rewrites the sequence's
223
+ // own `name` to the new id, so the copy does not claim to be its
224
+ // source — the method id owns the identity.
225
+ await seqIo.copy(selectedId, candidate);
166
226
  await tis.putMethod(candidate, JSON.parse(JSON.stringify(source)));
167
227
  setSelectedId(candidate);
168
228
  } catch (e: any) {
@@ -172,13 +232,62 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
172
232
  }
173
233
  };
174
234
 
175
- const onDelete = async () => {
235
+ /**
236
+ * Build and hand over an archive of every run recorded under `methodId`.
237
+ * Gates the delete confirmation; see DeleteMethodDialog.
238
+ */
239
+ const downloadMethodArchive = async (methodId: string): Promise<boolean> => {
240
+ const resp: any = await effectiveInvoker('tis.export_method_zip', {
241
+ project_id: projectId,
242
+ method_id: methodId,
243
+ });
244
+ if (!resp?.success) {
245
+ throw new Error(resp?.error_message ?? 'export_method_zip failed');
246
+ }
247
+ // Archives ride the server's /downloads/ endpoint rather than the
248
+ // WebSocket frame: a method with a season of history is not a payload.
249
+ const url = typeof resp.data?.download_url === 'string' ? resp.data.download_url : '';
250
+ if (!url) throw new Error('the server returned no download URL');
251
+ const a = document.createElement('a');
252
+ a.href = url;
253
+ a.download = typeof resp.data?.filename === 'string' && resp.data.filename
254
+ ? resp.data.filename
255
+ : `${methodId}_method_archive.zip`;
256
+ a.click();
257
+ return true;
258
+ };
259
+
260
+ const onDeleteConfirmed = async () => {
176
261
  if (!selectedId) return;
177
- if (!window.confirm(`Remove method "${selectedId}"? This is staged — Save persists it.`)) return;
178
262
  setBusy(true);
179
263
  try {
264
+ // Schema first here, unlike create: the schema is what makes the
265
+ // method selectable, so removing it first means an interrupted
266
+ // delete leaves an unreachable file rather than a method that is
267
+ // still offered and no longer runs.
180
268
  await tis.removeMethod(selectedId);
269
+ await seqIo.remove(selectedId);
181
270
  setSelectedId(null);
271
+ setDeleteDialogOpen(false);
272
+ } catch (e: any) {
273
+ setDraftError(String(e?.message ?? e));
274
+ } finally {
275
+ setBusy(false);
276
+ }
277
+ };
278
+
279
+ /** The selected method and its procedure, as one portable file. */
280
+ const onExport = async () => {
281
+ if (!selectedId || !tis.config) return;
282
+ const schema = tis.config.methods[selectedId] as TestMethod | undefined;
283
+ if (!schema) return;
284
+ setBusy(true);
285
+ try {
286
+ // The STAGED schema, deliberately: what the operator sees on screen
287
+ // is what they mean to hand over. Exporting the on-disk copy instead
288
+ // would silently ship a version they had already changed.
289
+ const sequence = schema.procedure === 'sequence' ? await seqIo.read(selectedId) : null;
290
+ downloadTransfer(buildTransfer(selectedId, schema, sequence));
182
291
  } catch (e: any) {
183
292
  setDraftError(String(e?.message ?? e));
184
293
  } finally {
@@ -186,6 +295,51 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
186
295
  }
187
296
  };
188
297
 
298
+ const onImportFile = async (file: File) => {
299
+ const result = parseTransfer(await file.text());
300
+ if ('error' in result) {
301
+ setImportError(`${file.name}: ${result.error}`);
302
+ setPendingImport(null);
303
+ return;
304
+ }
305
+ setImportError(null);
306
+ setPendingImport(result.file);
307
+ setImportId(result.file.method_id);
308
+ };
309
+
310
+ /**
311
+ * Land a parsed import under `importId`.
312
+ *
313
+ * The dialog always asks, even when the name is free: an operator importing
314
+ * a colleague's method usually wants it called something of their own, and
315
+ * a list that fills up with other people's names is the complaint that
316
+ * follows. When the name IS taken, the same dialog is where they choose
317
+ * between replacing and renaming — the two are one decision, so they belong
318
+ * on one screen.
319
+ */
320
+ const onImportConfirm = async () => {
321
+ if (!pendingImport) return;
322
+ const id = importId.trim();
323
+ if (!METHOD_ID_RE.test(id)) {
324
+ setImportError(`"${id}" is not a valid method id. ${METHOD_ID_RULE}`);
325
+ return;
326
+ }
327
+ setBusy(true);
328
+ try {
329
+ if (pendingImport.sequence) {
330
+ await seqIo.write(id, pendingImport.sequence);
331
+ }
332
+ await tis.putMethod(id, pendingImport.schema as any);
333
+ setSelectedId(id);
334
+ setPendingImport(null);
335
+ setImportError(null);
336
+ } catch (e: any) {
337
+ setImportError(String(e?.message ?? e));
338
+ } finally {
339
+ setBusy(false);
340
+ }
341
+ };
342
+
189
343
  // Save flows through the diff dialog: it fetches the current disk
190
344
  // state via tis.list_schemas, shows the operator what's about to land,
191
345
  // and only invokes save_config on confirm.
@@ -279,9 +433,47 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
279
433
  icon="pi pi-trash"
280
434
  className="p-button-danger"
281
435
  disabled={busy || !selectedId}
282
- onClick={onDelete}
436
+ onClick={() => setDeleteDialogOpen(true)}
283
437
  />
284
438
  </div>
439
+ {/* Import / Export move the WHOLE method — schema and
440
+ procedure in one file. Half a method looks like a method
441
+ and silently is not; see MethodTransfer. */}
442
+ <div className="tis-editor__sidebar-actions">
443
+ <Button
444
+ label="Import"
445
+ icon="pi pi-upload"
446
+ className="p-button-secondary"
447
+ disabled={busy}
448
+ onClick={() => fileInput.current?.click()}
449
+ />
450
+ <Button
451
+ label="Export"
452
+ icon="pi pi-download"
453
+ className="p-button-secondary"
454
+ disabled={busy || !selectedId}
455
+ onClick={onExport}
456
+ />
457
+ </div>
458
+ {importError && (
459
+ <div className="tis-editor__error">
460
+ <pre>{importError}</pre>
461
+ </div>
462
+ )}
463
+ {/* Hidden by design, not by CSS trickery: a styled Import
464
+ button is a far better target than the browser's native
465
+ file input on a touch panel. */}
466
+ <input
467
+ ref={fileInput}
468
+ type="file"
469
+ accept=".json,.method.json,application/json"
470
+ style={{ display: 'none' }}
471
+ onChange={(e) => {
472
+ const f = e.target.files?.[0];
473
+ e.target.value = '';
474
+ if (f) void onImportFile(f);
475
+ }}
476
+ />
285
477
  <DataTable
286
478
  value={rows}
287
479
  selection={rows.find(r => r.id === selectedId) ?? null}
@@ -337,12 +529,118 @@ export const TisConfigEditor: React.FC<TisConfigEditorProps> = ({ projectId, inv
337
529
  />
338
530
  </label>
339
531
  <small>Canonical key — appears in wire payloads, on-disk paths, and generated code.</small>
532
+ {/* Stated on screen, not behind a tooltip: the rule is strict
533
+ because this id becomes a path segment and a Rust identifier
534
+ stem, and neither failure is legible where it surfaces. */}
535
+ {newId.trim() !== '' && !METHOD_ID_RE.test(newId.trim()) && (
536
+ <Message severity="warn" text={METHOD_ID_RULE} style={{ width: '100%', marginTop: '0.5rem' }} />
537
+ )}
538
+
539
+ <label className="tis-editor__new-method-label" style={{ marginTop: '1rem' }}>
540
+ Procedure
541
+ <SelectButton
542
+ value={newProcedure}
543
+ options={[
544
+ { label: 'Sequence', value: 'sequence' },
545
+ { label: 'Control program', value: 'control' },
546
+ ]}
547
+ allowEmpty={false}
548
+ onChange={(e) => e.value && setNewProcedure(e.value)}
549
+ />
550
+ </label>
551
+ <small>
552
+ {newProcedure === 'sequence'
553
+ ? 'The steps are a file you edit on the Method screen. Creating this method also creates an empty sequence.'
554
+ : 'The steps are compiled into the control program. A new method of this kind needs a rebuild and redeploy before it will run.'}
555
+ </small>
556
+
340
557
  <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
341
558
  <Button label="Cancel" className="p-button-text" onClick={() => setNewDialogOpen(false)} />
342
- <Button label="Create" disabled={!newId.trim() || busy} onClick={onCreate} />
559
+ <Button
560
+ label="Create"
561
+ disabled={!METHOD_ID_RE.test(newId.trim()) || busy}
562
+ onClick={onCreate}
563
+ />
343
564
  </div>
344
565
  </Dialog>
345
566
 
567
+ {/* Import: always asks for the name. See onImportConfirm. */}
568
+ <Dialog
569
+ header="Import Test Method"
570
+ visible={pendingImport !== null}
571
+ onHide={() => { setPendingImport(null); setImportError(null); }}
572
+ style={{ width: '28rem' }}
573
+ >
574
+ {pendingImport && (() => {
575
+ const taken = !!tis.config?.methods[importId.trim()];
576
+ const idOk = METHOD_ID_RE.test(importId.trim());
577
+ return (
578
+ <>
579
+ <p style={{ marginTop: 0 }}>
580
+ <strong>{pendingImport.method_id}</strong>
581
+ {pendingImport.schema.label ? ` — ${pendingImport.schema.label}` : ''}
582
+ <br />
583
+ <small>
584
+ {pendingImport.sequence
585
+ ? 'Includes a sequence procedure.'
586
+ : 'Control-program method — no sequence in this file.'}
587
+ </small>
588
+ </p>
589
+
590
+ <label className="tis-editor__new-method-label">
591
+ Import as
592
+ <InputText
593
+ value={importId}
594
+ onChange={(e) => setImportId(e.target.value)}
595
+ autoFocus
596
+ />
597
+ </label>
598
+ {!idOk && importId.trim() !== '' && (
599
+ <Message severity="warn" text={METHOD_ID_RULE} style={{ width: '100%' }} />
600
+ )}
601
+ {taken && idOk && (
602
+ <Message
603
+ severity="warn"
604
+ text={`"${importId.trim()}" already exists. Importing will REPLACE it, `
605
+ + `including its sequence. Change the name above to keep both.`}
606
+ style={{ width: '100%' }}
607
+ />
608
+ )}
609
+ {importError && (
610
+ <Message severity="error" text={importError} style={{ width: '100%' }} />
611
+ )}
612
+
613
+ <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
614
+ <Button
615
+ label="Cancel"
616
+ className="p-button-text"
617
+ onClick={() => { setPendingImport(null); setImportError(null); }}
618
+ />
619
+ <Button
620
+ label={taken ? 'Replace' : 'Import'}
621
+ className={taken ? 'p-button-danger' : undefined}
622
+ disabled={!idOk || busy}
623
+ onClick={onImportConfirm}
624
+ />
625
+ </div>
626
+ </>
627
+ );
628
+ })()}
629
+ </Dialog>
630
+
631
+ <DeleteMethodDialog
632
+ visible={deleteDialogOpen}
633
+ methodId={selectedId}
634
+ hasSequence={
635
+ !!selectedId
636
+ && (tis.config?.methods[selectedId] as TestMethod | undefined)?.procedure === 'sequence'
637
+ }
638
+ onDownloadArchive={downloadMethodArchive}
639
+ onConfirm={onDeleteConfirmed}
640
+ onCancel={() => setDeleteDialogOpen(false)}
641
+ busy={busy}
642
+ />
643
+
346
644
  <SaveDiffDialog
347
645
  visible={saveDialogOpen}
348
646
  staged={(tis.config?.methods ?? {}) as Record<string, TestMethod>}
@@ -1,5 +1,6 @@
1
1
  import { InputText } from 'primereact/inputtext';
2
2
  import { InputTextarea } from 'primereact/inputtextarea';
3
+ import { SelectButton } from 'primereact/selectbutton';
3
4
  import { FormSection } from '../../forms/FormSection';
4
5
  import { FormRow } from '../../forms/FormRow';
5
6
  import type { TestMethod } from '../types';
@@ -13,7 +14,7 @@ export const IdentitySection: React.FC<IdentitySectionProps> = ({ method, onChan
13
14
  return (
14
15
  <FormSection
15
16
  title="Identity"
16
- description="Display label and operator-facing description for this method."
17
+ description="Display label, operator-facing description, and which engine runs the steps."
17
18
  >
18
19
  <FormRow label="Label" hint="Pretty name shown in the Test Method picker.">
19
20
  <InputText
@@ -31,6 +32,37 @@ export const IdentitySection: React.FC<IdentitySectionProps> = ({ method, onChan
31
32
  onChange={(e) => onChange({ ...method, description: e.target.value })}
32
33
  />
33
34
  </FormRow>
35
+ {/* Which engine owns the steps.
36
+ `Sequence` is the one that can be authored on the machine: the
37
+ procedure is a file the sequence workbench edits, so a new
38
+ method runs without regenerating and rebuilding the control
39
+ program. `Control program` means compiled Rust, which needs a
40
+ rebuild for every method — the default only because it is what
41
+ every method predating this field does.
42
+ Absent rather than "control" when control is chosen, so a
43
+ method that never had an opinion keeps a clean record. */}
44
+ <FormRow
45
+ label="Procedure"
46
+ hint="Sequence: the steps are a file you edit on the Method screen. Control program: compiled Rust, requires a rebuild to add or change a method."
47
+ >
48
+ <SelectButton
49
+ value={(method.procedure as string) ?? 'control'}
50
+ options={[
51
+ { label: 'Control program', value: 'control' },
52
+ { label: 'Sequence', value: 'sequence' },
53
+ ]}
54
+ allowEmpty={false}
55
+ onChange={(e) => {
56
+ const next: TestMethod = { ...method };
57
+ if (e.value === 'sequence') {
58
+ next.procedure = 'sequence';
59
+ } else {
60
+ delete next.procedure;
61
+ }
62
+ onChange(next);
63
+ }}
64
+ />
65
+ </FormRow>
34
66
  </FormSection>
35
67
  );
36
68
  };
@@ -188,6 +188,17 @@ export interface TestMethod {
188
188
  asset_refs?: AssetRef[];
189
189
  analysis?: AnalysisShape | null;
190
190
  configurations?: TestConfiguration[];
191
+ /**
192
+ * Which engine runs this method's steps.
193
+ *
194
+ * `"sequence"` means the procedure is data at
195
+ * `methods/<method_id>.seq.json`, authored in the sequence workbench and
196
+ * executed by autocore-seq — so a method can be created on the machine and
197
+ * run without regenerating and rebuilding the control program.
198
+ * `"control"` (the default) means compiled Rust, which is every method that
199
+ * predates this field.
200
+ */
201
+ procedure?: 'control' | 'sequence';
191
202
  [key: string]: unknown; // tolerate unknown server-side fields
192
203
  }
193
204
 
@@ -98,6 +98,10 @@ export const VariablePicker: React.FC<VariablePickerProps> = ({
98
98
  visible={visible}
99
99
  onHide={onCancel}
100
100
  footer={footer}
101
+ // Carries the token bridge. PrimeReact portals the dialog to
102
+ // <body>, so it is not inside the field's subtree and cannot
103
+ // inherit the tokens declared there — see varpick.css.
104
+ className="ac-varpick"
101
105
  style={{ width: '44rem', maxWidth: '95vw' }}
102
106
  >
103
107
  {hint && <div className="ac-varpick__hint">{hint}</div>}