@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.
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ProjectSelector.d.ts +15 -0
- package/dist/components/tis/ProjectSelector.d.ts.map +1 -0
- package/dist/components/tis/ProjectSelector.js +1 -0
- package/dist/components/tis/TestSetupForm.d.ts +8 -4
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +28 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +3 -0
- package/src/components/tis/ProjectSelector.tsx +190 -0
- package/src/components/tis/TestSetupForm.tsx +66 -253
- package/src/components/tis/TisProvider.tsx +148 -1
|
@@ -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"}
|
package/dist/components/index.js
CHANGED
|
@@ -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
|
-
*
|
|
29
|
-
*
|
|
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,
|
|
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
|
|
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;
|
|
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
|
|
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
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
130
|
+
}, [tis.selection.methodId]);
|
|
172
131
|
|
|
173
|
-
const
|
|
174
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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
|
-
...
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
494
|
+
]);
|
|
348
495
|
|
|
349
496
|
return <TisContext.Provider value={value}>{children}</TisContext.Provider>;
|
|
350
497
|
};
|