@adcops/autocore-react 3.3.57 → 3.3.61

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.
@@ -0,0 +1,307 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <ProjectInfoDialog> — operator-facing setup for a project's
5
+ * `project_fields`. Used in two modes:
6
+ *
7
+ * - `create`: opened by the `+` button on <TestSetupForm>. The
8
+ * project doesn't exist yet; on submit we call `tis.create_project`
9
+ * with the user-entered fields baked into the request payload, so
10
+ * the directory and project.json materialise atomically. The form
11
+ * pre-fills source-bound fields from live GM values via the
12
+ * surrounding AutoCoreTagProvider so the operator isn't typing
13
+ * things the rig already knows.
14
+ *
15
+ * - `edit`: opened by the pencil button on <TestSetupForm>. The
16
+ * project already exists; we fetch project.json via
17
+ * `tis.read_project` and pre-fill from it, with source-bound
18
+ * fields preferring the live GM value over the persisted one (so
19
+ * "edit" reflects current reality). On submit we
20
+ * `tis.update_project` and `write()` source-bound fields back to
21
+ * GM in the same pass.
22
+ *
23
+ * The dialog never stages or starts a test — it only manages
24
+ * project-level metadata. The future per-user permission gate that
25
+ * locks "create/edit project" away from the operator role hangs off
26
+ * the visibility of the two buttons in <TestSetupForm>; this dialog
27
+ * is agnostic to who opened it.
28
+ */
29
+
30
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
31
+ import { Button } from 'primereact/button';
32
+ import { Dialog } from 'primereact/dialog';
33
+ import { Tooltip } from 'primereact/tooltip';
34
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
35
+ import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
36
+ import { MessageType } from '../../hub/CommandMessage';
37
+ import { ValueInput } from '../ValueInput';
38
+ import { TextInput } from '../TextInput';
39
+ import type { TestFieldDef } from './TestSetupForm';
40
+
41
+ export type ProjectInfoMode = 'create' | 'edit';
42
+
43
+ export interface ProjectInfoDialogProps {
44
+ visible: boolean;
45
+ onHide: () => void;
46
+ mode: ProjectInfoMode;
47
+ /** Project ID being created (`create` mode) or edited (`edit`). */
48
+ projectId: string;
49
+ /** Schema field defs for `project_fields` (from the selected method). */
50
+ projectFields: TestFieldDef[];
51
+ /**
52
+ * Called after a successful create/update with the values that
53
+ * landed on the server. Used by the parent form to refresh its
54
+ * known-projects list and to fold the new fields into subsequent
55
+ * `tis.stage_test` payloads without an extra round-trip.
56
+ */
57
+ onSubmitted: (projectId: string, projectFields: Record<string, any>) => void;
58
+ }
59
+
60
+ const labelOf = (f: TestFieldDef): string => {
61
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
62
+ return f.units ? `${base} [${f.units}]` : base;
63
+ };
64
+
65
+ const hasDescription = (f: TestFieldDef): boolean =>
66
+ typeof f.description === 'string' && f.description.length > 0;
67
+
68
+ export const ProjectInfoDialog: React.FC<ProjectInfoDialogProps> = ({
69
+ visible, onHide, mode, projectId, projectFields, onSubmitted,
70
+ }) => {
71
+ const { invoke, write } = useContext(EventEmitterContext);
72
+ const { rawValues, findTagByFqdn } = useContext(AutoCoreTagContext);
73
+
74
+ const [values, setValues] = useState<Record<string, any>>({});
75
+ const [submitting, setSubmitting] = useState(false);
76
+ const [loadError, setLoadError] = useState<string | null>(null);
77
+ const lastLoadedIdRef = useRef<string>('');
78
+
79
+ // Whenever the dialog opens (or the target project changes while
80
+ // open), reset state and re-load. We DON'T do this in a single
81
+ // useEffect on `[visible, projectId, mode]` because the load is
82
+ // async and we want to drop stale results.
83
+ useEffect(() => {
84
+ if (!visible) return;
85
+ const loadKey = `${mode}:${projectId}`;
86
+ if (lastLoadedIdRef.current === loadKey) return;
87
+ lastLoadedIdRef.current = loadKey;
88
+
89
+ let cancelled = false;
90
+ setLoadError(null);
91
+
92
+ // Build the initial value map. For source-bound fields, seed
93
+ // from live GM via the AutoCoreTagProvider's rawValues. For
94
+ // non-source fields the seed is empty in `create` mode and
95
+ // gets overwritten by the persisted value in `edit` mode.
96
+ const seed: Record<string, any> = {};
97
+ for (const f of projectFields) {
98
+ if (!f.source) continue;
99
+ const tag = findTagByFqdn(f.source);
100
+ if (!tag) continue;
101
+ const v = rawValues[tag.tagName];
102
+ if (v !== undefined && v !== null) seed[f.name] = v;
103
+ }
104
+
105
+ if (mode === 'create') {
106
+ if (!cancelled) setValues(seed);
107
+ } else {
108
+ (async () => {
109
+ try {
110
+ const resp: any = await invoke(
111
+ 'tis.read_project' as any, MessageType.Request,
112
+ { project_id: projectId } as any,
113
+ );
114
+ if (cancelled) return;
115
+ if (resp?.success) {
116
+ const persisted = (resp.data?.project_fields ?? {}) as Record<string, any>;
117
+ // Seed (source-bound from GM) + persisted +
118
+ // GM wins on conflicts for source fields,
119
+ // because GM is the live source of truth.
120
+ const merged: Record<string, any> = { ...persisted };
121
+ for (const f of projectFields) {
122
+ if (f.source && seed[f.name] !== undefined) {
123
+ merged[f.name] = seed[f.name];
124
+ }
125
+ }
126
+ setValues(merged);
127
+ } else {
128
+ setLoadError(resp?.error_message ?? 'Failed to read project');
129
+ }
130
+ } catch (e) {
131
+ if (!cancelled) setLoadError(String(e instanceof Error ? e.message : e));
132
+ }
133
+ })();
134
+ }
135
+ return () => { cancelled = true; };
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [visible, projectId, mode]);
138
+
139
+ // When the dialog closes, clear the loaded-marker so a subsequent
140
+ // open re-fetches fresh state.
141
+ useEffect(() => {
142
+ if (!visible) lastLoadedIdRef.current = '';
143
+ }, [visible]);
144
+
145
+ const handleFieldChange = (field: TestFieldDef, val: any) => {
146
+ setValues(prev => ({ ...prev, [field.name]: val }));
147
+ };
148
+
149
+ // All required project_fields must be non-empty before we let the
150
+ // operator submit. Source-bound fields are validated the same way
151
+ // — they should already have a value from GM, but the dialog is
152
+ // honest about it if they don't.
153
+ const isValid = useMemo(() => {
154
+ for (const f of projectFields) {
155
+ if (!f.required) continue;
156
+ const v = values[f.name];
157
+ if (v === undefined || v === null || v === '') return false;
158
+ }
159
+ return true;
160
+ }, [projectFields, values]);
161
+
162
+ const handleSubmit = async () => {
163
+ if (!isValid || submitting) return;
164
+ setSubmitting(true);
165
+ try {
166
+ // For source-bound fields, push the value into GM in the
167
+ // same pass. We do this whether `create` or `edit` because
168
+ // the persisted project.json copy is just a snapshot —
169
+ // GM is the live source of truth and we want the rig to
170
+ // see the operator's changes immediately.
171
+ for (const f of projectFields) {
172
+ if (!f.source) continue;
173
+ const v = values[f.name];
174
+ if (v === undefined || v === null) continue;
175
+ try { await write(f.source, v); }
176
+ catch (e) { console.warn(`[ProjectInfoDialog] write to ${f.source} failed:`, e); }
177
+ }
178
+
179
+ // The persisted blob carries every project_field we
180
+ // present, including source-bound ones; this lets the
181
+ // form fold them into stage_test even if the live tag
182
+ // hasn't broadcast a value yet at staging time.
183
+ const projectFieldsPayload: Record<string, any> = {};
184
+ for (const f of projectFields) {
185
+ if (values[f.name] !== undefined) projectFieldsPayload[f.name] = values[f.name];
186
+ }
187
+
188
+ const topic = mode === 'create' ? 'tis.create_project' : 'tis.update_project';
189
+ const resp: any = await invoke(topic as any, MessageType.Request, {
190
+ project_id: projectId,
191
+ project_fields: projectFieldsPayload,
192
+ } as any);
193
+ if (resp?.success) {
194
+ onSubmitted(projectId, projectFieldsPayload);
195
+ onHide();
196
+ } else {
197
+ alert(`Failed: ${resp?.error_message ?? 'unknown error'}`);
198
+ }
199
+ } catch (e) {
200
+ alert(`Failed: ${e instanceof Error ? e.message : String(e)}`);
201
+ } finally {
202
+ setSubmitting(false);
203
+ }
204
+ };
205
+
206
+ const isFieldValid = (f: TestFieldDef) => {
207
+ if (!f.required) return true;
208
+ const v = values[f.name];
209
+ return v !== undefined && v !== null && v !== '';
210
+ };
211
+
212
+ const renderField = (field: TestFieldDef) => {
213
+ const valid = isFieldValid(field);
214
+ const isNum = field.type !== 'string' && field.type !== 'bool';
215
+ const tooltipId = `acProjInfo_${field.name}`;
216
+ return (
217
+ <React.Fragment key={field.name}>
218
+ <span className="ac-form-label">{labelOf(field)}</span>
219
+ {isNum ? (
220
+ <ValueInput
221
+ label={undefined}
222
+ value={values[field.name] != null ? Number(values[field.name]) : null}
223
+ onValueChanged={(val) => handleFieldChange(field, val)}
224
+ className={!valid ? 'p-invalid' : ''}
225
+ />
226
+ ) : (
227
+ <TextInput
228
+ label={undefined}
229
+ value={values[field.name] != null ? String(values[field.name]) : ''}
230
+ onValueChanged={(val) => handleFieldChange(field, val)}
231
+ className={!valid ? 'p-invalid' : ''}
232
+ />
233
+ )}
234
+ {hasDescription(field) ? (
235
+ <>
236
+ <Tooltip target={`#${tooltipId}`} position="left" />
237
+ <span
238
+ id={tooltipId}
239
+ data-pr-tooltip={field.description}
240
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
241
+ >
242
+ <i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
243
+ </span>
244
+ </>
245
+ ) : (
246
+ <span aria-hidden="true" />
247
+ )}
248
+ <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
249
+ <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
250
+ </span>
251
+ </React.Fragment>
252
+ );
253
+ };
254
+
255
+ const footer = (
256
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
257
+ <Button label="Cancel" icon="pi pi-times" onClick={onHide} disabled={submitting} text />
258
+ <Button
259
+ label={mode === 'create' ? 'Create Project' : 'Save'}
260
+ icon={submitting ? 'pi pi-spin pi-spinner' : (mode === 'create' ? 'pi pi-plus' : 'pi pi-check')}
261
+ onClick={handleSubmit}
262
+ disabled={!isValid || submitting}
263
+ />
264
+ </div>
265
+ );
266
+
267
+ const header = mode === 'create'
268
+ ? `Create project: ${projectId || '(no ID)'}`
269
+ : `Edit project information: ${projectId}`;
270
+
271
+ return (
272
+ <Dialog
273
+ header={header}
274
+ visible={visible}
275
+ onHide={onHide}
276
+ footer={footer}
277
+ modal
278
+ style={{ width: 'min(640px, 90vw)' }}
279
+ // PrimeReact's `closable` X — same as Cancel.
280
+ closable={!submitting}
281
+ >
282
+ {loadError && (
283
+ <div style={{ color: 'var(--red-500)', marginBottom: '1rem' }}>
284
+ {loadError}
285
+ </div>
286
+ )}
287
+ {projectFields.length === 0 ? (
288
+ <p style={{ color: 'var(--text-secondary-color)' }}>
289
+ {mode === 'create'
290
+ ? `Click "Create Project" to create the empty project "${projectId}". `
291
+ + `This method declares no project_fields, so there's nothing to fill in.`
292
+ : `This method declares no project_fields, so there's nothing to edit.`}
293
+ </p>
294
+ ) : (
295
+ <div
296
+ className="ac-form-grid"
297
+ style={{
298
+ padding: '0.25rem 0',
299
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
300
+ }}
301
+ >
302
+ {projectFields.map(renderField)}
303
+ </div>
304
+ )}
305
+ </Dialog>
306
+ );
307
+ };
@@ -0,0 +1,190 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <ProjectSelector> — standalone Project ID picker, designed to live
5
+ * on a "Project" tab alongside <ResultHistoryTable>. Was previously
6
+ * the first row of <TestSetupForm>; lifted out so a user can browse a
7
+ * project's history without being forced through the full test-setup
8
+ * UI.
9
+ *
10
+ * The component is intentionally narrow:
11
+ *
12
+ * - One AutoComplete bound to `useTisSelection().projectId`.
13
+ * - A `+` button that opens the Create-Project dialog.
14
+ * - A `✏️` button that opens the Edit-Project-Information dialog.
15
+ *
16
+ * State (the existing projects list, the just-created set, the
17
+ * project_fields cache) all lives in <TisProvider>, so the form on
18
+ * the Test tab and this picker on the Project tab agree on what's
19
+ * known.
20
+ */
21
+
22
+ import React, { useContext, useState, useMemo } from 'react';
23
+ import { AutoComplete } from 'primereact/autocomplete';
24
+ import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
25
+ import { Button } from 'primereact/button';
26
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
27
+ import { useTis } from './TisProvider';
28
+ import { ProjectInfoDialog } from './ProjectInfoDialog';
29
+
30
+ // Project IDs follow the same character class as the server's
31
+ // `tis.create_project` validator. Keep these in sync — see
32
+ // `src/tis_servelet.rs::create_project`.
33
+ const PROJECT_ID_RE = /^[A-Za-z0-9_-]+$/;
34
+ const isValidProjectIdFormat = (id: string) => PROJECT_ID_RE.test(id);
35
+
36
+ export interface ProjectSelectorProps {
37
+ /**
38
+ * Optional override of the method whose `project_fields` are shown
39
+ * in the create / edit dialog. By default the dialog uses the
40
+ * provider's selected method (which is what you want — the
41
+ * project's metadata schema is per-method, and the form on the
42
+ * Test tab is going to use that same method anyway). Passing this
43
+ * is only useful if you have a "view-only" Project tab in a
44
+ * read-only HMI and want to lock the dialog to a specific method.
45
+ */
46
+ methodIdOverride?: string;
47
+ }
48
+
49
+ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ methodIdOverride }) => {
50
+ const tis = useTis();
51
+ const { invoke: _invoke } = useContext(EventEmitterContext);
52
+ void _invoke; // EventEmitterContext is consumed via ProjectInfoDialog; no direct call here.
53
+
54
+ const projectId = tis.selection.projectId;
55
+ const dialogMethodId =
56
+ methodIdOverride
57
+ ?? tis.selection.methodId
58
+ ?? tis.defaultMethodId
59
+ ?? Object.keys(tis.schemas)[0]
60
+ ?? '';
61
+ const dialogSchema = dialogMethodId ? tis.schemas[dialogMethodId] : undefined;
62
+ const projectFieldsSchema = dialogSchema?.project_fields ?? [];
63
+
64
+ const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
65
+ const [newOpen, setNewOpen] = useState(false);
66
+ const [editOpen, setEditOpen] = useState(false);
67
+
68
+ const projectExists = projectId.trim() !== '' && tis.projectKnown(projectId.trim());
69
+ const projectIdFormatValid = isValidProjectIdFormat(projectId.trim());
70
+ const canCreateProject =
71
+ projectId.trim() !== ''
72
+ && projectIdFormatValid
73
+ && !tis.projectKnown(projectId.trim());
74
+
75
+ const search = (event: AutoCompleteCompleteEvent) => {
76
+ const q = event.query.toLowerCase();
77
+ setFilteredProjects(tis.existingProjects.filter(p => p.toLowerCase().includes(q)));
78
+ };
79
+
80
+ const handleChange = (value: string | null | undefined) => {
81
+ const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
82
+ tis.setSelection({ projectId: sanitized });
83
+ };
84
+
85
+ // -----------------------------------------------------------------
86
+ // Plumbing for the create + edit dialogs. We treat the "create"
87
+ // result as authoritative for project_fields; the dialog sends
88
+ // them straight to `tis.create_project` so the persisted file
89
+ // already has the values. Stash them in the provider's cache so
90
+ // the form on the Test tab folds them into stage_test without an
91
+ // extra read_project round trip.
92
+ // -----------------------------------------------------------------
93
+ const handleSubmitted = (pid: string, fields: Record<string, any>) => {
94
+ tis.markProjectJustCreated(pid);
95
+ tis.setProjectFields(pid, fields);
96
+ // Refresh the dropdown so the new ID appears in future
97
+ // suggestions, and surface the new project as the current
98
+ // selection — operator's intent on `+` is "set up this
99
+ // project and start using it."
100
+ void tis.refreshProjects();
101
+ if (tis.selection.projectId !== pid) tis.setSelection({ projectId: pid });
102
+ };
103
+
104
+ const headerStatus = useMemo(() => {
105
+ if (projectExists) return { color: 'var(--green-500)', icon: 'pi-check-circle' };
106
+ if (projectId.trim() === '') return { color: 'var(--text-secondary-color)', icon: 'pi-info-circle' };
107
+ return { color: 'var(--red-500)', icon: 'pi-exclamation-circle' };
108
+ }, [projectExists, projectId]);
109
+
110
+ const gridStyle: React.CSSProperties = {
111
+ padding: '1.25rem',
112
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
113
+ };
114
+
115
+ return (
116
+ <div className="ac-form-grid" style={gridStyle}>
117
+ <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
118
+ Project
119
+ <span style={{ color: headerStatus.color }}>
120
+ <i className={`pi ${headerStatus.icon}`} />
121
+ </span>
122
+ </h3>
123
+
124
+ <span className="ac-form-label">Project ID</span>
125
+ <div className="p-inputgroup" style={{ flex: 1 }}>
126
+ <AutoComplete
127
+ value={projectId}
128
+ suggestions={filteredProjects}
129
+ completeMethod={search}
130
+ onChange={(e) => handleChange(e.value)}
131
+ dropdown
132
+ placeholder="Select an existing Project ID, or type a new one and click +"
133
+ className={projectId.trim() && !projectExists ? 'p-invalid' : ''}
134
+ style={{ flex: 1 }}
135
+ />
136
+ <Button
137
+ icon="pi pi-plus"
138
+ type="button"
139
+ onClick={() => setNewOpen(true)}
140
+ disabled={!canCreateProject}
141
+ tooltip={
142
+ !projectId.trim() ? 'Type a project ID first' :
143
+ !projectIdFormatValid ? 'Letters, digits, _ and - only' :
144
+ tis.projectKnown(projectId.trim()) ? 'Project already exists' :
145
+ `Create project "${projectId.trim()}"`
146
+ }
147
+ tooltipOptions={{ position: 'top' }}
148
+ />
149
+ <Button
150
+ icon="pi pi-pencil"
151
+ type="button"
152
+ onClick={() => setEditOpen(true)}
153
+ disabled={!projectExists}
154
+ tooltip={projectExists
155
+ ? `Edit information for "${projectId.trim()}"`
156
+ : 'Select an existing project to edit'}
157
+ tooltipOptions={{ position: 'top' }}
158
+ />
159
+ </div>
160
+ <span aria-hidden="true" />
161
+ <span style={{
162
+ color: projectExists ? 'var(--green-500)' :
163
+ projectId.trim() === '' ? 'var(--text-secondary-color)' : 'var(--red-500)',
164
+ display: 'flex', alignItems: 'center',
165
+ }}>
166
+ <i className={projectExists ? 'pi pi-check' : projectId.trim() === '' ? 'pi pi-minus' : 'pi pi-times'} />
167
+ </span>
168
+
169
+ {/* Both dialogs are mounted unconditionally and gated by
170
+ their own `visible` prop. Cheap, and lets PrimeReact's
171
+ portal layering manage its own lifecycle. */}
172
+ <ProjectInfoDialog
173
+ visible={newOpen}
174
+ onHide={() => setNewOpen(false)}
175
+ mode="create"
176
+ projectId={projectId.trim()}
177
+ projectFields={projectFieldsSchema}
178
+ onSubmitted={handleSubmitted}
179
+ />
180
+ <ProjectInfoDialog
181
+ visible={editOpen}
182
+ onHide={() => setEditOpen(false)}
183
+ mode="edit"
184
+ projectId={projectId.trim()}
185
+ projectFields={projectFieldsSchema}
186
+ onSubmitted={handleSubmitted}
187
+ />
188
+ </div>
189
+ );
190
+ };
@@ -65,6 +65,10 @@ export interface TestMethod {
65
65
  results_fields: TestFieldDef[];
66
66
  raw_data?: RawDataShape | null;
67
67
  views?: { [name: string]: ChartView };
68
+ /** Optional pretty label for the Test Method picker. */
69
+ label?: string;
70
+ /** Optional long-form description for the picker. */
71
+ description?: string;
68
72
  }
69
73
 
70
74
  export interface TestDataViewProps {
@@ -0,0 +1,147 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <TestMethodDialog> — picker UI for swapping the active test method.
5
+ *
6
+ * The main form displays a single "Test Method: <label>" row with an
7
+ * edit button. Clicking edit opens this dialog. The dialog shows a
8
+ * dropdown of every method declared in the current project's
9
+ * `test_methods` block, plus the long-form description for whichever
10
+ * method the operator has the dropdown highlighted on. OK commits the
11
+ * choice via the supplied callback; Cancel discards.
12
+ *
13
+ * This pattern scales past three or four methods where a SelectButton
14
+ * starts wrapping or eating horizontal space, and gives every method
15
+ * room to ship a description that disambiguates similar names.
16
+ */
17
+
18
+ import React, { useEffect, useMemo, useState } from 'react';
19
+ import { Button } from 'primereact/button';
20
+ import { Dialog } from 'primereact/dialog';
21
+ import { Dropdown } from 'primereact/dropdown';
22
+ import type { TestMethod } from './TestSetupForm';
23
+ import { useTisSchemas } from './TisProvider';
24
+
25
+ export interface TestMethodDialogProps {
26
+ visible: boolean;
27
+ onHide: () => void;
28
+ /** Method ID currently selected on the form. The dropdown opens
29
+ * pointing at this value so the dialog reflects current state. */
30
+ currentMethodId: string;
31
+ /**
32
+ * Called with the chosen method_id when the operator clicks OK.
33
+ * Cancel does not fire this callback. The parent is responsible
34
+ * for actually applying the new selection (e.g., updating the
35
+ * provider's selection or local state).
36
+ */
37
+ onSelected: (methodId: string) => void;
38
+ }
39
+
40
+ /** Display name for one method: prefer the schema's `label`, fall
41
+ * back to the canonical `method_id` key. */
42
+ const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
43
+ (schema?.label && schema.label.length > 0) ? schema.label : methodId;
44
+
45
+ export const TestMethodDialog: React.FC<TestMethodDialogProps> = ({
46
+ visible, onHide, currentMethodId, onSelected,
47
+ }) => {
48
+ const schemas = useTisSchemas();
49
+
50
+ // Local "draft" selection — the dropdown writes to this; OK
51
+ // applies it. We deliberately don't update the provider's
52
+ // selection on every dropdown change, so a Cancel really cancels.
53
+ const [draftMethodId, setDraftMethodId] = useState<string>(currentMethodId);
54
+
55
+ // Re-sync the draft whenever the dialog opens so a stale value
56
+ // from a previous open doesn't ghost the current selection.
57
+ useEffect(() => {
58
+ if (visible) setDraftMethodId(currentMethodId);
59
+ }, [visible, currentMethodId]);
60
+
61
+ const options = useMemo(() => {
62
+ return Object.keys(schemas).map(methodId => ({
63
+ label: methodLabelOf(methodId, schemas[methodId]),
64
+ value: methodId,
65
+ }));
66
+ }, [schemas]);
67
+
68
+ const draftSchema = schemas[draftMethodId];
69
+ const draftDescription =
70
+ (draftSchema?.description && draftSchema.description.length > 0)
71
+ ? draftSchema.description
72
+ : null;
73
+
74
+ const handleOk = () => {
75
+ if (draftMethodId && draftMethodId !== currentMethodId) {
76
+ onSelected(draftMethodId);
77
+ }
78
+ onHide();
79
+ };
80
+
81
+ const footer = (
82
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
83
+ <Button label="Cancel" icon="pi pi-times" onClick={onHide} text />
84
+ <Button
85
+ label="OK"
86
+ icon="pi pi-check"
87
+ onClick={handleOk}
88
+ disabled={!draftMethodId}
89
+ />
90
+ </div>
91
+ );
92
+
93
+ return (
94
+ <Dialog
95
+ header="Select Test Method"
96
+ visible={visible}
97
+ onHide={onHide}
98
+ footer={footer}
99
+ modal
100
+ style={{ width: 'min(560px, 90vw)' }}
101
+ >
102
+ {options.length === 0 ? (
103
+ <p style={{ color: 'var(--text-secondary-color)' }}>
104
+ No test methods are declared in this project's <code>test_methods</code> block.
105
+ </p>
106
+ ) : (
107
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
108
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
109
+ <label htmlFor="acTestMethodDropdown" style={{ flexShrink: 0 }}>
110
+ Test Method:
111
+ </label>
112
+ <Dropdown
113
+ inputId="acTestMethodDropdown"
114
+ value={draftMethodId}
115
+ options={options}
116
+ onChange={(e) => setDraftMethodId(e.value)}
117
+ placeholder="Select a method"
118
+ style={{ flex: 1 }}
119
+ />
120
+ </div>
121
+ {/*
122
+ * Show the description region whenever we have a
123
+ * draft selection — keeps layout stable even when
124
+ * the operator picks a method without one (we just
125
+ * render a muted placeholder rather than collapsing
126
+ * the dialog by ~3rem).
127
+ */}
128
+ <div
129
+ style={{
130
+ padding: '0.75rem 1rem',
131
+ background: 'var(--surface-100)',
132
+ borderRadius: '6px',
133
+ minHeight: '4.5rem',
134
+ color: draftDescription
135
+ ? 'var(--text-color)'
136
+ : 'var(--text-secondary-color)',
137
+ fontStyle: draftDescription ? 'normal' : 'italic',
138
+ whiteSpace: 'pre-wrap',
139
+ }}
140
+ >
141
+ {draftDescription ?? 'No description provided for this test method.'}
142
+ </div>
143
+ </div>
144
+ )}
145
+ </Dialog>
146
+ );
147
+ };