@adcops/autocore-react 3.3.54 → 3.3.59
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 +4 -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/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 +13 -8
- 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.map +1 -1
- package/dist/hub/HubWebSocket.d.ts +13 -0
- package/dist/hub/HubWebSocket.d.ts.map +1 -1
- package/dist/hub/HubWebSocket.js +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +6 -0
- package/src/components/tis/ProjectInfoDialog.tsx +307 -0
- package/src/components/tis/TestDataView.tsx +4 -0
- package/src/components/tis/TestMethodDialog.tsx +147 -0
- package/src/components/tis/TestSetupForm.tsx +323 -85
- package/src/components/tis/TisProvider.tsx +3 -2
- package/src/hub/HubWebSocket.ts +66 -3
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
import React, { useState, useEffect, useContext, useMemo } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
|
|
2
2
|
import { AutoComplete } from 'primereact/autocomplete';
|
|
3
3
|
import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
|
|
4
|
-
import {
|
|
4
|
+
import { Button } from 'primereact/button';
|
|
5
|
+
import { InputText } from 'primereact/inputtext';
|
|
6
|
+
import { Tooltip } from 'primereact/tooltip';
|
|
5
7
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
6
8
|
import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
|
|
7
9
|
import { MessageType } from '../../hub/CommandMessage';
|
|
8
10
|
import { ValueInput } from '../ValueInput';
|
|
9
11
|
import { TextInput } from '../TextInput';
|
|
10
12
|
import { useTis } from './TisProvider';
|
|
13
|
+
import { ProjectInfoDialog } from './ProjectInfoDialog';
|
|
14
|
+
import { TestMethodDialog } from './TestMethodDialog';
|
|
11
15
|
|
|
12
16
|
export interface TestFieldDef {
|
|
17
|
+
/** Canonical key — wire format, generated code, on-disk JSON. */
|
|
13
18
|
name: string;
|
|
14
19
|
type: string;
|
|
15
20
|
units?: string;
|
|
16
21
|
required?: boolean;
|
|
17
22
|
source?: string;
|
|
23
|
+
/** Pretty label rendered by the form. Falls back to `name`. Units
|
|
24
|
+
* are appended automatically; don't pre-format `[mm]` into label. */
|
|
25
|
+
label?: string;
|
|
26
|
+
/** Long-form guidance surfaced as a hover tooltip on an info icon. */
|
|
27
|
+
description?: string;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export interface TestMethod {
|
|
@@ -22,18 +32,17 @@ export interface TestMethod {
|
|
|
22
32
|
config_fields: TestFieldDef[];
|
|
23
33
|
cycle_fields: TestFieldDef[];
|
|
24
34
|
results_fields: TestFieldDef[];
|
|
35
|
+
/** Optional pretty label for the Test Method picker. Falls back
|
|
36
|
+
* to the canonical method_id key. */
|
|
37
|
+
label?: string;
|
|
38
|
+
/** Optional long-form description shown in the picker dialog
|
|
39
|
+
* when this method is highlighted. */
|
|
40
|
+
description?: string;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
/**
|
|
28
44
|
* Props are all optional overrides — by default the form drives itself
|
|
29
|
-
* from the surrounding `<TisProvider>`.
|
|
30
|
-
* particular axis.
|
|
31
|
-
*
|
|
32
|
-
* - `schema`: bypass `useTisSchemas()` for the selected method.
|
|
33
|
-
* - `defaultProjectId` / `defaultMethodId`: seed the form's local
|
|
34
|
-
* state when the corresponding TIS-context fields are blank.
|
|
35
|
-
* - `onProjectChange` / `onMethodChange` / `onValidationChange`:
|
|
36
|
-
* receive callbacks in addition to the provider's selection state.
|
|
45
|
+
* from the surrounding `<TisProvider>`.
|
|
37
46
|
*/
|
|
38
47
|
export interface TestSetupFormProps {
|
|
39
48
|
schema?: TestMethod;
|
|
@@ -44,6 +53,32 @@ export interface TestSetupFormProps {
|
|
|
44
53
|
onValidationChange?: (isValid: boolean, config: any) => void;
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
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
|
+
/** Display name for one method: prefer schema's `label`, fall back
|
|
69
|
+
* to the canonical method_id. Mirrors the helper in TestMethodDialog
|
|
70
|
+
* so the row label stays in sync with what the dialog shows. */
|
|
71
|
+
const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
|
|
72
|
+
(schema?.label && schema.label.length > 0) ? schema.label : methodId;
|
|
73
|
+
|
|
74
|
+
// Project IDs follow the same character class as the server's
|
|
75
|
+
// `tis.create_project` validator. Keep these in sync — see
|
|
76
|
+
// `src/tis_servelet.rs::create_project`.
|
|
77
|
+
const PROJECT_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
78
|
+
const isValidProjectIdFormat = (id: string) => PROJECT_ID_RE.test(id);
|
|
79
|
+
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
47
82
|
export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
48
83
|
schema: schemaOverride,
|
|
49
84
|
defaultProjectId,
|
|
@@ -58,9 +93,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
58
93
|
|
|
59
94
|
const methodIds = useMemo(() => Object.keys(tis.schemas), [tis.schemas]);
|
|
60
95
|
|
|
61
|
-
// Seed the form's local project/method state from the provider's
|
|
62
|
-
// selection. Direct edits update the provider via setSelection so
|
|
63
|
-
// the History tab and Data tab follow along.
|
|
64
96
|
const [projectId, setProjectIdLocal] = useState<string>(
|
|
65
97
|
tis.selection.projectId || defaultProjectId || ''
|
|
66
98
|
);
|
|
@@ -70,13 +102,8 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
70
102
|
const [sampleId, setSampleIdLocal] = useState<string>('');
|
|
71
103
|
const [config, setConfig] = useState<any>({});
|
|
72
104
|
|
|
73
|
-
// Resolve the schema for the active method. The override beats the
|
|
74
|
-
// registry; if neither is available, render the empty state.
|
|
75
105
|
const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
|
|
76
106
|
|
|
77
|
-
// Push local edits back into the provider's selection so other
|
|
78
|
-
// components react. Only push when the value actually differs to
|
|
79
|
-
// avoid feedback loops.
|
|
80
107
|
useEffect(() => {
|
|
81
108
|
if (tis.selection.projectId !== projectId) {
|
|
82
109
|
tis.setSelection({ projectId });
|
|
@@ -100,10 +127,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
100
127
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
128
|
}, [sampleId]);
|
|
102
129
|
|
|
103
|
-
// Track the live broadcast scalars — when the form is staged for
|
|
104
|
-
// a different sample externally (e.g., the operator types in
|
|
105
|
-
// another tab), reflect that here. Only react on a true change
|
|
106
|
-
// to avoid stomping local edits.
|
|
107
130
|
useEffect(() => {
|
|
108
131
|
if (tis.state.stagedSampleId && tis.state.stagedSampleId !== sampleId) {
|
|
109
132
|
setSampleIdLocal(tis.state.stagedSampleId);
|
|
@@ -113,16 +136,90 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
113
136
|
|
|
114
137
|
const [existingProjects, setExistingProjects] = useState<string[]>([]);
|
|
115
138
|
const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
|
|
139
|
+
const justCreatedRef = useRef<Set<string>>(new Set());
|
|
140
|
+
const [justCreatedTick, setJustCreatedTick] = useState(0);
|
|
116
141
|
const [isValid, setIsValid] = useState(false);
|
|
117
142
|
|
|
118
|
-
//
|
|
143
|
+
// Cache of `project_fields` for the currently-selected project,
|
|
144
|
+
// fetched once on project selection. Folded into every
|
|
145
|
+
// `tis.stage_test` payload so the recorded test.json carries the
|
|
146
|
+
// project-level metadata even though the operator no longer sees
|
|
147
|
+
// those fields in the main form. Keyed by projectId so switching
|
|
148
|
+
// projects mid-session refetches cleanly.
|
|
149
|
+
const [projectFieldsCache, setProjectFieldsCache] = useState<Record<string, any>>({});
|
|
150
|
+
const projectFieldsForCurrent = projectFieldsCache[projectId] ?? {};
|
|
151
|
+
|
|
152
|
+
// Dialog state for create + edit + method-picker.
|
|
153
|
+
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
|
154
|
+
const [editProjectOpen, setEditProjectOpen] = useState(false);
|
|
155
|
+
const [methodPickerOpen, setMethodPickerOpen] = useState(false);
|
|
156
|
+
|
|
157
|
+
const fetchProjects = async () => {
|
|
158
|
+
try {
|
|
159
|
+
const resp: any = await invoke('tis.list_projects' as any, MessageType.Request as any, {} as any);
|
|
160
|
+
if (resp.success && resp.data && resp.data.projects) {
|
|
161
|
+
setExistingProjects(resp.data.projects);
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error('Failed to list projects', err);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
fetchProjects();
|
|
170
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
171
|
+
}, [invoke]);
|
|
172
|
+
|
|
173
|
+
const knownProjects = useMemo(() => {
|
|
174
|
+
const s = new Set<string>(existingProjects);
|
|
175
|
+
for (const id of justCreatedRef.current) s.add(id);
|
|
176
|
+
return s;
|
|
177
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
178
|
+
}, [existingProjects, justCreatedTick]);
|
|
179
|
+
|
|
180
|
+
const projectExists = projectId.trim() !== '' && knownProjects.has(projectId.trim());
|
|
181
|
+
const projectIdFormatValid = isValidProjectIdFormat(projectId.trim());
|
|
182
|
+
const canCreateProject =
|
|
183
|
+
projectId.trim() !== ''
|
|
184
|
+
&& projectIdFormatValid
|
|
185
|
+
&& !knownProjects.has(projectId.trim());
|
|
186
|
+
|
|
187
|
+
// Whenever the user selects a known project (or the project
|
|
188
|
+
// gets created in-session), pull its persisted project_fields
|
|
189
|
+
// so we can fold them into stage_test. We don't refetch on
|
|
190
|
+
// every keystroke — only when projectId actually lands on an
|
|
191
|
+
// existing project we don't yet have cached.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const pid = projectId.trim();
|
|
194
|
+
if (!pid || !projectExists) return;
|
|
195
|
+
if (projectFieldsCache[pid] !== undefined) return; // already cached
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const resp: any = await invoke(
|
|
200
|
+
'tis.read_project' as any, MessageType.Request,
|
|
201
|
+
{ project_id: pid } as any,
|
|
202
|
+
);
|
|
203
|
+
if (cancelled) return;
|
|
204
|
+
if (resp?.success) {
|
|
205
|
+
const pf = (resp.data?.project_fields ?? {}) as Record<string, any>;
|
|
206
|
+
setProjectFieldsCache(prev => ({ ...prev, [pid]: pf }));
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.warn('[TestSetupForm] read_project failed:', e);
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
return () => { cancelled = true; };
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
}, [projectId, projectExists]);
|
|
215
|
+
|
|
216
|
+
// Seed and live-update config_fields that declare a `source`.
|
|
119
217
|
useEffect(() => {
|
|
120
218
|
if (!schema) return;
|
|
121
|
-
const allFields = [...schema.project_fields, ...schema.config_fields];
|
|
122
219
|
setConfig((prev: any) => {
|
|
123
220
|
let next = prev;
|
|
124
|
-
for (const field of
|
|
125
|
-
if (field.name === 'sample_id') continue;
|
|
221
|
+
for (const field of schema.config_fields) {
|
|
222
|
+
if (field.name === 'sample_id') continue;
|
|
126
223
|
if (!field.source) continue;
|
|
127
224
|
const tag = findTagByFqdn(field.source);
|
|
128
225
|
if (!tag) continue;
|
|
@@ -136,61 +233,64 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
136
233
|
});
|
|
137
234
|
}, [schema, rawValues, findTagByFqdn]);
|
|
138
235
|
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
const fetchProjects = async () => {
|
|
141
|
-
try {
|
|
142
|
-
const resp: any = await invoke('tis.list_projects' as any, MessageType.Request as any, {} as any);
|
|
143
|
-
if (resp.success && resp.data && resp.data.projects) {
|
|
144
|
-
setExistingProjects(resp.data.projects);
|
|
145
|
-
}
|
|
146
|
-
} catch (err) {
|
|
147
|
-
console.error('Failed to list projects', err);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
fetchProjects();
|
|
151
|
-
}, [invoke]);
|
|
152
|
-
|
|
153
236
|
const searchProjects = (event: AutoCompleteCompleteEvent) => {
|
|
154
237
|
const query = event.query.toLowerCase();
|
|
155
238
|
setFilteredProjects(existingProjects.filter(p => p.toLowerCase().includes(query)));
|
|
156
239
|
};
|
|
157
240
|
|
|
158
|
-
// Validation drives both the local UI and the auto-stage.
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
241
|
+
// Validation drives both the local UI and the auto-stage. Project
|
|
242
|
+
// ID must be a known project — typing an unknown name is invalid
|
|
243
|
+
// until the operator goes through the New Project dialog. Each
|
|
244
|
+
// required *config_field* must also be filled in; project_fields
|
|
245
|
+
// are validated by the dialog at create/edit time, not here.
|
|
162
246
|
useEffect(() => {
|
|
163
247
|
if (!schema) { setIsValid(false); return; }
|
|
164
248
|
let valid = true;
|
|
165
|
-
if (!
|
|
249
|
+
if (!projectExists) valid = false;
|
|
166
250
|
if (!methodId.trim()) valid = false;
|
|
167
251
|
if (!sampleId.trim()) valid = false;
|
|
168
252
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
if (field.name === 'sample_id') continue; // tracked separately
|
|
253
|
+
for (const field of schema.config_fields) {
|
|
254
|
+
if (field.name === 'sample_id') continue;
|
|
172
255
|
if (field.required) {
|
|
173
256
|
const v = config[field.name];
|
|
174
257
|
if (v === undefined || v === '' || v === null) { valid = false; break; }
|
|
175
258
|
}
|
|
176
259
|
}
|
|
260
|
+
|
|
261
|
+
// We also gate validity on having loaded the project_fields
|
|
262
|
+
// for the selected project — staging *without* them would
|
|
263
|
+
// record a test.json that's missing project-level metadata
|
|
264
|
+
// for the lifetime of the run. The fetch happens automatically
|
|
265
|
+
// when the project is selected, so this is a tight transient
|
|
266
|
+
// window in practice.
|
|
267
|
+
if (valid && projectExists && projectFieldsCache[projectId.trim()] === undefined) {
|
|
268
|
+
valid = false;
|
|
269
|
+
}
|
|
270
|
+
|
|
177
271
|
setIsValid(valid);
|
|
178
272
|
if (onValidationChange) onValidationChange(valid, config);
|
|
179
273
|
|
|
180
|
-
// Auto-stage when everything is valid. Stripping sample_id from
|
|
181
|
-
// config (it's a top-level peer of project_id/method_id now;
|
|
182
|
-
// the legacy nested form is server-side hoisted with a
|
|
183
|
-
// deprecation warning, but new code shouldn't rely on it).
|
|
184
274
|
if (valid) {
|
|
185
|
-
const { sample_id: _drop, ...
|
|
275
|
+
const { sample_id: _drop, ...configRest } = (config ?? {}) as any;
|
|
276
|
+
// Combine persisted project_fields (managerial setup) with
|
|
277
|
+
// the per-test config_fields the operator just filled in.
|
|
278
|
+
// Keys collide in pathological project.json, in which case
|
|
279
|
+
// the per-test value wins — operators are closer to the
|
|
280
|
+
// run than the project metadata.
|
|
281
|
+
const mergedConfig = {
|
|
282
|
+
...projectFieldsForCurrent,
|
|
283
|
+
...configRest,
|
|
284
|
+
};
|
|
186
285
|
void invoke('tis.stage_test' as any, MessageType.Request, {
|
|
187
286
|
project_id: projectId,
|
|
188
287
|
method_id: methodId,
|
|
189
288
|
sample_id: sampleId,
|
|
190
|
-
config:
|
|
289
|
+
config: mergedConfig,
|
|
191
290
|
} as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
|
|
192
291
|
}
|
|
193
|
-
|
|
292
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
293
|
+
}, [config, schema, projectId, methodId, sampleId, projectExists, projectFieldsCache, onValidationChange, invoke]);
|
|
194
294
|
|
|
195
295
|
const isFieldValid = (field: TestFieldDef) => {
|
|
196
296
|
if (!field.required) return true;
|
|
@@ -199,7 +299,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
199
299
|
};
|
|
200
300
|
|
|
201
301
|
const handleProjectIdChange = (value: string | null | undefined) => {
|
|
202
|
-
const sanitized = (value || '').replace(/[^a-zA-Z0-9_]/g, '');
|
|
302
|
+
const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
203
303
|
setProjectIdLocal(sanitized);
|
|
204
304
|
};
|
|
205
305
|
|
|
@@ -215,13 +315,34 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
215
315
|
}
|
|
216
316
|
};
|
|
217
317
|
|
|
218
|
-
|
|
318
|
+
// Called by ProjectInfoDialog after a successful create/update.
|
|
319
|
+
// Populates the local cache + known-projects set so the main form
|
|
320
|
+
// is immediately valid without requiring a refresh.
|
|
321
|
+
const handleProjectInfoSubmitted = (pid: string, projectFields: Record<string, any>) => {
|
|
322
|
+
justCreatedRef.current.add(pid);
|
|
323
|
+
setJustCreatedTick(t => t + 1);
|
|
324
|
+
setProjectFieldsCache(prev => ({ ...prev, [pid]: projectFields }));
|
|
325
|
+
// Refresh the dropdown so the new project shows up for any
|
|
326
|
+
// future searches in this session.
|
|
327
|
+
void fetchProjects();
|
|
328
|
+
// If the dialog created a brand-new project, also surface it
|
|
329
|
+
// as the current selection — the operator's intent is clearly
|
|
330
|
+
// "set up this project and start working on it."
|
|
331
|
+
if (projectId.trim() !== pid) setProjectIdLocal(pid);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// -----------------------------------------------------------------
|
|
335
|
+
// Per-config-field row renderer — same four-column layout as before:
|
|
336
|
+
// label[units] | input | info-icon | validity-icon
|
|
337
|
+
// -----------------------------------------------------------------
|
|
338
|
+
const renderConfigField = (field: TestFieldDef) => {
|
|
219
339
|
if (field.name === 'sample_id') return null;
|
|
220
340
|
const valid = isFieldValid(field);
|
|
221
341
|
const isNum = field.type !== 'string' && field.type !== 'bool';
|
|
342
|
+
const tooltipId = `acFormInfo_${field.name}`;
|
|
222
343
|
return (
|
|
223
344
|
<React.Fragment key={field.name}>
|
|
224
|
-
<span className="ac-form-label">{field
|
|
345
|
+
<span className="ac-form-label">{labelOf(field)}</span>
|
|
225
346
|
{isNum ? (
|
|
226
347
|
<ValueInput
|
|
227
348
|
label={undefined}
|
|
@@ -237,7 +358,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
237
358
|
className={!valid ? 'p-invalid' : ''}
|
|
238
359
|
/>
|
|
239
360
|
)}
|
|
240
|
-
|
|
361
|
+
{hasDescription(field) ? (
|
|
362
|
+
<>
|
|
363
|
+
<Tooltip target={`#${tooltipId}`} position="left" />
|
|
364
|
+
<span
|
|
365
|
+
id={tooltipId}
|
|
366
|
+
data-pr-tooltip={field.description}
|
|
367
|
+
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
|
|
368
|
+
>
|
|
369
|
+
<i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
|
|
370
|
+
</span>
|
|
371
|
+
</>
|
|
372
|
+
) : (
|
|
373
|
+
<span aria-hidden="true" />
|
|
374
|
+
)}
|
|
241
375
|
<span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
|
|
242
376
|
<i className={valid ? 'pi pi-check' : 'pi pi-times'} />
|
|
243
377
|
</span>
|
|
@@ -255,8 +389,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
255
389
|
);
|
|
256
390
|
}
|
|
257
391
|
|
|
392
|
+
const gridStyle: React.CSSProperties = {
|
|
393
|
+
padding: '1.25rem',
|
|
394
|
+
gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const projectRowValid = projectExists;
|
|
398
|
+
|
|
258
399
|
return (
|
|
259
|
-
<div className="ac-form-grid" style={
|
|
400
|
+
<div className="ac-form-grid" style={gridStyle}>
|
|
260
401
|
<h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
261
402
|
Project & Method
|
|
262
403
|
<span style={{ color: isValid ? 'var(--green-500)' : 'var(--red-500)' }}>
|
|
@@ -265,18 +406,62 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
265
406
|
</h3>
|
|
266
407
|
|
|
267
408
|
<span className="ac-form-label">Project ID</span>
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
409
|
+
<div className="p-inputgroup" style={{ flex: 1 }}>
|
|
410
|
+
<AutoComplete
|
|
411
|
+
value={projectId}
|
|
412
|
+
suggestions={filteredProjects}
|
|
413
|
+
completeMethod={searchProjects}
|
|
414
|
+
onChange={(e) => handleProjectIdChange(e.value)}
|
|
415
|
+
dropdown
|
|
416
|
+
placeholder="Select an existing Project ID, or type a new one and click +"
|
|
417
|
+
className={!projectRowValid ? 'p-invalid' : ''}
|
|
418
|
+
style={{ flex: 1 }}
|
|
419
|
+
/>
|
|
420
|
+
{/*
|
|
421
|
+
* + button → opens the New Project dialog where the
|
|
422
|
+
* operator (or manager) fills in project_fields.
|
|
423
|
+
* Enabled only when the typed ID is a fresh, valid
|
|
424
|
+
* candidate. Once the dialog completes, the project
|
|
425
|
+
* is created on the server and added to our local
|
|
426
|
+
* known-projects set so the main form becomes valid
|
|
427
|
+
* immediately.
|
|
428
|
+
*/}
|
|
429
|
+
<Button
|
|
430
|
+
icon="pi pi-plus"
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={() => setNewProjectOpen(true)}
|
|
433
|
+
disabled={!canCreateProject}
|
|
434
|
+
tooltip={
|
|
435
|
+
!projectId.trim() ? 'Type a project ID first' :
|
|
436
|
+
!projectIdFormatValid ? 'Letters, digits, _ and - only' :
|
|
437
|
+
knownProjects.has(projectId.trim()) ? 'Project already exists' :
|
|
438
|
+
`Create project "${projectId.trim()}"`
|
|
439
|
+
}
|
|
440
|
+
tooltipOptions={{ position: 'top' }}
|
|
441
|
+
/>
|
|
442
|
+
{/*
|
|
443
|
+
* ✏️ button → opens the Edit Project Information
|
|
444
|
+
* dialog. Enabled only when the selected project
|
|
445
|
+
* actually exists. This is the only way to mutate
|
|
446
|
+
* project_fields after creation; the operator can't
|
|
447
|
+
* stumble into editing project metadata while running
|
|
448
|
+
* a sample, which keeps the future per-user permission
|
|
449
|
+
* gate (manager vs operator) clean.
|
|
450
|
+
*/}
|
|
451
|
+
<Button
|
|
452
|
+
icon="pi pi-pencil"
|
|
453
|
+
type="button"
|
|
454
|
+
onClick={() => setEditProjectOpen(true)}
|
|
455
|
+
disabled={!projectExists}
|
|
456
|
+
tooltip={projectExists
|
|
457
|
+
? `Edit information for "${projectId.trim()}"`
|
|
458
|
+
: 'Select an existing project to edit'}
|
|
459
|
+
tooltipOptions={{ position: 'top' }}
|
|
460
|
+
/>
|
|
461
|
+
</div>
|
|
462
|
+
<span aria-hidden="true" />
|
|
463
|
+
<span style={{ color: projectRowValid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
|
|
464
|
+
<i className={projectRowValid ? 'pi pi-check' : 'pi pi-times'} />
|
|
280
465
|
</span>
|
|
281
466
|
|
|
282
467
|
<span className="ac-form-label">Sample ID</span>
|
|
@@ -286,29 +471,82 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
286
471
|
onValueChanged={handleSampleIdChange}
|
|
287
472
|
className={!sampleId.trim() ? 'p-invalid' : ''}
|
|
288
473
|
/>
|
|
289
|
-
<span />
|
|
474
|
+
<span aria-hidden="true" />
|
|
290
475
|
<span style={{ color: sampleId.trim() ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
|
|
291
476
|
<i className={sampleId.trim() ? 'pi pi-check' : 'pi pi-times'} />
|
|
292
477
|
</span>
|
|
293
478
|
|
|
294
|
-
{
|
|
479
|
+
{/*
|
|
480
|
+
* Test Method row. Shows the current method's pretty
|
|
481
|
+
* label (or its canonical method_id when no label is
|
|
482
|
+
* declared) read-only, with an edit button that opens
|
|
483
|
+
* the picker dialog. The dialog scales past three or
|
|
484
|
+
* four methods where a SelectButton would wrap, and
|
|
485
|
+
* surfaces the per-method description so the operator
|
|
486
|
+
* can disambiguate similarly-named methods at the
|
|
487
|
+
* point of choice. The row is rendered even when only
|
|
488
|
+
* one method is declared so the operator can still open
|
|
489
|
+
* the picker and read its description.
|
|
490
|
+
*/}
|
|
491
|
+
{methodIds.length > 0 && (
|
|
295
492
|
<>
|
|
296
493
|
<span className="ac-form-label">Test Method</span>
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
494
|
+
<div className="p-inputgroup" style={{ flex: 1 }}>
|
|
495
|
+
<InputText
|
|
496
|
+
value={methodLabelOf(methodId, schema)}
|
|
497
|
+
readOnly
|
|
498
|
+
style={{ flex: 1 }}
|
|
499
|
+
tabIndex={-1}
|
|
500
|
+
/>
|
|
501
|
+
<Button
|
|
502
|
+
icon="pi pi-pencil"
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={() => setMethodPickerOpen(true)}
|
|
505
|
+
tooltip={methodIds.length > 1
|
|
506
|
+
? 'Change test method'
|
|
507
|
+
: 'View test method details'}
|
|
508
|
+
tooltipOptions={{ position: 'top' }}
|
|
509
|
+
/>
|
|
510
|
+
</div>
|
|
511
|
+
<span aria-hidden="true" />
|
|
512
|
+
<span style={{ color: methodId ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
|
|
513
|
+
<i className={methodId ? 'pi pi-check' : 'pi pi-times'} />
|
|
514
|
+
</span>
|
|
304
515
|
</>
|
|
305
516
|
)}
|
|
306
517
|
|
|
307
|
-
<h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Project Information</h3>
|
|
308
|
-
{schema.project_fields.map(renderField)}
|
|
309
|
-
|
|
310
518
|
<h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
|
|
311
|
-
{schema.config_fields.map(
|
|
519
|
+
{schema.config_fields.map(renderConfigField)}
|
|
520
|
+
|
|
521
|
+
{/*
|
|
522
|
+
* Project Information no longer renders inline. Use the
|
|
523
|
+
* + and ✏️ buttons in the Project ID row above to create
|
|
524
|
+
* or edit it. The dialogs persist project_fields on the
|
|
525
|
+
* server and the form folds them into stage_test
|
|
526
|
+
* automatically.
|
|
527
|
+
*/}
|
|
528
|
+
<ProjectInfoDialog
|
|
529
|
+
visible={newProjectOpen}
|
|
530
|
+
onHide={() => setNewProjectOpen(false)}
|
|
531
|
+
mode="create"
|
|
532
|
+
projectId={projectId.trim()}
|
|
533
|
+
projectFields={schema.project_fields}
|
|
534
|
+
onSubmitted={handleProjectInfoSubmitted}
|
|
535
|
+
/>
|
|
536
|
+
<ProjectInfoDialog
|
|
537
|
+
visible={editProjectOpen}
|
|
538
|
+
onHide={() => setEditProjectOpen(false)}
|
|
539
|
+
mode="edit"
|
|
540
|
+
projectId={projectId.trim()}
|
|
541
|
+
projectFields={schema.project_fields}
|
|
542
|
+
onSubmitted={handleProjectInfoSubmitted}
|
|
543
|
+
/>
|
|
544
|
+
<TestMethodDialog
|
|
545
|
+
visible={methodPickerOpen}
|
|
546
|
+
onHide={() => setMethodPickerOpen(false)}
|
|
547
|
+
currentMethodId={methodId}
|
|
548
|
+
onSelected={(picked) => setMethodIdLocal(picked)}
|
|
549
|
+
/>
|
|
312
550
|
</div>
|
|
313
551
|
);
|
|
314
552
|
};
|
|
@@ -191,7 +191,9 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
|
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
// -----------------------------------------------------------------
|
|
194
|
-
// Schema load — once on mount
|
|
194
|
+
// Schema load — once on mount. The Hub's `invoke()` queues sends
|
|
195
|
+
// while the WS is still CONNECTING and flushes them on `onopen`,
|
|
196
|
+
// so this works whether or not the handshake has finished yet.
|
|
195
197
|
// -----------------------------------------------------------------
|
|
196
198
|
useEffect(() => {
|
|
197
199
|
let cancelled = false;
|
|
@@ -213,7 +215,6 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
|
|
|
213
215
|
}
|
|
214
216
|
})();
|
|
215
217
|
return () => { cancelled = true; };
|
|
216
|
-
// We intentionally only run once on mount — schemas are stable.
|
|
217
218
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
218
219
|
}, []);
|
|
219
220
|
|