@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.
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ProjectInfoDialog.d.ts +21 -0
- package/dist/components/tis/ProjectInfoDialog.d.ts.map +1 -0
- package/dist/components/tis/ProjectInfoDialog.js +1 -0
- package/dist/components/tis/ProjectSelector.d.ts +15 -0
- package/dist/components/tis/ProjectSelector.d.ts.map +1 -0
- package/dist/components/tis/ProjectSelector.js +1 -0
- package/dist/components/tis/TestDataView.d.ts +4 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestMethodDialog.d.ts +17 -0
- package/dist/components/tis/TestMethodDialog.d.ts.map +1 -0
- package/dist/components/tis/TestMethodDialog.js +1 -0
- package/dist/components/tis/TestSetupForm.d.ts +19 -10
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +28 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +9 -0
- package/src/components/tis/ProjectInfoDialog.tsx +307 -0
- package/src/components/tis/ProjectSelector.tsx +190 -0
- package/src/components/tis/TestDataView.tsx +4 -0
- package/src/components/tis/TestMethodDialog.tsx +147 -0
- package/src/components/tis/TestSetupForm.tsx +167 -116
- package/src/components/tis/TisProvider.tsx +148 -1
|
@@ -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
|
+
};
|