@adcops/autocore-react 3.3.59 → 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,5 +1,7 @@
1
1
  export { TisProvider, TisContext, useTis, useTisSchemas, useTisState, useTisSelection, useTisRuns, useTisRun, } from './tis/TisProvider';
2
2
  export type { TisProviderProps, TisContextValue, TisLiveState, TisSelection, TisSelectionPatch, TisRunCacheEntry, SchemaRegistry, TisMethodSchema, } from './tis/TisProvider';
3
+ export { ProjectSelector } from './tis/ProjectSelector';
4
+ export type { ProjectSelectorProps } from './tis/ProjectSelector';
3
5
  export { TestSetupForm } from './tis/TestSetupForm';
4
6
  export type { TestSetupFormProps } from './tis/TestSetupForm';
5
7
  export { ProjectInfoDialog } from './tis/ProjectInfoDialog';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAQA,OAAO,EACH,WAAW,EACX,UAAU,EACV,MAAM,EACN,aAAa,EACb,WAAW,EACX,eAAe,EACf,UAAU,EACV,SAAS,GACZ,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACR,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,cAAc,EACd,eAAe,GAClB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,aAAa,EAAE,MAAY,qBAAqB,CAAC;AAC1D,YAAY,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,iBAAiB,EAAE,MAAQ,yBAAyB,CAAC;AAC9D,YAAY,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAS,wBAAwB,CAAC;AAC7D,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,OAAO,EAAE,kBAAkB,EAAE,MAAO,0BAA0B,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAExE,OAAO,EAAE,YAAY,EAAE,MAAa,oBAAoB,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE7G,OAAO,EAAE,eAAe,EAAE,MAAU,uBAAuB,CAAC;AAC5D,YAAY,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAQA,OAAO,EACH,WAAW,EACX,UAAU,EACV,MAAM,EACN,aAAa,EACb,WAAW,EACX,eAAe,EACf,UAAU,EACV,SAAS,GACZ,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACR,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,cAAc,EACd,eAAe,GAClB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,eAAe,EAAE,MAAU,uBAAuB,CAAC;AAC5D,YAAY,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAElE,OAAO,EAAE,aAAa,EAAE,MAAY,qBAAqB,CAAC;AAC1D,YAAY,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,EAAE,iBAAiB,EAAE,MAAQ,yBAAyB,CAAC;AAC9D,YAAY,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAS,wBAAwB,CAAC;AAC7D,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,OAAO,EAAE,kBAAkB,EAAE,MAAO,0BAA0B,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAExE,OAAO,EAAE,YAAY,EAAE,MAAa,oBAAoB,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE7G,OAAO,EAAE,eAAe,EAAE,MAAU,uBAAuB,CAAC;AAC5D,YAAY,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC"}
@@ -1 +1 @@
1
- export{TisProvider,TisContext,useTis,useTisSchemas,useTisState,useTisSelection,useTisRuns,useTisRun}from"./tis/TisProvider";export{TestSetupForm}from"./tis/TestSetupForm";export{ProjectInfoDialog}from"./tis/ProjectInfoDialog";export{TestMethodDialog}from"./tis/TestMethodDialog";export{ResultHistoryTable}from"./tis/ResultHistoryTable";export{TestDataView}from"./tis/TestDataView";export{TestRawDataView}from"./tis/TestRawDataView";
1
+ export{TisProvider,TisContext,useTis,useTisSchemas,useTisState,useTisSelection,useTisRuns,useTisRun}from"./tis/TisProvider";export{ProjectSelector}from"./tis/ProjectSelector";export{TestSetupForm}from"./tis/TestSetupForm";export{ProjectInfoDialog}from"./tis/ProjectInfoDialog";export{TestMethodDialog}from"./tis/TestMethodDialog";export{ResultHistoryTable}from"./tis/ResultHistoryTable";export{TestDataView}from"./tis/TestDataView";export{TestRawDataView}from"./tis/TestRawDataView";
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ export interface ProjectSelectorProps {
3
+ /**
4
+ * Optional override of the method whose `project_fields` are shown
5
+ * in the create / edit dialog. By default the dialog uses the
6
+ * provider's selected method (which is what you want — the
7
+ * project's metadata schema is per-method, and the form on the
8
+ * Test tab is going to use that same method anyway). Passing this
9
+ * is only useful if you have a "view-only" Project tab in a
10
+ * read-only HMI and want to lock the dialog to a specific method.
11
+ */
12
+ methodIdOverride?: string;
13
+ }
14
+ export declare const ProjectSelector: React.FC<ProjectSelectorProps>;
15
+ //# sourceMappingURL=ProjectSelector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectSelector.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ProjectSelector.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAwC,MAAM,OAAO,CAAC;AAc7D,MAAM,WAAW,oBAAoB;IACjC;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC,oBAAoB,CA6I1D,CAAC"}
@@ -0,0 +1 @@
1
+ import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";import React,{useContext,useState,useMemo}from"react";import{AutoComplete}from"primereact/autocomplete";import{Button}from"primereact/button";import{EventEmitterContext}from"../../core/EventEmitterContext";import{useTis}from"./TisProvider";import{ProjectInfoDialog}from"./ProjectInfoDialog";const PROJECT_ID_RE=/^[A-Za-z0-9_-]+$/,isValidProjectIdFormat=e=>PROJECT_ID_RE.test(e);export const ProjectSelector=({methodIdOverride:e})=>{const t=useTis(),{invoke:o}=useContext(EventEmitterContext),i=t.selection.projectId,r=e??t.selection.methodId??t.defaultMethodId??Object.keys(t.schemas)[0]??"",s=r?t.schemas[r]:void 0,c=s?.project_fields??[],[n,a]=useState([]),[l,p]=useState(!1),[m,d]=useState(!1),j=""!==i.trim()&&t.projectKnown(i.trim()),u=isValidProjectIdFormat(i.trim()),x=""!==i.trim()&&u&&!t.projectKnown(i.trim()),f=(e,o)=>{t.markProjectJustCreated(e),t.setProjectFields(e,o),t.refreshProjects(),t.selection.projectId!==e&&t.setSelection({projectId:e})},_=useMemo(()=>j?{color:"var(--green-500)",icon:"pi-check-circle"}:""===i.trim()?{color:"var(--text-secondary-color)",icon:"pi-info-circle"}:{color:"var(--red-500)",icon:"pi-exclamation-circle"},[j,i]);return _jsxs("div",{className:"ac-form-grid",style:{padding:"1.25rem",gridTemplateColumns:"auto 1fr 1.75rem 1.75rem"},children:[_jsxs("h3",{className:"ac-form-section",style:{display:"flex",alignItems:"center",gap:"10px"},children:["Project",_jsx("span",{style:{color:_.color},children:_jsx("i",{className:`pi ${_.icon}`})})]}),_jsx("span",{className:"ac-form-label",children:"Project ID"}),_jsxs("div",{className:"p-inputgroup",style:{flex:1},children:[_jsx(AutoComplete,{value:i,suggestions:n,completeMethod:e=>{const o=e.query.toLowerCase();a(t.existingProjects.filter(e=>e.toLowerCase().includes(o)))},onChange:e=>(e=>{const o=(e||"").replace(/[^a-zA-Z0-9_-]/g,"");t.setSelection({projectId:o})})(e.value),dropdown:!0,placeholder:"Select an existing Project ID, or type a new one and click +",className:i.trim()&&!j?"p-invalid":"",style:{flex:1}}),_jsx(Button,{icon:"pi pi-plus",type:"button",onClick:()=>p(!0),disabled:!x,tooltip:i.trim()?u?t.projectKnown(i.trim())?"Project already exists":`Create project "${i.trim()}"`:"Letters, digits, _ and - only":"Type a project ID first",tooltipOptions:{position:"top"}}),_jsx(Button,{icon:"pi pi-pencil",type:"button",onClick:()=>d(!0),disabled:!j,tooltip:j?`Edit information for "${i.trim()}"`:"Select an existing project to edit",tooltipOptions:{position:"top"}})]}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:j?"var(--green-500)":""===i.trim()?"var(--text-secondary-color)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:j?"pi pi-check":""===i.trim()?"pi pi-minus":"pi pi-times"})}),_jsx(ProjectInfoDialog,{visible:l,onHide:()=>p(!1),mode:"create",projectId:i.trim(),projectFields:c,onSubmitted:f}),_jsx(ProjectInfoDialog,{visible:m,onHide:()=>d(!1),mode:"edit",projectId:i.trim(),projectFields:c,onSubmitted:f})]})};
@@ -25,14 +25,18 @@ export interface TestMethod {
25
25
  description?: string;
26
26
  }
27
27
  /**
28
- * Props are all optional overrides by default the form drives itself
29
- * from the surrounding `<TisProvider>`.
28
+ * Test-setup form. Renders Sample ID, Test Method picker, and Test
29
+ * Configuration. Project ID lives in `<ProjectSelector>` on its own
30
+ * tab — this form reads the selected project from `<TisProvider>`
31
+ * and gates staging on it being a known project (created via the
32
+ * Project tab's `+` button).
33
+ *
34
+ * All props are optional overrides — by default the form drives
35
+ * itself from the surrounding `<TisProvider>`.
30
36
  */
31
37
  export interface TestSetupFormProps {
32
38
  schema?: TestMethod;
33
- defaultProjectId?: string;
34
39
  defaultMethodId?: string;
35
- onProjectChange?: (projectId: string) => void;
36
40
  onMethodChange?: (methodId: string) => void;
37
41
  onValidationChange?: (isValid: boolean, config: any) => void;
38
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"TestSetupForm.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestSetupForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA2D,MAAM,OAAO,CAAC;AAehF,MAAM,WAAW,YAAY;IACzB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACvB,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B;0CACsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;2CACuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IAC/B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;CAChE;AA4BD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAsdtD,CAAC"}
1
+ {"version":3,"file":"TestSetupForm.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestSetupForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAYxE,MAAM,WAAW,YAAY;IACzB,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACvB,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,YAAY,EAAE,YAAY,EAAE,CAAC;IAC7B,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B;0CACsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;2CACuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,kBAAkB;IAC/B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;CAChE;AAmBD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAmStD,CAAC"}
@@ -1 +1 @@
1
- import{jsx as _jsx,Fragment as _Fragment,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useMemo,useRef}from"react";import{AutoComplete}from"primereact/autocomplete";import{Button}from"primereact/button";import{InputText}from"primereact/inputtext";import{Tooltip}from"primereact/tooltip";import{EventEmitterContext}from"../../core/EventEmitterContext";import{AutoCoreTagContext}from"../../core/AutoCoreTagContext";import{MessageType}from"../../hub/CommandMessage";import{ValueInput}from"../ValueInput";import{TextInput}from"../TextInput";import{useTis}from"./TisProvider";import{ProjectInfoDialog}from"./ProjectInfoDialog";import{TestMethodDialog}from"./TestMethodDialog";const labelOf=e=>{const t=e.label&&e.label.length>0?e.label:e.name;return e.units?`${t} [${e.units}]`:t},hasDescription=e=>"string"==typeof e.description&&e.description.length>0,methodLabelOf=(e,t)=>t?.label&&t.label.length>0?t.label:e,PROJECT_ID_RE=/^[A-Za-z0-9_-]+$/,isValidProjectIdFormat=e=>PROJECT_ID_RE.test(e);export const TestSetupForm=({schema:e,defaultProjectId:t,defaultMethodId:s,onProjectChange:i,onMethodChange:o,onValidationChange:a})=>{const r=useTis(),{invoke:n,write:l}=useContext(EventEmitterContext),{rawValues:c,findTagByFqdn:d}=useContext(AutoCoreTagContext),p=useMemo(()=>Object.keys(r.schemas),[r.schemas]),[m,u]=useState(r.selection.projectId||t||""),[f,h]=useState(r.selection.methodId||s||r.defaultMethodId||""),[x,j]=useState(""),[g,_]=useState({}),y=e??(f?r.schemas[f]:void 0);useEffect(()=>{r.selection.projectId!==m&&r.setSelection({projectId:m}),i&&i(m)},[m]),useEffect(()=>{r.selection.methodId!==f&&f&&r.setSelection({methodId:f}),o&&o(f)},[f]),useEffect(()=>{r.selection.sampleId!==x&&r.setSelection({sampleId:x})},[x]),useEffect(()=>{r.state.stagedSampleId&&r.state.stagedSampleId!==x&&j(r.state.stagedSampleId)},[r.state.stagedSampleId]);const[I,v]=useState([]),[b,C]=useState([]),S=useRef(new Set),[T,N]=useState(0),[E,M]=useState(!1),[D,P]=useState({}),k=D[m]??{},[w,F]=useState(!1),[O,R]=useState(!1),[V,q]=useState(!1),A=async()=>{try{const e=await n("tis.list_projects",MessageType.Request,{});e.success&&e.data&&e.data.projects&&v(e.data.projects)}catch(e){}};useEffect(()=>{A()},[n]);const L=useMemo(()=>{const e=new Set(I);for(const t of S.current)e.add(t);return e},[I,T]),$=""!==m.trim()&&L.has(m.trim()),B=isValidProjectIdFormat(m.trim()),H=""!==m.trim()&&B&&!L.has(m.trim());useEffect(()=>{const e=m.trim();if(!e||!$)return;if(void 0!==D[e])return;let t=!1;return(async()=>{try{const s=await n("tis.read_project",MessageType.Request,{project_id:e});if(t)return;if(s?.success){const t=s.data?.project_fields??{};P(s=>({...s,[e]:t}))}}catch(e){}})(),()=>{t=!0}},[m,$]),useEffect(()=>{y&&_(e=>{let t=e;for(const s of y.config_fields){if("sample_id"===s.name)continue;if(!s.source)continue;const i=d(s.source);if(!i)continue;const o=c[i.tagName];null!=o&&(t[s.name]!==o&&(t===e&&(t={...e}),t[s.name]=o))}return t})},[y,c,d]);useEffect(()=>{if(!y)return void M(!1);let e=!0;$||(e=!1),f.trim()||(e=!1),x.trim()||(e=!1);for(const t of y.config_fields)if("sample_id"!==t.name&&t.required){const s=g[t.name];if(void 0===s||""===s||null===s){e=!1;break}}if(e&&$&&void 0===D[m.trim()]&&(e=!1),M(e),a&&a(e,g),e){const{sample_id:e,...t}=g??{},s={...k,...t};n("tis.stage_test",MessageType.Request,{project_id:m,method_id:f,sample_id:x,config:s}).catch(e=>{})}},[g,y,m,f,x,$,D,a,n]);const z=async(e,t)=>{if(_({...g,[e.name]:t}),e.source)try{await l(e.source,t)}catch(e){}},J=(e,t)=>{S.current.add(e),N(e=>e+1),P(s=>({...s,[e]:t})),A(),m.trim()!==e&&u(e)};if(!y)return _jsx("div",{className:"ac-form-grid",style:{padding:"1.25rem"},children:_jsx("h3",{className:"ac-form-section",children:r.schemasLoaded?"No Test Method Selected":"Loading test methods…"})});const Z=$;return _jsxs("div",{className:"ac-form-grid",style:{padding:"1.25rem",gridTemplateColumns:"auto 1fr 1.75rem 1.75rem"},children:[_jsxs("h3",{className:"ac-form-section",style:{display:"flex",alignItems:"center",gap:"10px"},children:["Project & Method",_jsx("span",{style:{color:E?"var(--green-500)":"var(--red-500)"},children:_jsx("i",{className:E?"pi pi-check-circle":"pi pi-exclamation-circle"})})]}),_jsx("span",{className:"ac-form-label",children:"Project ID"}),_jsxs("div",{className:"p-inputgroup",style:{flex:1},children:[_jsx(AutoComplete,{value:m,suggestions:b,completeMethod:e=>{const t=e.query.toLowerCase();C(I.filter(e=>e.toLowerCase().includes(t)))},onChange:e=>(e=>{const t=(e||"").replace(/[^a-zA-Z0-9_-]/g,"");u(t)})(e.value),dropdown:!0,placeholder:"Select an existing Project ID, or type a new one and click +",className:Z?"":"p-invalid",style:{flex:1}}),_jsx(Button,{icon:"pi pi-plus",type:"button",onClick:()=>F(!0),disabled:!H,tooltip:m.trim()?B?L.has(m.trim())?"Project already exists":`Create project "${m.trim()}"`:"Letters, digits, _ and - only":"Type a project ID first",tooltipOptions:{position:"top"}}),_jsx(Button,{icon:"pi pi-pencil",type:"button",onClick:()=>R(!0),disabled:!$,tooltip:$?`Edit information for "${m.trim()}"`:"Select an existing project to edit",tooltipOptions:{position:"top"}})]}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:Z?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:Z?"pi pi-check":"pi pi-times"})}),_jsx("span",{className:"ac-form-label",children:"Sample ID"}),_jsx(TextInput,{label:void 0,value:x,onValueChanged:e=>{j(e)},className:x.trim()?"":"p-invalid"}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:x.trim()?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:x.trim()?"pi pi-check":"pi pi-times"})}),p.length>0&&_jsxs(_Fragment,{children:[_jsx("span",{className:"ac-form-label",children:"Test Method"}),_jsxs("div",{className:"p-inputgroup",style:{flex:1},children:[_jsx(InputText,{value:methodLabelOf(f,y),readOnly:!0,style:{flex:1},tabIndex:-1}),_jsx(Button,{icon:"pi pi-pencil",type:"button",onClick:()=>q(!0),tooltip:p.length>1?"Change test method":"View test method details",tooltipOptions:{position:"top"}})]}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:f?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:f?"pi pi-check":"pi pi-times"})})]}),_jsx("h3",{className:"ac-form-section",style:{marginTop:"1rem"},children:"Test Configuration"}),y.config_fields.map(e=>{if("sample_id"===e.name)return null;const t=(e=>{if(!e.required)return!0;const t=g[e.name];return void 0!==t&&""!==t&&null!==t})(e),s="string"!==e.type&&"bool"!==e.type,i=`acFormInfo_${e.name}`;return _jsxs(React.Fragment,{children:[_jsx("span",{className:"ac-form-label",children:labelOf(e)}),s?_jsx(ValueInput,{label:void 0,value:null!=g[e.name]?Number(g[e.name]):null,onValueChanged:t=>z(e,t),className:t?"":"p-invalid"}):_jsx(TextInput,{label:void 0,value:null!=g[e.name]?String(g[e.name]):"",onValueChanged:t=>z(e,t),className:t?"":"p-invalid"}),hasDescription(e)?_jsxs(_Fragment,{children:[_jsx(Tooltip,{target:`#${i}`,position:"left"}),_jsx("span",{id:i,"data-pr-tooltip":e.description,style:{display:"inline-flex",alignItems:"center",justifyContent:"center",cursor:"help"},children:_jsx("i",{className:"pi pi-info-circle",style:{color:"var(--text-secondary-color)"}})})]}):_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:t?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:t?"pi pi-check":"pi pi-times"})})]},e.name)}),_jsx(ProjectInfoDialog,{visible:w,onHide:()=>F(!1),mode:"create",projectId:m.trim(),projectFields:y.project_fields,onSubmitted:J}),_jsx(ProjectInfoDialog,{visible:O,onHide:()=>R(!1),mode:"edit",projectId:m.trim(),projectFields:y.project_fields,onSubmitted:J}),_jsx(TestMethodDialog,{visible:V,onHide:()=>q(!1),currentMethodId:f,onSelected:e=>h(e)})]})};
1
+ import{jsx as _jsx,Fragment as _Fragment,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useMemo}from"react";import{Button}from"primereact/button";import{InputText}from"primereact/inputtext";import{Tooltip}from"primereact/tooltip";import{EventEmitterContext}from"../../core/EventEmitterContext";import{AutoCoreTagContext}from"../../core/AutoCoreTagContext";import{MessageType}from"../../hub/CommandMessage";import{ValueInput}from"../ValueInput";import{TextInput}from"../TextInput";import{useTis}from"./TisProvider";import{TestMethodDialog}from"./TestMethodDialog";const labelOf=e=>{const t=e.label&&e.label.length>0?e.label:e.name;return e.units?`${t} [${e.units}]`:t},hasDescription=e=>"string"==typeof e.description&&e.description.length>0,methodLabelOf=(e,t)=>t?.label&&t.label.length>0?t.label:e;export const TestSetupForm=({schema:e,defaultMethodId:t,onMethodChange:s,onValidationChange:a})=>{const i=useTis(),{invoke:o,write:n}=useContext(EventEmitterContext),{rawValues:r,findTagByFqdn:l}=useContext(AutoCoreTagContext),c=useMemo(()=>Object.keys(i.schemas),[i.schemas]),d=i.selection.projectId,m=""!==d.trim()&&i.projectKnown(d.trim()),[p,u]=useState(i.selection.methodId||t||i.defaultMethodId||""),[f,h]=useState(""),[x,g]=useState({}),j=e??(p?i.schemas[p]:void 0);useEffect(()=>{i.selection.methodId!==p&&p&&i.setSelection({methodId:p}),s&&s(p)},[p]),useEffect(()=>{i.selection.sampleId!==f&&i.setSelection({sampleId:f})},[f]),useEffect(()=>{i.state.stagedSampleId&&i.state.stagedSampleId!==f&&h(i.state.stagedSampleId)},[i.state.stagedSampleId]),useEffect(()=>{i.selection.methodId&&i.selection.methodId!==p&&u(i.selection.methodId)},[i.selection.methodId]);const[_,v]=useState(!1),[y,I]=useState(!1);useEffect(()=>{j&&g(e=>{let t=e;for(const s of j.config_fields){if("sample_id"===s.name)continue;if(!s.source)continue;const a=l(s.source);if(!a)continue;const i=r[a.tagName];null!=i&&(t[s.name]!==i&&(t===e&&(t={...e}),t[s.name]=i))}return t})},[j,r,l]),useEffect(()=>{if(!j)return void v(!1);let e=!0;m||(e=!1),p.trim()||(e=!1),f.trim()||(e=!1),e&&!i.projectFieldsLoaded&&(e=!1);for(const t of j.config_fields)if("sample_id"!==t.name&&t.required){const s=x[t.name];if(void 0===s||""===s||null===s){e=!1;break}}if(v(e),a&&a(e,x),e){const{sample_id:e,...t}=x??{},s={...i.projectFields,...t};o("tis.stage_test",MessageType.Request,{project_id:d,method_id:p,sample_id:f,config:s}).catch(e=>{})}},[x,j,d,p,f,m,i.projectFields,i.projectFieldsLoaded,a,o]);const T=async(e,t)=>{if(g({...x,[e.name]:t}),e.source)try{await n(e.source,t)}catch(e){}};if(!j)return _jsx("div",{className:"ac-form-grid",style:{padding:"1.25rem"},children:_jsx("h3",{className:"ac-form-section",children:i.schemasLoaded?"No Test Method Selected":"Loading test methods…"})});if(!m)return _jsxs("div",{style:{padding:"1.25rem",maxWidth:"600px"},children:[_jsx("h3",{className:"ac-form-section",children:"No project selected"}),_jsxs("p",{style:{color:"var(--text-secondary-color)",marginTop:"0.5rem"},children:["Pick a project on the ",_jsx("strong",{children:"Project"})," tab first",""!==d.trim()&&` (or click + there to create "${d.trim()}")`,"."]})]});return _jsxs("div",{className:"ac-form-grid",style:{padding:"1.25rem",gridTemplateColumns:"auto 1fr 1.75rem 1.75rem"},children:[_jsxs("h3",{className:"ac-form-section",style:{display:"flex",alignItems:"center",gap:"10px"},children:["Test Setup",_jsx("span",{style:{color:_?"var(--green-500)":"var(--red-500)"},children:_jsx("i",{className:_?"pi pi-check-circle":"pi pi-exclamation-circle"})}),_jsxs("span",{style:{fontSize:"0.85em",color:"var(--text-secondary-color)",fontWeight:"normal",marginLeft:"0.25rem"},children:["project: ",_jsx("strong",{children:d})]})]}),_jsx("span",{className:"ac-form-label",children:"Sample ID"}),_jsx(TextInput,{label:void 0,value:f,onValueChanged:e=>{h(e)},className:f.trim()?"":"p-invalid"}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:f.trim()?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:f.trim()?"pi pi-check":"pi pi-times"})}),c.length>0&&_jsxs(_Fragment,{children:[_jsx("span",{className:"ac-form-label",children:"Test Method"}),_jsxs("div",{className:"p-inputgroup",style:{flex:1},children:[_jsx(InputText,{value:methodLabelOf(p,j),readOnly:!0,style:{flex:1},tabIndex:-1}),_jsx(Button,{icon:"pi pi-pencil",type:"button",onClick:()=>I(!0),tooltip:c.length>1?"Change test method":"View test method details",tooltipOptions:{position:"top"}})]}),_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:p?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:p?"pi pi-check":"pi pi-times"})})]}),_jsx("h3",{className:"ac-form-section",style:{marginTop:"1rem"},children:"Test Configuration"}),j.config_fields.map(e=>{if("sample_id"===e.name)return null;const t=(e=>{if(!e.required)return!0;const t=x[e.name];return void 0!==t&&""!==t&&null!==t})(e),s="string"!==e.type&&"bool"!==e.type,a=`acFormInfo_${e.name}`;return _jsxs(React.Fragment,{children:[_jsx("span",{className:"ac-form-label",children:labelOf(e)}),s?_jsx(ValueInput,{label:void 0,value:null!=x[e.name]?Number(x[e.name]):null,onValueChanged:t=>T(e,t),className:t?"":"p-invalid"}):_jsx(TextInput,{label:void 0,value:null!=x[e.name]?String(x[e.name]):"",onValueChanged:t=>T(e,t),className:t?"":"p-invalid"}),hasDescription(e)?_jsxs(_Fragment,{children:[_jsx(Tooltip,{target:`#${a}`,position:"left"}),_jsx("span",{id:a,"data-pr-tooltip":e.description,style:{display:"inline-flex",alignItems:"center",justifyContent:"center",cursor:"help"},children:_jsx("i",{className:"pi pi-info-circle",style:{color:"var(--text-secondary-color)"}})})]}):_jsx("span",{"aria-hidden":"true"}),_jsx("span",{style:{color:t?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:t?"pi pi-check":"pi pi-times"})})]},e.name)}),_jsx(TestMethodDialog,{visible:y,onHide:()=>I(!1),currentMethodId:p,onSelected:e=>u(e)})]})};
@@ -49,6 +49,34 @@ export interface TisContextValue {
49
49
  state: TisLiveState;
50
50
  selection: TisSelection;
51
51
  setSelection: (patch: TisSelectionPatch) => void;
52
+ /** Project IDs returned by the server's `tis.list_projects`. */
53
+ existingProjects: string[];
54
+ /** True when the project either exists on disk OR was created in
55
+ * this browser session via `<ProjectInfoDialog mode="create">`.
56
+ * This is the gate for staging — typing an unknown name is
57
+ * invalid until + creates the directory. */
58
+ projectKnown: (id: string) => boolean;
59
+ /** Refresh `existingProjects` from the server. Called automatically
60
+ * on `tis.project_created` / `tis.project_updated` broadcasts. */
61
+ refreshProjects: () => Promise<void>;
62
+ /** Add a project ID to the in-session "just created" set so the
63
+ * form is immediately valid for it without round-tripping to
64
+ * list_projects. Idempotent. */
65
+ markProjectJustCreated: (id: string) => void;
66
+ /** `project_fields` blob for the currently-selected project,
67
+ * fetched from project.json. `{}` when nothing is loaded yet
68
+ * (use `projectFieldsLoaded` to disambiguate "empty project" vs
69
+ * "still fetching"). */
70
+ projectFields: Record<string, any>;
71
+ projectFieldsLoaded: boolean;
72
+ /** Fetch and cache project_fields for one project. Returns the
73
+ * fields on success, or null on error. The current selection's
74
+ * fields are also re-loaded automatically when `selection.projectId`
75
+ * changes. */
76
+ loadProjectFields: (id: string) => Promise<Record<string, any> | null>;
77
+ /** Stash freshly-known project_fields without a round trip — used
78
+ * by the create / edit dialogs after a successful submit. */
79
+ setProjectFields: (id: string, fields: Record<string, any>) => void;
52
80
  /** Fetch the run list for a (project, method?) pair. Method may be
53
81
  * omitted to aggregate runs across every method in the project —
54
82
  * the History tab uses this. */
@@ -1 +1 @@
1
- {"version":3,"file":"TisProvider.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TisProvider.tsx"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,EASV,KAAK,SAAS,EACjB,MAAM,OAAO,CAAC;AAQf;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC;AAElC,MAAM,MAAM,cAAc,GAAG;IAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAAA;CAAE,CAAC;AAErE,MAAM,WAAW,YAAY;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IAEvB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACjB;AAED;gDACgD;AAChD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AAEF,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,GAAG,GAAG,IAAI,CAAC;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC;IACd,OAAO,EAAE,GAAG,CAAC;IACb,OAAO,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC5B,OAAO,EAAE,cAAc,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;IAEvB,KAAK,EAAE,YAAY,CAAC;IAEpB,SAAS,EAAE,YAAY,CAAC;IACxB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAEjD;;qCAEiC;IACjC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACpE;uEACmE;IACnE,QAAQ,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACnG,QAAQ,EAAE;QAAE,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAAA;KAAE,CAAC;CACnD;AAeD,QAAA,MAAM,UAAU,gCAUd,CAAC;AAmCH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAmLlD,CAAC;AAMF,eAAO,MAAM,MAAM,uBAAyC,CAAC;AAC7D,eAAO,MAAM,aAAa,sBAA0C,CAAC;AACrE,eAAO,MAAM,WAAW,oBAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,wCA5QF,iBAAiB,KAAK,IAAI,CA+QnD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,YAAY,MAAM,EAAE,WAAW,MAAM;;;;CAY/D,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM;;;;;;;;CAuBvC,CAAC;AAIF,OAAO,EAAE,UAAU,EAAE,CAAC"}
1
+ {"version":3,"file":"TisProvider.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TisProvider.tsx"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,EASV,KAAK,SAAS,EACjB,MAAM,OAAO,CAAC;AAQf;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC;AAElC,MAAM,MAAM,cAAc,GAAG;IAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAAA;CAAE,CAAC;AAErE,MAAM,WAAW,YAAY;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IAEvB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACjB;AAED;gDACgD;AAChD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AAEF,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,GAAG,GAAG,IAAI,CAAC;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC;IACd,OAAO,EAAE,GAAG,CAAC;IACb,OAAO,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC5B,OAAO,EAAE,cAAc,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;IAEvB,KAAK,EAAE,YAAY,CAAC;IAEpB,SAAS,EAAE,YAAY,CAAC;IACxB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAWjD,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B;;;iDAG6C;IAC7C,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACtC;uEACmE;IACnE,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC;;qCAEiC;IACjC,sBAAsB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAE7C;;;6BAGyB;IACzB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC,mBAAmB,EAAE,OAAO,CAAC;IAC7B;;;mBAGe;IACf,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;IACvE;kEAC8D;IAC9D,gBAAgB,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,CAAC;IAEpE;;qCAEiC;IACjC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACpE;uEACmE;IACnE,QAAQ,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACnG,QAAQ,EAAE;QAAE,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAAA;KAAE,CAAC;CACnD;AAeD,QAAA,MAAM,UAAU,gCAkBd,CAAC;AAmCH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAuRlD,CAAC;AAMF,eAAO,MAAM,MAAM,uBAAyC,CAAC;AAC7D,eAAO,MAAM,aAAa,sBAA0C,CAAC;AACrE,eAAO,MAAM,WAAW,oBAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,wCA/ZF,iBAAiB,KAAK,IAAI,CAkanD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,YAAY,MAAM,EAAE,WAAW,MAAM;;;;CAY/D,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM;;;;;;;;CAuBvC,CAAC;AAIF,OAAO,EAAE,UAAU,EAAE,CAAC"}
@@ -1 +1 @@
1
- import{jsx as _jsx}from"react/jsx-runtime";import React,{createContext,useCallback,useContext,useEffect,useMemo,useReducer,useRef,useState}from"react";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";const EMPTY_STATE={staged:!1,stagedProjectId:"",stagedMethodId:"",stagedSampleId:"",active:!1,activeProjectId:"",activeMethodId:"",activeSampleId:"",activeRunId:""},EMPTY_SELECTION={projectId:"",methodId:"",sampleId:"",runId:""},TisContext=createContext({schemas:{},defaultMethodId:"",schemasLoaded:!1,state:EMPTY_STATE,selection:EMPTY_SELECTION,setSelection:()=>{},fetchRuns:async()=>[],fetchRun:async()=>null,runCache:{}});function liveReducer(e,t){switch(t.kind){case"staged":return{...e,staged:t.value};case"staged_project_id":return{...e,stagedProjectId:t.value};case"staged_method_id":return{...e,stagedMethodId:t.value};case"staged_sample_id":return{...e,stagedSampleId:t.value};case"active":return{...e,active:t.value};case"active_project_id":return{...e,activeProjectId:t.value};case"active_method_id":return{...e,activeMethodId:t.value};case"active_sample_id":return{...e,activeSampleId:t.value};case"active_run_id":return{...e,activeRunId:t.value}}}export const TisProvider=({children:e,defaultMethodId:t})=>{const{invoke:s,subscribe:a,unsubscribe:c}=useContext(EventEmitterContext),[d,r]=useState({}),[u,n]=useState(t??""),[i,o]=useState(!1),[l,m]=useReducer(liveReducer,EMPTY_STATE),[_,v]=useState({projectId:null,methodId:null,sampleId:null,runId:null});useEffect(()=>{let e=!1;return(async()=>{try{const a=await s("tis.list_schemas",MessageType.Request,{});if(e)return;if(a?.success&&a.data){const e=a.data.test_methods??{},s=a.data.default_method_id??"";r(e),!t&&s&&n(s),o(!0)}}catch(e){}})(),()=>{e=!0}},[]),useEffect(()=>{const e=[a("tis.staged",e=>m({kind:"staged",value:!!e})),a("tis.staged_project_id",e=>m({kind:"staged_project_id",value:String(e??"")})),a("tis.staged_method_id",e=>m({kind:"staged_method_id",value:String(e??"")})),a("tis.staged_sample_id",e=>m({kind:"staged_sample_id",value:String(e??"")})),a("tis.active",e=>m({kind:"active",value:!!e})),a("tis.active_project_id",e=>m({kind:"active_project_id",value:String(e??"")})),a("tis.active_method_id",e=>m({kind:"active_method_id",value:String(e??"")})),a("tis.active_sample_id",e=>m({kind:"active_sample_id",value:String(e??"")})),a("tis.active_run_id",e=>m({kind:"active_run_id",value:String(e??"")}))];return()=>{e.forEach(c)}},[a,c]);const I=useRef({}),[p,h]=useState(0),f=useCallback(()=>h(e=>e+1),[]),C=useCallback((e,t)=>{if(!e)return;const s=I.current[e]??{meta:null,cycles:[],results:{},rawData:{}};I.current[e]={...s,cycles:[...s.cycles,t]},f()},[f]),g=useCallback((e,t)=>{if(!e)return;const s=I.current[e]??{meta:null,cycles:[],results:{},rawData:{}};I.current[e]={...s,results:t},f()},[f]);useEffect(()=>{const e=a("tis.cycle_added",e=>{e?.run_id&&e.cycle&&C(e.run_id,e.cycle)}),t=a("tis.results_updated",e=>{e?.run_id&&g(e.run_id,e.results??{})});return()=>{c(e),c(t)}},[a,c,C,g]);const x=useMemo(()=>({projectId:_.projectId??l.activeProjectId,methodId:_.methodId??(l.activeMethodId||u),sampleId:_.sampleId??l.activeSampleId,runId:_.runId??l.activeRunId}),[_,l,u]),T=useCallback(e=>{v(t=>({projectId:void 0===e.projectId?t.projectId:e.projectId,methodId:void 0===e.methodId?t.methodId:e.methodId,sampleId:void 0===e.sampleId?t.sampleId:e.sampleId,runId:void 0===e.runId?t.runId:e.runId}))},[]),S=useCallback(async(e,t)=>{if(!e)return[];const a={project_id:e};t&&(a.method_id=t);try{const e=await s("tis.list_tests",MessageType.Request,a);if(e?.success&&e.data?.tests)return e.data.tests}catch(e){}return[]},[s]),y=useCallback(async(e,t,a)=>{if(!e||!t||!a)return null;try{const c=await s("tis.read_test",MessageType.Request,{project_id:e,method_id:t,run_id:a}),d=await s("tis.read_cycles",MessageType.Request,{project_id:e,method_id:t,run_id:a,offset:0,limit:1e3,order:"asc"});if(!c?.success)return null;const r={meta:c.data??null,cycles:d?.success?d.data?.cycles??[]:[],results:c.data?.results??{},rawData:I.current[a]?.rawData??{}};return I.current[a]=r,f(),r}catch(e){return null}},[s,f]),j=useMemo(()=>({...I.current}),[p]),E=useMemo(()=>({schemas:d,defaultMethodId:u,schemasLoaded:i,state:l,selection:x,setSelection:T,fetchRuns:S,fetchRun:y,runCache:j}),[d,u,i,l,x,T,S,y,j]);return _jsx(TisContext.Provider,{value:E,children:e})};export const useTis=()=>useContext(TisContext);export const useTisSchemas=()=>useContext(TisContext).schemas;export const useTisState=()=>useContext(TisContext).state;export const useTisSelection=()=>{const{selection:e,setSelection:t}=useContext(TisContext);return[e,t]};export const useTisRuns=(e,t)=>{const{fetchRuns:s}=useContext(TisContext),[a,c]=useState([]),[d,r]=useState(!1),u=useCallback(async()=>{if(e){r(!0);try{c(await s(e,t))}finally{r(!1)}}else c([])},[e,t,s]);return useEffect(()=>{u()},[u]),{runs:a,loading:d,refresh:u}};export const useTisRun=e=>{const{selection:t,fetchRun:s,runCache:a}=useContext(TisContext),[c,d]=useState(!1),r=e??t.runId;useEffect(()=>{if(!r)return;if(a[r]?.meta)return;const e=t.projectId,c=t.methodId;e&&c&&(d(!0),s(e,c,r).finally(()=>d(!1)))},[r,t.projectId,t.methodId,s,a]);const u=r?a[r]:null;return{meta:u?.meta??null,cycles:u?.cycles??[],results:u?.results??{},rawData:u?.rawData??{},loading:c}};export{TisContext};
1
+ import{jsx as _jsx}from"react/jsx-runtime";import React,{createContext,useCallback,useContext,useEffect,useMemo,useReducer,useRef,useState}from"react";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";const EMPTY_STATE={staged:!1,stagedProjectId:"",stagedMethodId:"",stagedSampleId:"",active:!1,activeProjectId:"",activeMethodId:"",activeSampleId:"",activeRunId:""},EMPTY_SELECTION={projectId:"",methodId:"",sampleId:"",runId:""},TisContext=createContext({schemas:{},defaultMethodId:"",schemasLoaded:!1,state:EMPTY_STATE,selection:EMPTY_SELECTION,setSelection:()=>{},existingProjects:[],projectKnown:()=>!1,refreshProjects:async()=>{},markProjectJustCreated:()=>{},projectFields:{},projectFieldsLoaded:!1,loadProjectFields:async()=>null,setProjectFields:()=>{},fetchRuns:async()=>[],fetchRun:async()=>null,runCache:{}});function liveReducer(e,t){switch(t.kind){case"staged":return{...e,staged:t.value};case"staged_project_id":return{...e,stagedProjectId:t.value};case"staged_method_id":return{...e,stagedMethodId:t.value};case"staged_sample_id":return{...e,stagedSampleId:t.value};case"active":return{...e,active:t.value};case"active_project_id":return{...e,activeProjectId:t.value};case"active_method_id":return{...e,activeMethodId:t.value};case"active_sample_id":return{...e,activeSampleId:t.value};case"active_run_id":return{...e,activeRunId:t.value}}}export const TisProvider=({children:e,defaultMethodId:t})=>{const{invoke:s,subscribe:a,unsubscribe:c}=useContext(EventEmitterContext),[r,d]=useState({}),[u,n]=useState(t??""),[i,o]=useState(!1),[l,p]=useReducer(liveReducer,EMPTY_STATE),[_,m]=useState({projectId:null,methodId:null,sampleId:null,runId:null});useEffect(()=>{let e=!1;return(async()=>{try{const a=await s("tis.list_schemas",MessageType.Request,{});if(e)return;if(a?.success&&a.data){const e=a.data.test_methods??{},s=a.data.default_method_id??"";d(e),!t&&s&&n(s),o(!0)}}catch(e){}})(),()=>{e=!0}},[]),useEffect(()=>{const e=[a("tis.staged",e=>p({kind:"staged",value:!!e})),a("tis.staged_project_id",e=>p({kind:"staged_project_id",value:String(e??"")})),a("tis.staged_method_id",e=>p({kind:"staged_method_id",value:String(e??"")})),a("tis.staged_sample_id",e=>p({kind:"staged_sample_id",value:String(e??"")})),a("tis.active",e=>p({kind:"active",value:!!e})),a("tis.active_project_id",e=>p({kind:"active_project_id",value:String(e??"")})),a("tis.active_method_id",e=>p({kind:"active_method_id",value:String(e??"")})),a("tis.active_sample_id",e=>p({kind:"active_sample_id",value:String(e??"")})),a("tis.active_run_id",e=>p({kind:"active_run_id",value:String(e??"")}))];return()=>{e.forEach(c)}},[a,c]);const I=useRef({}),[v,h]=useState(0),f=useCallback(()=>h(e=>e+1),[]),j=useCallback((e,t)=>{if(!e)return;const s=I.current[e]??{meta:null,cycles:[],results:{},rawData:{}};I.current[e]={...s,cycles:[...s.cycles,t]},f()},[f]),C=useCallback((e,t)=>{if(!e)return;const s=I.current[e]??{meta:null,cycles:[],results:{},rawData:{}};I.current[e]={...s,results:t},f()},[f]);useEffect(()=>{const e=a("tis.cycle_added",e=>{e?.run_id&&e.cycle&&j(e.run_id,e.cycle)}),t=a("tis.results_updated",e=>{e?.run_id&&C(e.run_id,e.results??{})});return()=>{c(e),c(t)}},[a,c,j,C]);const g=useMemo(()=>({projectId:_.projectId??l.activeProjectId,methodId:_.methodId??(l.activeMethodId||u),sampleId:_.sampleId??l.activeSampleId,runId:_.runId??l.activeRunId}),[_,l,u]),y=useCallback(e=>{m(t=>({projectId:void 0===e.projectId?t.projectId:e.projectId,methodId:void 0===e.methodId?t.methodId:e.methodId,sampleId:void 0===e.sampleId?t.sampleId:e.sampleId,runId:void 0===e.runId?t.runId:e.runId}))},[]),x=useCallback(async(e,t)=>{if(!e)return[];const a={project_id:e};t&&(a.method_id=t);try{const e=await s("tis.list_tests",MessageType.Request,a);if(e?.success&&e.data?.tests)return e.data.tests}catch(e){}return[]},[s]),S=useCallback(async(e,t,a)=>{if(!e||!t||!a)return null;try{const c=await s("tis.read_test",MessageType.Request,{project_id:e,method_id:t,run_id:a}),r=await s("tis.read_cycles",MessageType.Request,{project_id:e,method_id:t,run_id:a,offset:0,limit:1e3,order:"asc"});if(!c?.success)return null;const d={meta:c.data??null,cycles:r?.success?r.data?.cycles??[]:[],results:c.data?.results??{},rawData:I.current[a]?.rawData??{}};return I.current[a]=d,f(),d}catch(e){return null}},[s,f]),T=useMemo(()=>({...I.current}),[v]),[E,k]=useState([]),M=useRef(new Set),[R,P]=useState(0),[b,w]=useState({}),F=useCallback(async()=>{try{const e=await s("tis.list_projects",MessageType.Request,{});e?.success&&e.data?.projects&&k(e.data.projects)}catch(e){}},[s]),q=useCallback(e=>{e&&(M.current.has(e)||(M.current.add(e),P(e=>e+1)))},[]),D=useCallback(e=>!!e&&(!!M.current.has(e)||E.includes(e)),[E,R]),L=useCallback((e,t)=>{e&&w(s=>({...s,[e]:t}))},[]),Y=useCallback(async e=>{if(!e)return null;try{const t=await s("tis.read_project",MessageType.Request,{project_id:e});if(t?.success){const s=t.data?.project_fields??{};return w(t=>({...t,[e]:s})),s}}catch(e){}return null},[s]);useEffect(()=>{F()},[F]),useEffect(()=>{const e=a("tis.project_created",()=>{F()}),t=a("tis.project_updated",e=>{const t="string"==typeof e?.project_id?e.project_id:"";t&&Y(t)});return()=>{c(e),c(t)}},[a,c,F,Y]),useEffect(()=>{const e=g.projectId;e&&D(e)&&void 0===b[e]&&Y(e)},[g.projectId,D,b,Y]);const A=b[g.projectId]??{},J=void 0!==b[g.projectId],K=useMemo(()=>({schemas:r,defaultMethodId:u,schemasLoaded:i,state:l,selection:g,setSelection:y,existingProjects:E,projectKnown:D,refreshProjects:F,markProjectJustCreated:q,projectFields:A,projectFieldsLoaded:J,loadProjectFields:Y,setProjectFields:L,fetchRuns:x,fetchRun:S,runCache:T}),[r,u,i,l,g,y,E,D,F,q,A,J,Y,L,x,S,T]);return _jsx(TisContext.Provider,{value:K,children:e})};export const useTis=()=>useContext(TisContext);export const useTisSchemas=()=>useContext(TisContext).schemas;export const useTisState=()=>useContext(TisContext).state;export const useTisSelection=()=>{const{selection:e,setSelection:t}=useContext(TisContext);return[e,t]};export const useTisRuns=(e,t)=>{const{fetchRuns:s}=useContext(TisContext),[a,c]=useState([]),[r,d]=useState(!1),u=useCallback(async()=>{if(e){d(!0);try{c(await s(e,t))}finally{d(!1)}}else c([])},[e,t,s]);return useEffect(()=>{u()},[u]),{runs:a,loading:r,refresh:u}};export const useTisRun=e=>{const{selection:t,fetchRun:s,runCache:a}=useContext(TisContext),[c,r]=useState(!1),d=e??t.runId;useEffect(()=>{if(!d)return;if(a[d]?.meta)return;const e=t.projectId,c=t.methodId;e&&c&&(r(!0),s(e,c,d).finally(()=>r(!1)))},[d,t.projectId,t.methodId,s,a]);const u=d?a[d]:null;return{meta:u?.meta??null,cycles:u?.cycles??[],results:u?.results??{},rawData:u?.rawData??{},loading:c}};export{TisContext};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcops/autocore-react",
3
- "version": "3.3.59",
3
+ "version": "3.3.61",
4
4
  "description": "A React component library for industrial user interfaces.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,9 @@ export type {
27
27
  TisMethodSchema,
28
28
  } from './tis/TisProvider';
29
29
 
30
+ export { ProjectSelector } from './tis/ProjectSelector';
31
+ export type { ProjectSelectorProps } from './tis/ProjectSelector';
32
+
30
33
  export { TestSetupForm } from './tis/TestSetupForm';
31
34
  export type { TestSetupFormProps } from './tis/TestSetupForm';
32
35
 
@@ -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
+ };
@@ -1,6 +1,4 @@
1
- import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
2
- import { AutoComplete } from 'primereact/autocomplete';
3
- import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
1
+ import React, { useState, useEffect, useContext, useMemo } from 'react';
4
2
  import { Button } from 'primereact/button';
5
3
  import { InputText } from 'primereact/inputtext';
6
4
  import { Tooltip } from 'primereact/tooltip';
@@ -10,7 +8,6 @@ import { MessageType } from '../../hub/CommandMessage';
10
8
  import { ValueInput } from '../ValueInput';
11
9
  import { TextInput } from '../TextInput';
12
10
  import { useTis } from './TisProvider';
13
- import { ProjectInfoDialog } from './ProjectInfoDialog';
14
11
  import { TestMethodDialog } from './TestMethodDialog';
15
12
 
16
13
  export interface TestFieldDef {
@@ -41,14 +38,18 @@ export interface TestMethod {
41
38
  }
42
39
 
43
40
  /**
44
- * Props are all optional overrides by default the form drives itself
45
- * from the surrounding `<TisProvider>`.
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).
46
+ *
47
+ * All props are optional overrides — by default the form drives
48
+ * itself from the surrounding `<TisProvider>`.
46
49
  */
47
50
  export interface TestSetupFormProps {
48
51
  schema?: TestMethod;
49
- defaultProjectId?: string;
50
52
  defaultMethodId?: string;
51
- onProjectChange?: (projectId: string) => void;
52
53
  onMethodChange?: (methodId: string) => void;
53
54
  onValidationChange?: (isValid: boolean, config: any) => void;
54
55
  }
@@ -65,25 +66,14 @@ const labelOf = (f: TestFieldDef): string => {
65
66
  const hasDescription = (f: TestFieldDef): boolean =>
66
67
  typeof f.description === 'string' && f.description.length > 0;
67
68
 
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
69
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
72
70
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
73
71
 
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
72
  // -------------------------------------------------------------------------
81
73
 
82
74
  export const TestSetupForm: React.FC<TestSetupFormProps> = ({
83
75
  schema: schemaOverride,
84
- defaultProjectId,
85
76
  defaultMethodId,
86
- onProjectChange,
87
77
  onMethodChange,
88
78
  onValidationChange,
89
79
  }) => {
@@ -93,9 +83,12 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
93
83
 
94
84
  const methodIds = useMemo(() => Object.keys(tis.schemas), [tis.schemas]);
95
85
 
96
- const [projectId, setProjectIdLocal] = useState<string>(
97
- tis.selection.projectId || defaultProjectId || ''
98
- );
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
+
99
92
  const [methodId, setMethodIdLocal] = useState<string>(
100
93
  tis.selection.methodId || defaultMethodId || tis.defaultMethodId || ''
101
94
  );
@@ -104,14 +97,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
104
97
 
105
98
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
106
99
 
107
- useEffect(() => {
108
- if (tis.selection.projectId !== projectId) {
109
- tis.setSelection({ projectId });
110
- }
111
- if (onProjectChange) onProjectChange(projectId);
112
- // eslint-disable-next-line react-hooks/exhaustive-deps
113
- }, [projectId]);
114
-
115
100
  useEffect(() => {
116
101
  if (tis.selection.methodId !== methodId && methodId) {
117
102
  tis.setSelection({ methodId });
@@ -134,84 +119,18 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
134
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
120
  }, [tis.state.stagedSampleId]);
136
121
 
137
- const [existingProjects, setExistingProjects] = useState<string[]>([]);
138
- const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
139
- const justCreatedRef = useRef<Set<string>>(new Set());
140
- const [justCreatedTick, setJustCreatedTick] = useState(0);
141
- const [isValid, setIsValid] = useState(false);
142
-
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
-
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.
168
125
  useEffect(() => {
169
- fetchProjects();
126
+ if (tis.selection.methodId && tis.selection.methodId !== methodId) {
127
+ setMethodIdLocal(tis.selection.methodId);
128
+ }
170
129
  // eslint-disable-next-line react-hooks/exhaustive-deps
171
- }, [invoke]);
130
+ }, [tis.selection.methodId]);
172
131
 
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]);
132
+ const [isValid, setIsValid] = useState(false);
133
+ const [methodPickerOpen, setMethodPickerOpen] = useState(false);
215
134
 
216
135
  // Seed and live-update config_fields that declare a `source`.
217
136
  useEffect(() => {
@@ -233,22 +152,18 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
233
152
  });
234
153
  }, [schema, rawValues, findTagByFqdn]);
235
154
 
236
- const searchProjects = (event: AutoCompleteCompleteEvent) => {
237
- const query = event.query.toLowerCase();
238
- setFilteredProjects(existingProjects.filter(p => p.toLowerCase().includes(query)));
239
- };
240
-
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.
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.
246
160
  useEffect(() => {
247
161
  if (!schema) { setIsValid(false); return; }
248
162
  let valid = true;
249
163
  if (!projectExists) valid = false;
250
164
  if (!methodId.trim()) valid = false;
251
165
  if (!sampleId.trim()) valid = false;
166
+ if (valid && !tis.projectFieldsLoaded) valid = false;
252
167
 
253
168
  for (const field of schema.config_fields) {
254
169
  if (field.name === 'sample_id') continue;
@@ -258,28 +173,17 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
258
173
  }
259
174
  }
260
175
 
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
-
271
176
  setIsValid(valid);
272
177
  if (onValidationChange) onValidationChange(valid, config);
273
178
 
274
179
  if (valid) {
275
180
  const { sample_id: _drop, ...configRest } = (config ?? {}) as any;
276
181
  // 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.
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.
281
185
  const mergedConfig = {
282
- ...projectFieldsForCurrent,
186
+ ...tis.projectFields,
283
187
  ...configRest,
284
188
  };
285
189
  void invoke('tis.stage_test' as any, MessageType.Request, {
@@ -290,7 +194,11 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
290
194
  } as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
291
195
  }
292
196
  // eslint-disable-next-line react-hooks/exhaustive-deps
293
- }, [config, schema, projectId, methodId, sampleId, projectExists, projectFieldsCache, onValidationChange, invoke]);
197
+ }, [
198
+ config, schema, projectId, methodId, sampleId,
199
+ projectExists, tis.projectFields, tis.projectFieldsLoaded,
200
+ onValidationChange, invoke,
201
+ ]);
294
202
 
295
203
  const isFieldValid = (field: TestFieldDef) => {
296
204
  if (!field.required) return true;
@@ -298,11 +206,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
298
206
  return v !== undefined && v !== '' && v !== null;
299
207
  };
300
208
 
301
- const handleProjectIdChange = (value: string | null | undefined) => {
302
- const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
303
- setProjectIdLocal(sanitized);
304
- };
305
-
306
209
  const handleSampleIdChange = (value: string) => {
307
210
  setSampleIdLocal(value);
308
211
  };
@@ -315,26 +218,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
315
218
  }
316
219
  };
317
220
 
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
221
  const renderConfigField = (field: TestFieldDef) => {
339
222
  if (field.name === 'sample_id') return null;
340
223
  const valid = isFieldValid(field);
@@ -389,81 +272,46 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
389
272
  );
390
273
  }
391
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
+
392
293
  const gridStyle: React.CSSProperties = {
393
294
  padding: '1.25rem',
394
295
  gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
395
296
  };
396
297
 
397
- const projectRowValid = projectExists;
398
-
399
298
  return (
400
299
  <div className="ac-form-grid" style={gridStyle}>
401
300
  <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
402
- Project &amp; Method
301
+ Test Setup
403
302
  <span style={{ color: isValid ? 'var(--green-500)' : 'var(--red-500)' }}>
404
303
  <i className={isValid ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'} />
405
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>
406
313
  </h3>
407
314
 
408
- <span className="ac-form-label">Project ID</span>
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'} />
465
- </span>
466
-
467
315
  <span className="ac-form-label">Sample ID</span>
468
316
  <TextInput
469
317
  label={undefined}
@@ -476,18 +324,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
476
324
  <i className={sampleId.trim() ? 'pi pi-check' : 'pi pi-times'} />
477
325
  </span>
478
326
 
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
327
  {methodIds.length > 0 && (
492
328
  <>
493
329
  <span className="ac-form-label">Test Method</span>
@@ -518,29 +354,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
518
354
  <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
519
355
  {schema.config_fields.map(renderConfigField)}
520
356
 
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
357
  <TestMethodDialog
545
358
  visible={methodPickerOpen}
546
359
  onHide={() => setMethodPickerOpen(false)}
@@ -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
  };