@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.
@@ -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 { SelectButton } from 'primereact/selectbutton';
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>`. Pass any of these to lock that
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
- // Seed and live-update fields that declare a `source` (FQDN).
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 allFields) {
125
- if (field.name === 'sample_id') continue; // sample_id is top-level now
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. We only
159
- // call `tis.stage_test` when every required piece is present —
160
- // otherwise the operator's edge-triggered Start button could fire
161
- // a half-baked stage.
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 (!projectId.trim()) valid = false;
249
+ if (!projectExists) valid = false;
166
250
  if (!methodId.trim()) valid = false;
167
251
  if (!sampleId.trim()) valid = false;
168
252
 
169
- const allFields = [...schema.project_fields, ...schema.config_fields];
170
- for (const field of allFields) {
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, ...rest } = (config ?? {}) as any;
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: rest,
289
+ config: mergedConfig,
191
290
  } as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
192
291
  }
193
- }, [config, schema, projectId, methodId, sampleId, onValidationChange, invoke]);
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
- const renderField = (field: TestFieldDef) => {
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.name}</span>
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
- <span className="ac-form-units">{field.units ?? ''}</span>
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={{ padding: '1.25rem' }}>
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 &amp; 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
- <AutoComplete
269
- value={projectId}
270
- suggestions={filteredProjects}
271
- completeMethod={searchProjects}
272
- onChange={(e) => handleProjectIdChange(e.value)}
273
- dropdown
274
- placeholder="Enter or select Project ID"
275
- className={!projectId.trim() ? 'p-invalid' : ''}
276
- />
277
- <span />
278
- <span style={{ color: projectId.trim() ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
279
- <i className={projectId.trim() ? 'pi pi-check' : 'pi pi-times'} />
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
- {methodIds.length > 1 && (
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
- <SelectButton
298
- value={methodId}
299
- options={methodIds.map(id => ({ label: id, value: id }))}
300
- onChange={(e) => e.value && setMethodIdLocal(e.value)}
301
- />
302
- <span />
303
- <span />
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(renderField)}
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