@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.
@@ -1,20 +1,27 @@
1
1
  import React, { useState, useEffect, useContext, useMemo } from 'react';
2
- import { AutoComplete } from 'primereact/autocomplete';
3
- import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
4
- import { SelectButton } from 'primereact/selectbutton';
2
+ import { Button } from 'primereact/button';
3
+ import { InputText } from 'primereact/inputtext';
4
+ import { Tooltip } from 'primereact/tooltip';
5
5
  import { EventEmitterContext } from '../../core/EventEmitterContext';
6
6
  import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
7
7
  import { MessageType } from '../../hub/CommandMessage';
8
8
  import { ValueInput } from '../ValueInput';
9
9
  import { TextInput } from '../TextInput';
10
10
  import { useTis } from './TisProvider';
11
+ import { TestMethodDialog } from './TestMethodDialog';
11
12
 
12
13
  export interface TestFieldDef {
14
+ /** Canonical key — wire format, generated code, on-disk JSON. */
13
15
  name: string;
14
16
  type: string;
15
17
  units?: string;
16
18
  required?: boolean;
17
19
  source?: string;
20
+ /** Pretty label rendered by the form. Falls back to `name`. Units
21
+ * are appended automatically; don't pre-format `[mm]` into label. */
22
+ label?: string;
23
+ /** Long-form guidance surfaced as a hover tooltip on an info icon. */
24
+ description?: string;
18
25
  }
19
26
 
20
27
  export interface TestMethod {
@@ -22,33 +29,51 @@ export interface TestMethod {
22
29
  config_fields: TestFieldDef[];
23
30
  cycle_fields: TestFieldDef[];
24
31
  results_fields: TestFieldDef[];
32
+ /** Optional pretty label for the Test Method picker. Falls back
33
+ * to the canonical method_id key. */
34
+ label?: string;
35
+ /** Optional long-form description shown in the picker dialog
36
+ * when this method is highlighted. */
37
+ description?: string;
25
38
  }
26
39
 
27
40
  /**
28
- * 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.
41
+ * Test-setup form. Renders Sample ID, Test Method picker, and Test
42
+ * Configuration. Project ID lives in `<ProjectSelector>` on its own
43
+ * tab — this form reads the selected project from `<TisProvider>`
44
+ * and gates staging on it being a known project (created via the
45
+ * Project tab's `+` button).
31
46
  *
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.
47
+ * All props are optional overrides — by default the form drives
48
+ * itself from the surrounding `<TisProvider>`.
37
49
  */
38
50
  export interface TestSetupFormProps {
39
51
  schema?: TestMethod;
40
- defaultProjectId?: string;
41
52
  defaultMethodId?: string;
42
- onProjectChange?: (projectId: string) => void;
43
53
  onMethodChange?: (methodId: string) => void;
44
54
  onValidationChange?: (isValid: boolean, config: any) => void;
45
55
  }
46
56
 
57
+ // -------------------------------------------------------------------------
58
+ // Helpers
59
+ // -------------------------------------------------------------------------
60
+
61
+ const labelOf = (f: TestFieldDef): string => {
62
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
63
+ return f.units ? `${base} [${f.units}]` : base;
64
+ };
65
+
66
+ const hasDescription = (f: TestFieldDef): boolean =>
67
+ typeof f.description === 'string' && f.description.length > 0;
68
+
69
+ const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
70
+ (schema?.label && schema.label.length > 0) ? schema.label : methodId;
71
+
72
+ // -------------------------------------------------------------------------
73
+
47
74
  export const TestSetupForm: React.FC<TestSetupFormProps> = ({
48
75
  schema: schemaOverride,
49
- defaultProjectId,
50
76
  defaultMethodId,
51
- onProjectChange,
52
77
  onMethodChange,
53
78
  onValidationChange,
54
79
  }) => {
@@ -58,33 +83,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
58
83
 
59
84
  const methodIds = useMemo(() => Object.keys(tis.schemas), [tis.schemas]);
60
85
 
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
- const [projectId, setProjectIdLocal] = useState<string>(
65
- tis.selection.projectId || defaultProjectId || ''
66
- );
86
+ // The form owns Sample ID, Method, and per-test config_fields
87
+ // values. Project ID is sourced from the provider's selection
88
+ // (set by <ProjectSelector> on the Project tab).
89
+ const projectId = tis.selection.projectId;
90
+ const projectExists = projectId.trim() !== '' && tis.projectKnown(projectId.trim());
91
+
67
92
  const [methodId, setMethodIdLocal] = useState<string>(
68
93
  tis.selection.methodId || defaultMethodId || tis.defaultMethodId || ''
69
94
  );
70
95
  const [sampleId, setSampleIdLocal] = useState<string>('');
71
96
  const [config, setConfig] = useState<any>({});
72
97
 
73
- // Resolve the schema for the active method. The override beats the
74
- // registry; if neither is available, render the empty state.
75
98
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
76
99
 
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
- useEffect(() => {
81
- if (tis.selection.projectId !== projectId) {
82
- tis.setSelection({ projectId });
83
- }
84
- if (onProjectChange) onProjectChange(projectId);
85
- // eslint-disable-next-line react-hooks/exhaustive-deps
86
- }, [projectId]);
87
-
88
100
  useEffect(() => {
89
101
  if (tis.selection.methodId !== methodId && methodId) {
90
102
  tis.setSelection({ methodId });
@@ -100,10 +112,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
100
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
101
113
  }, [sampleId]);
102
114
 
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
115
  useEffect(() => {
108
116
  if (tis.state.stagedSampleId && tis.state.stagedSampleId !== sampleId) {
109
117
  setSampleIdLocal(tis.state.stagedSampleId);
@@ -111,18 +119,26 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
111
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
112
120
  }, [tis.state.stagedSampleId]);
113
121
 
114
- const [existingProjects, setExistingProjects] = useState<string[]>([]);
115
- const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
122
+ // If the provider's selected method changes elsewhere (e.g., the
123
+ // operator picks a different method via the dialog), reflect it
124
+ // here so the schema we render stays in sync.
125
+ useEffect(() => {
126
+ if (tis.selection.methodId && tis.selection.methodId !== methodId) {
127
+ setMethodIdLocal(tis.selection.methodId);
128
+ }
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [tis.selection.methodId]);
131
+
116
132
  const [isValid, setIsValid] = useState(false);
133
+ const [methodPickerOpen, setMethodPickerOpen] = useState(false);
117
134
 
118
- // Seed and live-update fields that declare a `source` (FQDN).
135
+ // Seed and live-update config_fields that declare a `source`.
119
136
  useEffect(() => {
120
137
  if (!schema) return;
121
- const allFields = [...schema.project_fields, ...schema.config_fields];
122
138
  setConfig((prev: any) => {
123
139
  let next = prev;
124
- for (const field of allFields) {
125
- if (field.name === 'sample_id') continue; // sample_id is top-level now
140
+ for (const field of schema.config_fields) {
141
+ if (field.name === 'sample_id') continue;
126
142
  if (!field.source) continue;
127
143
  const tag = findTagByFqdn(field.source);
128
144
  if (!tag) continue;
@@ -136,61 +152,53 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
136
152
  });
137
153
  }, [schema, rawValues, findTagByFqdn]);
138
154
 
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
- const searchProjects = (event: AutoCompleteCompleteEvent) => {
154
- const query = event.query.toLowerCase();
155
- setFilteredProjects(existingProjects.filter(p => p.toLowerCase().includes(query)));
156
- };
157
-
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.
155
+ // Validation: project must be known (set on Project tab + + button),
156
+ // sample_id non-empty, and every required config_field present. We
157
+ // also require the provider's projectFieldsCache for the selected
158
+ // project to be loaded before we stage otherwise the recorded
159
+ // test.json would be missing project-level metadata.
162
160
  useEffect(() => {
163
161
  if (!schema) { setIsValid(false); return; }
164
162
  let valid = true;
165
- if (!projectId.trim()) valid = false;
163
+ if (!projectExists) valid = false;
166
164
  if (!methodId.trim()) valid = false;
167
165
  if (!sampleId.trim()) valid = false;
166
+ if (valid && !tis.projectFieldsLoaded) valid = false;
168
167
 
169
- const allFields = [...schema.project_fields, ...schema.config_fields];
170
- for (const field of allFields) {
171
- if (field.name === 'sample_id') continue; // tracked separately
168
+ for (const field of schema.config_fields) {
169
+ if (field.name === 'sample_id') continue;
172
170
  if (field.required) {
173
171
  const v = config[field.name];
174
172
  if (v === undefined || v === '' || v === null) { valid = false; break; }
175
173
  }
176
174
  }
175
+
177
176
  setIsValid(valid);
178
177
  if (onValidationChange) onValidationChange(valid, config);
179
178
 
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
179
  if (valid) {
185
- const { sample_id: _drop, ...rest } = (config ?? {}) as any;
180
+ const { sample_id: _drop, ...configRest } = (config ?? {}) as any;
181
+ // Combine persisted project_fields (managerial setup) with
182
+ // per-test config_fields. If keys collide the per-test
183
+ // value wins — operators are closer to the run than the
184
+ // project metadata.
185
+ const mergedConfig = {
186
+ ...tis.projectFields,
187
+ ...configRest,
188
+ };
186
189
  void invoke('tis.stage_test' as any, MessageType.Request, {
187
190
  project_id: projectId,
188
191
  method_id: methodId,
189
192
  sample_id: sampleId,
190
- config: rest,
193
+ config: mergedConfig,
191
194
  } as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
192
195
  }
193
- }, [config, schema, projectId, methodId, sampleId, onValidationChange, invoke]);
196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
197
+ }, [
198
+ config, schema, projectId, methodId, sampleId,
199
+ projectExists, tis.projectFields, tis.projectFieldsLoaded,
200
+ onValidationChange, invoke,
201
+ ]);
194
202
 
195
203
  const isFieldValid = (field: TestFieldDef) => {
196
204
  if (!field.required) return true;
@@ -198,11 +206,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
198
206
  return v !== undefined && v !== '' && v !== null;
199
207
  };
200
208
 
201
- const handleProjectIdChange = (value: string | null | undefined) => {
202
- const sanitized = (value || '').replace(/[^a-zA-Z0-9_]/g, '');
203
- setProjectIdLocal(sanitized);
204
- };
205
-
206
209
  const handleSampleIdChange = (value: string) => {
207
210
  setSampleIdLocal(value);
208
211
  };
@@ -215,13 +218,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
215
218
  }
216
219
  };
217
220
 
218
- const renderField = (field: TestFieldDef) => {
221
+ const renderConfigField = (field: TestFieldDef) => {
219
222
  if (field.name === 'sample_id') return null;
220
223
  const valid = isFieldValid(field);
221
224
  const isNum = field.type !== 'string' && field.type !== 'bool';
225
+ const tooltipId = `acFormInfo_${field.name}`;
222
226
  return (
223
227
  <React.Fragment key={field.name}>
224
- <span className="ac-form-label">{field.name}</span>
228
+ <span className="ac-form-label">{labelOf(field)}</span>
225
229
  {isNum ? (
226
230
  <ValueInput
227
231
  label={undefined}
@@ -237,7 +241,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
237
241
  className={!valid ? 'p-invalid' : ''}
238
242
  />
239
243
  )}
240
- <span className="ac-form-units">{field.units ?? ''}</span>
244
+ {hasDescription(field) ? (
245
+ <>
246
+ <Tooltip target={`#${tooltipId}`} position="left" />
247
+ <span
248
+ id={tooltipId}
249
+ data-pr-tooltip={field.description}
250
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
251
+ >
252
+ <i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
253
+ </span>
254
+ </>
255
+ ) : (
256
+ <span aria-hidden="true" />
257
+ )}
241
258
  <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
242
259
  <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
243
260
  </span>
@@ -255,30 +272,46 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
255
272
  );
256
273
  }
257
274
 
275
+ // Cross-tab guard: if no project is selected (or the typed name
276
+ // hasn't been created yet on the Project tab), the form is
277
+ // effectively useless because every staging path needs a real
278
+ // project_id. Render an explicit empty-state pointing the user
279
+ // back to the Project tab so they don't sit confused in front of
280
+ // a half-functional form.
281
+ if (!projectExists) {
282
+ return (
283
+ <div style={{ padding: '1.25rem', maxWidth: '600px' }}>
284
+ <h3 className="ac-form-section">No project selected</h3>
285
+ <p style={{ color: 'var(--text-secondary-color)', marginTop: '0.5rem' }}>
286
+ Pick a project on the <strong>Project</strong> tab first
287
+ {projectId.trim() !== '' && ` (or click + there to create "${projectId.trim()}")`}.
288
+ </p>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ const gridStyle: React.CSSProperties = {
294
+ padding: '1.25rem',
295
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
296
+ };
297
+
258
298
  return (
259
- <div className="ac-form-grid" style={{ padding: '1.25rem' }}>
299
+ <div className="ac-form-grid" style={gridStyle}>
260
300
  <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
261
- Project &amp; Method
301
+ Test Setup
262
302
  <span style={{ color: isValid ? 'var(--green-500)' : 'var(--red-500)' }}>
263
303
  <i className={isValid ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'} />
264
304
  </span>
305
+ <span style={{
306
+ fontSize: '0.85em',
307
+ color: 'var(--text-secondary-color)',
308
+ fontWeight: 'normal',
309
+ marginLeft: '0.25rem',
310
+ }}>
311
+ project: <strong>{projectId}</strong>
312
+ </span>
265
313
  </h3>
266
314
 
267
- <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'} />
280
- </span>
281
-
282
315
  <span className="ac-form-label">Sample ID</span>
283
316
  <TextInput
284
317
  label={undefined}
@@ -286,29 +319,47 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
286
319
  onValueChanged={handleSampleIdChange}
287
320
  className={!sampleId.trim() ? 'p-invalid' : ''}
288
321
  />
289
- <span />
322
+ <span aria-hidden="true" />
290
323
  <span style={{ color: sampleId.trim() ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
291
324
  <i className={sampleId.trim() ? 'pi pi-check' : 'pi pi-times'} />
292
325
  </span>
293
326
 
294
- {methodIds.length > 1 && (
327
+ {methodIds.length > 0 && (
295
328
  <>
296
329
  <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 />
330
+ <div className="p-inputgroup" style={{ flex: 1 }}>
331
+ <InputText
332
+ value={methodLabelOf(methodId, schema)}
333
+ readOnly
334
+ style={{ flex: 1 }}
335
+ tabIndex={-1}
336
+ />
337
+ <Button
338
+ icon="pi pi-pencil"
339
+ type="button"
340
+ onClick={() => setMethodPickerOpen(true)}
341
+ tooltip={methodIds.length > 1
342
+ ? 'Change test method'
343
+ : 'View test method details'}
344
+ tooltipOptions={{ position: 'top' }}
345
+ />
346
+ </div>
347
+ <span aria-hidden="true" />
348
+ <span style={{ color: methodId ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
349
+ <i className={methodId ? 'pi pi-check' : 'pi pi-times'} />
350
+ </span>
304
351
  </>
305
352
  )}
306
353
 
307
- <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Project Information</h3>
308
- {schema.project_fields.map(renderField)}
309
-
310
354
  <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
311
- {schema.config_fields.map(renderField)}
355
+ {schema.config_fields.map(renderConfigField)}
356
+
357
+ <TestMethodDialog
358
+ visible={methodPickerOpen}
359
+ onHide={() => setMethodPickerOpen(false)}
360
+ currentMethodId={methodId}
361
+ onSelected={(picked) => setMethodIdLocal(picked)}
362
+ />
312
363
  </div>
313
364
  );
314
365
  };
@@ -90,6 +90,45 @@ export interface TisContextValue {
90
90
  selection: TisSelection;
91
91
  setSelection: (patch: TisSelectionPatch) => void;
92
92
 
93
+ // -----------------------------------------------------------------
94
+ // Project management — used by both <ProjectSelector> (Project tab)
95
+ // and <TestSetupForm> (Test tab) so they share a single source of
96
+ // truth for "which projects are real" and "what fields does the
97
+ // current one have." Keeping this in the provider rather than in
98
+ // TestSetupForm lets the two components live in different tabs
99
+ // without prop-threading.
100
+ // -----------------------------------------------------------------
101
+
102
+ /** Project IDs returned by the server's `tis.list_projects`. */
103
+ existingProjects: string[];
104
+ /** True when the project either exists on disk OR was created in
105
+ * this browser session via `<ProjectInfoDialog mode="create">`.
106
+ * This is the gate for staging — typing an unknown name is
107
+ * invalid until + creates the directory. */
108
+ projectKnown: (id: string) => boolean;
109
+ /** Refresh `existingProjects` from the server. Called automatically
110
+ * on `tis.project_created` / `tis.project_updated` broadcasts. */
111
+ refreshProjects: () => Promise<void>;
112
+ /** Add a project ID to the in-session "just created" set so the
113
+ * form is immediately valid for it without round-tripping to
114
+ * list_projects. Idempotent. */
115
+ markProjectJustCreated: (id: string) => void;
116
+
117
+ /** `project_fields` blob for the currently-selected project,
118
+ * fetched from project.json. `{}` when nothing is loaded yet
119
+ * (use `projectFieldsLoaded` to disambiguate "empty project" vs
120
+ * "still fetching"). */
121
+ projectFields: Record<string, any>;
122
+ projectFieldsLoaded: boolean;
123
+ /** Fetch and cache project_fields for one project. Returns the
124
+ * fields on success, or null on error. The current selection's
125
+ * fields are also re-loaded automatically when `selection.projectId`
126
+ * changes. */
127
+ loadProjectFields: (id: string) => Promise<Record<string, any> | null>;
128
+ /** Stash freshly-known project_fields without a round trip — used
129
+ * by the create / edit dialogs after a successful submit. */
130
+ setProjectFields: (id: string, fields: Record<string, any>) => void;
131
+
93
132
  /** Fetch the run list for a (project, method?) pair. Method may be
94
133
  * omitted to aggregate runs across every method in the project —
95
134
  * the History tab uses this. */
@@ -120,6 +159,14 @@ const TisContext = createContext<TisContextValue>({
120
159
  state: EMPTY_STATE,
121
160
  selection: EMPTY_SELECTION,
122
161
  setSelection: () => {},
162
+ existingProjects: [],
163
+ projectKnown: () => false,
164
+ refreshProjects: async () => {},
165
+ markProjectJustCreated: () => {},
166
+ projectFields: {},
167
+ projectFieldsLoaded: false,
168
+ loadProjectFields: async () => null,
169
+ setProjectFields: () => {},
123
170
  fetchRuns: async () => [],
124
171
  fetchRun: async () => null,
125
172
  runCache: {},
@@ -340,11 +387,111 @@ export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMetho
340
387
 
341
388
  const runCache = useMemo(() => ({ ...cacheRef.current }), [cacheVersion]);
342
389
 
390
+ // -----------------------------------------------------------------
391
+ // Project management state
392
+ //
393
+ // Mirrors what TestSetupForm used to track locally, but lifted up
394
+ // here so <ProjectSelector> on the Project tab and <TestSetupForm>
395
+ // on the Test tab share a single source of truth. Without this
396
+ // lift, the two tabs would each fire their own list_projects and
397
+ // disagree on which IDs are valid.
398
+ // -----------------------------------------------------------------
399
+ const [existingProjects, setExistingProjects] = useState<string[]>([]);
400
+ const justCreatedRef = useRef<Set<string>>(new Set());
401
+ const [projectsTick, setProjectsTick] = useState(0); // bumps on Set mutation
402
+ const [projectFieldsCache, setProjectFieldsCache] = useState<Record<string, Record<string, any>>>({});
403
+
404
+ const refreshProjects = useCallback(async () => {
405
+ try {
406
+ const resp: any = await invoke('tis.list_projects' as any, MessageType.Request, {} as any);
407
+ if (resp?.success && resp.data?.projects) {
408
+ setExistingProjects(resp.data.projects as string[]);
409
+ }
410
+ } catch (e) {
411
+ console.error('[TisProvider] tis.list_projects failed:', e);
412
+ }
413
+ }, [invoke]);
414
+
415
+ const markProjectJustCreated = useCallback((id: string) => {
416
+ if (!id) return;
417
+ if (justCreatedRef.current.has(id)) return;
418
+ justCreatedRef.current.add(id);
419
+ setProjectsTick(t => t + 1);
420
+ }, []);
421
+
422
+ const projectKnown = useCallback((id: string) => {
423
+ if (!id) return false;
424
+ if (justCreatedRef.current.has(id)) return true;
425
+ return existingProjects.includes(id);
426
+ // existingProjects + projectsTick are deps but useCallback
427
+ // closes over them; consumers read current values fine.
428
+ // eslint-disable-next-line react-hooks/exhaustive-deps
429
+ }, [existingProjects, projectsTick]);
430
+
431
+ const setProjectFields = useCallback((id: string, fields: Record<string, any>) => {
432
+ if (!id) return;
433
+ setProjectFieldsCache(prev => ({ ...prev, [id]: fields }));
434
+ }, []);
435
+
436
+ const loadProjectFields = useCallback(async (id: string): Promise<Record<string, any> | null> => {
437
+ if (!id) return null;
438
+ try {
439
+ const resp: any = await invoke('tis.read_project' as any, MessageType.Request, { project_id: id } as any);
440
+ if (resp?.success) {
441
+ const fields = (resp.data?.project_fields ?? {}) as Record<string, any>;
442
+ setProjectFieldsCache(prev => ({ ...prev, [id]: fields }));
443
+ return fields;
444
+ }
445
+ } catch (e) {
446
+ console.warn('[TisProvider] tis.read_project failed:', e);
447
+ }
448
+ return null;
449
+ }, [invoke]);
450
+
451
+ // Initial project list load + refresh on server-side mutation
452
+ // broadcasts. Mark-just-created is purely local; the server
453
+ // broadcasts kick the persisted list back into sync if a project
454
+ // was added by another client (or the next time `acctl` writes a
455
+ // new directory).
456
+ useEffect(() => { void refreshProjects(); }, [refreshProjects]);
457
+
458
+ useEffect(() => {
459
+ const onCreated = () => { void refreshProjects(); };
460
+ const onUpdated = (payload: any) => {
461
+ const pid = typeof payload?.project_id === 'string' ? payload.project_id : '';
462
+ if (pid) void loadProjectFields(pid);
463
+ };
464
+ const id1 = subscribe('tis.project_created', onCreated);
465
+ const id2 = subscribe('tis.project_updated', onUpdated);
466
+ return () => { unsubscribe(id1); unsubscribe(id2); };
467
+ }, [subscribe, unsubscribe, refreshProjects, loadProjectFields]);
468
+
469
+ // Auto-fetch project_fields whenever the selection lands on a
470
+ // known project we don't yet have cached. The Test tab's stage
471
+ // payload depends on this.
472
+ useEffect(() => {
473
+ const pid = selection.projectId;
474
+ if (!pid || !projectKnown(pid)) return;
475
+ if (projectFieldsCache[pid] !== undefined) return;
476
+ void loadProjectFields(pid);
477
+ }, [selection.projectId, projectKnown, projectFieldsCache, loadProjectFields]);
478
+
479
+ const projectFields = projectFieldsCache[selection.projectId] ?? {};
480
+ const projectFieldsLoaded = projectFieldsCache[selection.projectId] !== undefined;
481
+
343
482
  const value: TisContextValue = useMemo(() => ({
344
483
  schemas, defaultMethodId, schemasLoaded,
345
484
  state, selection, setSelection,
485
+ existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
486
+ projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
487
+ fetchRuns, fetchRun, runCache,
488
+ }), [
489
+ schemas, defaultMethodId, schemasLoaded,
490
+ state, selection, setSelection,
491
+ existingProjects, projectKnown, refreshProjects, markProjectJustCreated,
492
+ projectFields, projectFieldsLoaded, loadProjectFields, setProjectFields,
346
493
  fetchRuns, fetchRun, runCache,
347
- }), [schemas, defaultMethodId, schemasLoaded, state, selection, setSelection, fetchRuns, fetchRun, runCache]);
494
+ ]);
348
495
 
349
496
  return <TisContext.Provider value={value}>{children}</TisContext.Provider>;
350
497
  };