@adcops/autocore-react 3.3.57 → 3.3.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,10 @@ export { TisProvider, TisContext, useTis, useTisSchemas, useTisState, useTisSele
2
2
  export type { TisProviderProps, TisContextValue, TisLiveState, TisSelection, TisSelectionPatch, TisRunCacheEntry, SchemaRegistry, TisMethodSchema, } from './tis/TisProvider';
3
3
  export { TestSetupForm } from './tis/TestSetupForm';
4
4
  export type { TestSetupFormProps } from './tis/TestSetupForm';
5
+ export { ProjectInfoDialog } from './tis/ProjectInfoDialog';
6
+ export type { ProjectInfoDialogProps, ProjectInfoMode } from './tis/ProjectInfoDialog';
7
+ export { TestMethodDialog } from './tis/TestMethodDialog';
8
+ export type { TestMethodDialogProps } from './tis/TestMethodDialog';
5
9
  export { ResultHistoryTable } from './tis/ResultHistoryTable';
6
10
  export type { ResultHistoryTableProps } from './tis/ResultHistoryTable';
7
11
  export { TestDataView } from './tis/TestDataView';
@@ -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,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,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{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{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,21 @@
1
+ import React from 'react';
2
+ import type { TestFieldDef } from './TestSetupForm';
3
+ export type ProjectInfoMode = 'create' | 'edit';
4
+ export interface ProjectInfoDialogProps {
5
+ visible: boolean;
6
+ onHide: () => void;
7
+ mode: ProjectInfoMode;
8
+ /** Project ID being created (`create` mode) or edited (`edit`). */
9
+ projectId: string;
10
+ /** Schema field defs for `project_fields` (from the selected method). */
11
+ projectFields: TestFieldDef[];
12
+ /**
13
+ * Called after a successful create/update with the values that
14
+ * landed on the server. Used by the parent form to refresh its
15
+ * known-projects list and to fold the new fields into subsequent
16
+ * `tis.stage_test` payloads without an extra round-trip.
17
+ */
18
+ onSubmitted: (projectId: string, projectFields: Record<string, any>) => void;
19
+ }
20
+ export declare const ProjectInfoDialog: React.FC<ProjectInfoDialogProps>;
21
+ //# sourceMappingURL=ProjectInfoDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectInfoDialog.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ProjectInfoDialog.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAA2D,MAAM,OAAO,CAAC;AAShF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEhD,MAAM,WAAW,sBAAsB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,IAAI,EAAE,eAAe,CAAC;IACtB,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B;;;;;OAKG;IACH,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,CAAC;CAChF;AAUD,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA+O9D,CAAC"}
@@ -0,0 +1 @@
1
+ import{jsx as _jsx,Fragment as _Fragment,jsxs as _jsxs}from"react/jsx-runtime";import React,{useContext,useEffect,useMemo,useRef,useState}from"react";import{Button}from"primereact/button";import{Dialog}from"primereact/dialog";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";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;export const ProjectInfoDialog=({visible:e,onHide:t,mode:r,projectId:o,projectFields:n,onSubmitted:s})=>{const{invoke:i,write:a}=useContext(EventEmitterContext),{rawValues:c,findTagByFqdn:l}=useContext(AutoCoreTagContext),[m,p]=useState({}),[u,d]=useState(!1),[f,j]=useState(null),x=useRef("");useEffect(()=>{if(!e)return;const t=`${r}:${o}`;if(x.current===t)return;x.current=t;let s=!1;j(null);const a={};for(const e of n){if(!e.source)continue;const t=l(e.source);if(!t)continue;const r=c[t.tagName];null!=r&&(a[e.name]=r)}return"create"===r?s||p(a):(async()=>{try{const e=await i("tis.read_project",MessageType.Request,{project_id:o});if(s)return;if(e?.success){const t={...e.data?.project_fields??{}};for(const e of n)e.source&&void 0!==a[e.name]&&(t[e.name]=a[e.name]);p(t)}else j(e?.error_message??"Failed to read project")}catch(e){s||j(String(e instanceof Error?e.message:e))}})(),()=>{s=!0}},[e,o,r]),useEffect(()=>{e||(x.current="")},[e]);const g=(e,t)=>{p(r=>({...r,[e.name]:t}))},h=useMemo(()=>{for(const e of n){if(!e.required)continue;const t=m[e.name];if(null==t||""===t)return!1}return!0},[n,m]),_=_jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:"0.5rem"},children:[_jsx(Button,{label:"Cancel",icon:"pi pi-times",onClick:t,disabled:u,text:!0}),_jsx(Button,{label:"create"===r?"Create Project":"Save",icon:u?"pi pi-spin pi-spinner":"create"===r?"pi pi-plus":"pi pi-check",onClick:async()=>{if(h&&!u){d(!0);try{for(const e of n){if(!e.source)continue;const t=m[e.name];if(null!=t)try{await a(e.source,t)}catch(e){}}const e={};for(const t of n)void 0!==m[t.name]&&(e[t.name]=m[t.name]);const c="create"===r?"tis.create_project":"tis.update_project",l=await i(c,MessageType.Request,{project_id:o,project_fields:e});l?.success?(s(o,e),t()):alert(`Failed: ${l?.error_message??"unknown error"}`)}catch(e){alert(`Failed: ${e instanceof Error?e.message:String(e)}`)}finally{d(!1)}}},disabled:!h||u})]});return _jsxs(Dialog,{header:"create"===r?`Create project: ${o||"(no ID)"}`:`Edit project information: ${o}`,visible:e,onHide:t,footer:_,modal:!0,style:{width:"min(640px, 90vw)"},closable:!u,children:[f&&_jsx("div",{style:{color:"var(--red-500)",marginBottom:"1rem"},children:f}),0===n.length?_jsx("p",{style:{color:"var(--text-secondary-color)"},children:"create"===r?`Click "Create Project" to create the empty project "${o}". This method declares no project_fields, so there's nothing to fill in.`:"This method declares no project_fields, so there's nothing to edit."}):_jsx("div",{className:"ac-form-grid",style:{padding:"0.25rem 0",gridTemplateColumns:"auto 1fr 1.75rem 1.75rem"},children:n.map(e=>{const t=(e=>{if(!e.required)return!0;const t=m[e.name];return null!=t&&""!==t})(e),r="string"!==e.type&&"bool"!==e.type,o=`acProjInfo_${e.name}`;return _jsxs(React.Fragment,{children:[_jsx("span",{className:"ac-form-label",children:labelOf(e)}),r?_jsx(ValueInput,{label:void 0,value:null!=m[e.name]?Number(m[e.name]):null,onValueChanged:t=>g(e,t),className:t?"":"p-invalid"}):_jsx(TextInput,{label:void 0,value:null!=m[e.name]?String(m[e.name]):"",onValueChanged:t=>g(e,t),className:t?"":"p-invalid"}),hasDescription(e)?_jsxs(_Fragment,{children:[_jsx(Tooltip,{target:`#${o}`,position:"left"}),_jsx("span",{id:o,"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)})})]})};
@@ -39,6 +39,10 @@ export interface TestMethod {
39
39
  views?: {
40
40
  [name: string]: ChartView;
41
41
  };
42
+ /** Optional pretty label for the Test Method picker. */
43
+ label?: string;
44
+ /** Optional long-form description for the picker. */
45
+ description?: string;
42
46
  }
43
47
  export interface TestDataViewProps {
44
48
  /** Optional override; defaults to `useTisSelection().projectId`. */
@@ -1 +1 @@
1
- {"version":3,"file":"TestDataView.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestDataView.tsx"],"names":[],"mappings":"AAUA,OAAO,KAA2D,MAAM,OAAO,CAAC;AA6BhF,MAAM,WAAW,YAAY;IACzB,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;CACnB;AAED,MAAM,WAAW,SAAS;IAAI,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CAAE;AAChF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CAAE;AAC5G,MAAM,WAAW,SAAS;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,eAAe,GAAG,WAAW,CAAC;IACpC,CAAC,EAAE,SAAS,CAAC;IACb,CAAC,EAAE,WAAW,EAAE,CAAC;CACpB;AACD,MAAM,WAAW,YAAY;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACrC;AACD,MAAM,WAAW,UAAU;IACvB,cAAc,EAAG,YAAY,EAAE,CAAC;IAChC,aAAa,EAAI,YAAY,EAAE,CAAC;IAChC,YAAY,EAAK,YAAY,EAAE,CAAC;IAChC,cAAc,EAAG,YAAY,EAAE,CAAC;IAChC,QAAQ,CAAC,EAAQ,YAAY,GAAG,IAAI,CAAC;IACrC,KAAK,CAAC,EAAW;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAC;CAClD;AAED,MAAM,WAAW,iBAAiB;IAC9B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,CAAC,EAAG,MAAM,CAAC;IACnB,gEAAgE;IAChE,KAAK,CAAC,EAAM,MAAM,CAAC;IACnB,kEAAkE;IAClE,MAAM,CAAC,EAAK,UAAU,CAAC;IACvB,8EAA8E;IAC9E,UAAU,CAAC,EAAG,MAAM,CAAC;IACrB,wDAAwD;IACxD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAgPpD,CAAC"}
1
+ {"version":3,"file":"TestDataView.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestDataView.tsx"],"names":[],"mappings":"AAUA,OAAO,KAA2D,MAAM,OAAO,CAAC;AA6BhF,MAAM,WAAW,YAAY;IACzB,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;CACnB;AAED,MAAM,WAAW,SAAS;IAAI,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CAAE;AAChF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CAAE;AAC5G,MAAM,WAAW,SAAS;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,eAAe,GAAG,WAAW,CAAC;IACpC,CAAC,EAAE,SAAS,CAAC;IACb,CAAC,EAAE,WAAW,EAAE,CAAC;CACpB;AACD,MAAM,WAAW,YAAY;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACrC;AACD,MAAM,WAAW,UAAU;IACvB,cAAc,EAAG,YAAY,EAAE,CAAC;IAChC,aAAa,EAAI,YAAY,EAAE,CAAC;IAChC,YAAY,EAAK,YAAY,EAAE,CAAC;IAChC,cAAc,EAAG,YAAY,EAAE,CAAC;IAChC,QAAQ,CAAC,EAAQ,YAAY,GAAG,IAAI,CAAC;IACrC,KAAK,CAAC,EAAW;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAC;IAC/C,wDAAwD;IACxD,KAAK,CAAC,EAAW,MAAM,CAAC;IACxB,qDAAqD;IACrD,WAAW,CAAC,EAAK,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAC9B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,CAAC,EAAG,MAAM,CAAC;IACnB,gEAAgE;IAChE,KAAK,CAAC,EAAM,MAAM,CAAC;IACnB,kEAAkE;IAClE,MAAM,CAAC,EAAK,UAAU,CAAC;IACvB,8EAA8E;IAC9E,UAAU,CAAC,EAAG,MAAM,CAAC;IACrB,wDAAwD;IACxD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAgPpD,CAAC"}
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ export interface TestMethodDialogProps {
3
+ visible: boolean;
4
+ onHide: () => void;
5
+ /** Method ID currently selected on the form. The dropdown opens
6
+ * pointing at this value so the dialog reflects current state. */
7
+ currentMethodId: string;
8
+ /**
9
+ * Called with the chosen method_id when the operator clicks OK.
10
+ * Cancel does not fire this callback. The parent is responsible
11
+ * for actually applying the new selection (e.g., updating the
12
+ * provider's selection or local state).
13
+ */
14
+ onSelected: (methodId: string) => void;
15
+ }
16
+ export declare const TestMethodDialog: React.FC<TestMethodDialogProps>;
17
+ //# sourceMappingURL=TestMethodDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TestMethodDialog.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestMethodDialog.tsx"],"names":[],"mappings":"AAiBA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAO5D,MAAM,WAAW,qBAAqB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB;uEACmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1C;AAOD,eAAO,MAAM,gBAAgB,EAAE,KAAK,CAAC,EAAE,CAAC,qBAAqB,CAsG5D,CAAC"}
@@ -0,0 +1 @@
1
+ import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";import React,{useEffect,useMemo,useState}from"react";import{Button}from"primereact/button";import{Dialog}from"primereact/dialog";import{Dropdown}from"primereact/dropdown";import{useTisSchemas}from"./TisProvider";const methodLabelOf=(e,t)=>t?.label&&t.label.length>0?t.label:e;export const TestMethodDialog=({visible:e,onHide:t,currentMethodId:o,onSelected:r})=>{const s=useTisSchemas(),[l,i]=useState(o);useEffect(()=>{e&&i(o)},[e,o]);const d=useMemo(()=>Object.keys(s).map(e=>({label:methodLabelOf(e,s[e]),value:e})),[s]),a=s[l],n=a?.description&&a.description.length>0?a.description:null,c=_jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:"0.5rem"},children:[_jsx(Button,{label:"Cancel",icon:"pi pi-times",onClick:t,text:!0}),_jsx(Button,{label:"OK",icon:"pi pi-check",onClick:()=>{l&&l!==o&&r(l),t()},disabled:!l})]});return _jsx(Dialog,{header:"Select Test Method",visible:e,onHide:t,footer:c,modal:!0,style:{width:"min(560px, 90vw)"},children:0===d.length?_jsxs("p",{style:{color:"var(--text-secondary-color)"},children:["No test methods are declared in this project's ",_jsx("code",{children:"test_methods"})," block."]}):_jsxs("div",{style:{display:"flex",flexDirection:"column",gap:"1rem"},children:[_jsxs("div",{style:{display:"flex",alignItems:"center",gap:"0.75rem"},children:[_jsx("label",{htmlFor:"acTestMethodDropdown",style:{flexShrink:0},children:"Test Method:"}),_jsx(Dropdown,{inputId:"acTestMethodDropdown",value:l,options:d,onChange:e=>i(e.value),placeholder:"Select a method",style:{flex:1}})]}),_jsx("div",{style:{padding:"0.75rem 1rem",background:"var(--surface-100)",borderRadius:"6px",minHeight:"4.5rem",color:n?"var(--text-color)":"var(--text-secondary-color)",fontStyle:n?"normal":"italic",whiteSpace:"pre-wrap"},children:n??"No description provided for this test method."})]})})};
@@ -1,27 +1,32 @@
1
1
  import React from 'react';
2
2
  export interface TestFieldDef {
3
+ /** Canonical key — wire format, generated code, on-disk JSON. */
3
4
  name: string;
4
5
  type: string;
5
6
  units?: string;
6
7
  required?: boolean;
7
8
  source?: string;
9
+ /** Pretty label rendered by the form. Falls back to `name`. Units
10
+ * are appended automatically; don't pre-format `[mm]` into label. */
11
+ label?: string;
12
+ /** Long-form guidance surfaced as a hover tooltip on an info icon. */
13
+ description?: string;
8
14
  }
9
15
  export interface TestMethod {
10
16
  project_fields: TestFieldDef[];
11
17
  config_fields: TestFieldDef[];
12
18
  cycle_fields: TestFieldDef[];
13
19
  results_fields: TestFieldDef[];
20
+ /** Optional pretty label for the Test Method picker. Falls back
21
+ * to the canonical method_id key. */
22
+ label?: string;
23
+ /** Optional long-form description shown in the picker dialog
24
+ * when this method is highlighted. */
25
+ description?: string;
14
26
  }
15
27
  /**
16
28
  * Props are all optional overrides — by default the form drives itself
17
- * from the surrounding `<TisProvider>`. Pass any of these to lock that
18
- * particular axis.
19
- *
20
- * - `schema`: bypass `useTisSchemas()` for the selected method.
21
- * - `defaultProjectId` / `defaultMethodId`: seed the form's local
22
- * state when the corresponding TIS-context fields are blank.
23
- * - `onProjectChange` / `onMethodChange` / `onValidationChange`:
24
- * receive callbacks in addition to the provider's selection state.
29
+ * from the surrounding `<TisProvider>`.
25
30
  */
26
31
  export interface TestSetupFormProps {
27
32
  schema?: TestMethod;
@@ -1 +1 @@
1
- {"version":3,"file":"TestSetupForm.d.ts","sourceRoot":"","sources":["../../../src/components/tis/TestSetupForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAWxE,MAAM,WAAW,YAAY;IACzB,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;CACnB;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;CAClC;AAED;;;;;;;;;;GAUG;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;AAED,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA2QtD,CAAC"}
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 +1 @@
1
- import{jsx as _jsx,jsxs as _jsxs,Fragment as _Fragment}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useMemo}from"react";import{AutoComplete}from"primereact/autocomplete";import{SelectButton}from"primereact/selectbutton";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";export const TestSetupForm=({schema:e,defaultProjectId:t,defaultMethodId:s,onProjectChange:a,onMethodChange:n,onValidationChange:o})=>{const r=useTis(),{invoke:i,write:c}=useContext(EventEmitterContext),{rawValues:l,findTagByFqdn:m}=useContext(AutoCoreTagContext),d=useMemo(()=>Object.keys(r.schemas),[r.schemas]),[p,u]=useState(r.selection.projectId||t||""),[f,h]=useState(r.selection.methodId||s||r.defaultMethodId||""),[x,g]=useState(""),[j,_]=useState({}),v=e??(f?r.schemas[f]:void 0);useEffect(()=>{r.selection.projectId!==p&&r.setSelection({projectId:p}),a&&a(p)},[p]),useEffect(()=>{r.selection.methodId!==f&&f&&r.setSelection({methodId:f}),n&&n(f)},[f]),useEffect(()=>{r.selection.sampleId!==x&&r.setSelection({sampleId:x})},[x]),useEffect(()=>{r.state.stagedSampleId&&r.state.stagedSampleId!==x&&g(r.state.stagedSampleId)},[r.state.stagedSampleId]);const[I,C]=useState([]),[y,N]=useState([]),[S,T]=useState(!1);useEffect(()=>{if(!v)return;const e=[...v.project_fields,...v.config_fields];_(t=>{let s=t;for(const a of e){if("sample_id"===a.name)continue;if(!a.source)continue;const e=m(a.source);if(!e)continue;const n=l[e.tagName];null!=n&&(s[a.name]!==n&&(s===t&&(s={...t}),s[a.name]=n))}return s})},[v,l,m]),useEffect(()=>{(async()=>{try{const e=await i("tis.list_projects",MessageType.Request,{});e.success&&e.data&&e.data.projects&&C(e.data.projects)}catch(e){}})()},[i]);useEffect(()=>{if(!v)return void T(!1);let e=!0;p.trim()||(e=!1),f.trim()||(e=!1),x.trim()||(e=!1);const t=[...v.project_fields,...v.config_fields];for(const s of t)if("sample_id"!==s.name&&s.required){const t=j[s.name];if(void 0===t||""===t||null===t){e=!1;break}}if(T(e),o&&o(e,j),e){const{sample_id:e,...t}=j??{};i("tis.stage_test",MessageType.Request,{project_id:p,method_id:f,sample_id:x,config:t}).catch(e=>{})}},[j,v,p,f,x,o,i]);const E=async(e,t)=>{if(_({...j,[e.name]:t}),e.source)try{await c(e.source,t)}catch(e){}},b=e=>{if("sample_id"===e.name)return null;const t=(e=>{if(!e.required)return!0;const t=j[e.name];return void 0!==t&&""!==t&&null!==t})(e),s="string"!==e.type&&"bool"!==e.type;return _jsxs(React.Fragment,{children:[_jsx("span",{className:"ac-form-label",children:e.name}),s?_jsx(ValueInput,{label:void 0,value:null!=j[e.name]?Number(j[e.name]):null,onValueChanged:t=>E(e,t),className:t?"":"p-invalid"}):_jsx(TextInput,{label:void 0,value:null!=j[e.name]?String(j[e.name]):"",onValueChanged:t=>E(e,t),className:t?"":"p-invalid"}),_jsx("span",{className:"ac-form-units",children:e.units??""}),_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)};return v?_jsxs("div",{className:"ac-form-grid",style:{padding:"1.25rem"},children:[_jsxs("h3",{className:"ac-form-section",style:{display:"flex",alignItems:"center",gap:"10px"},children:["Project & Method",_jsx("span",{style:{color:S?"var(--green-500)":"var(--red-500)"},children:_jsx("i",{className:S?"pi pi-check-circle":"pi pi-exclamation-circle"})})]}),_jsx("span",{className:"ac-form-label",children:"Project ID"}),_jsx(AutoComplete,{value:p,suggestions:y,completeMethod:e=>{const t=e.query.toLowerCase();N(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:"Enter or select Project ID",className:p.trim()?"":"p-invalid"}),_jsx("span",{}),_jsx("span",{style:{color:p.trim()?"var(--green-500)":"var(--red-500)",display:"flex",alignItems:"center"},children:_jsx("i",{className:p.trim()?"pi pi-check":"pi pi-times"})}),_jsx("span",{className:"ac-form-label",children:"Sample ID"}),_jsx(TextInput,{label:void 0,value:x,onValueChanged:e=>{g(e)},className:x.trim()?"":"p-invalid"}),_jsx("span",{}),_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"})}),d.length>1&&_jsxs(_Fragment,{children:[_jsx("span",{className:"ac-form-label",children:"Test Method"}),_jsx(SelectButton,{value:f,options:d.map(e=>({label:e,value:e})),onChange:e=>e.value&&h(e.value)}),_jsx("span",{}),_jsx("span",{})]}),_jsx("h3",{className:"ac-form-section",style:{marginTop:"1rem"},children:"Project Information"}),v.project_fields.map(b),_jsx("h3",{className:"ac-form-section",style:{marginTop:"1rem"},children:"Test Configuration"}),v.config_fields.map(b)]}):_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…"})})};
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)})]})};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcops/autocore-react",
3
- "version": "3.3.57",
3
+ "version": "3.3.59",
4
4
  "description": "A React component library for industrial user interfaces.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -30,6 +30,12 @@ export type {
30
30
  export { TestSetupForm } from './tis/TestSetupForm';
31
31
  export type { TestSetupFormProps } from './tis/TestSetupForm';
32
32
 
33
+ export { ProjectInfoDialog } from './tis/ProjectInfoDialog';
34
+ export type { ProjectInfoDialogProps, ProjectInfoMode } from './tis/ProjectInfoDialog';
35
+
36
+ export { TestMethodDialog } from './tis/TestMethodDialog';
37
+ export type { TestMethodDialogProps } from './tis/TestMethodDialog';
38
+
33
39
  export { ResultHistoryTable } from './tis/ResultHistoryTable';
34
40
  export type { ResultHistoryTableProps } from './tis/ResultHistoryTable';
35
41
 
@@ -0,0 +1,307 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <ProjectInfoDialog> — operator-facing setup for a project's
5
+ * `project_fields`. Used in two modes:
6
+ *
7
+ * - `create`: opened by the `+` button on <TestSetupForm>. The
8
+ * project doesn't exist yet; on submit we call `tis.create_project`
9
+ * with the user-entered fields baked into the request payload, so
10
+ * the directory and project.json materialise atomically. The form
11
+ * pre-fills source-bound fields from live GM values via the
12
+ * surrounding AutoCoreTagProvider so the operator isn't typing
13
+ * things the rig already knows.
14
+ *
15
+ * - `edit`: opened by the pencil button on <TestSetupForm>. The
16
+ * project already exists; we fetch project.json via
17
+ * `tis.read_project` and pre-fill from it, with source-bound
18
+ * fields preferring the live GM value over the persisted one (so
19
+ * "edit" reflects current reality). On submit we
20
+ * `tis.update_project` and `write()` source-bound fields back to
21
+ * GM in the same pass.
22
+ *
23
+ * The dialog never stages or starts a test — it only manages
24
+ * project-level metadata. The future per-user permission gate that
25
+ * locks "create/edit project" away from the operator role hangs off
26
+ * the visibility of the two buttons in <TestSetupForm>; this dialog
27
+ * is agnostic to who opened it.
28
+ */
29
+
30
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
31
+ import { Button } from 'primereact/button';
32
+ import { Dialog } from 'primereact/dialog';
33
+ import { Tooltip } from 'primereact/tooltip';
34
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
35
+ import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
36
+ import { MessageType } from '../../hub/CommandMessage';
37
+ import { ValueInput } from '../ValueInput';
38
+ import { TextInput } from '../TextInput';
39
+ import type { TestFieldDef } from './TestSetupForm';
40
+
41
+ export type ProjectInfoMode = 'create' | 'edit';
42
+
43
+ export interface ProjectInfoDialogProps {
44
+ visible: boolean;
45
+ onHide: () => void;
46
+ mode: ProjectInfoMode;
47
+ /** Project ID being created (`create` mode) or edited (`edit`). */
48
+ projectId: string;
49
+ /** Schema field defs for `project_fields` (from the selected method). */
50
+ projectFields: TestFieldDef[];
51
+ /**
52
+ * Called after a successful create/update with the values that
53
+ * landed on the server. Used by the parent form to refresh its
54
+ * known-projects list and to fold the new fields into subsequent
55
+ * `tis.stage_test` payloads without an extra round-trip.
56
+ */
57
+ onSubmitted: (projectId: string, projectFields: Record<string, any>) => void;
58
+ }
59
+
60
+ const labelOf = (f: TestFieldDef): string => {
61
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
62
+ return f.units ? `${base} [${f.units}]` : base;
63
+ };
64
+
65
+ const hasDescription = (f: TestFieldDef): boolean =>
66
+ typeof f.description === 'string' && f.description.length > 0;
67
+
68
+ export const ProjectInfoDialog: React.FC<ProjectInfoDialogProps> = ({
69
+ visible, onHide, mode, projectId, projectFields, onSubmitted,
70
+ }) => {
71
+ const { invoke, write } = useContext(EventEmitterContext);
72
+ const { rawValues, findTagByFqdn } = useContext(AutoCoreTagContext);
73
+
74
+ const [values, setValues] = useState<Record<string, any>>({});
75
+ const [submitting, setSubmitting] = useState(false);
76
+ const [loadError, setLoadError] = useState<string | null>(null);
77
+ const lastLoadedIdRef = useRef<string>('');
78
+
79
+ // Whenever the dialog opens (or the target project changes while
80
+ // open), reset state and re-load. We DON'T do this in a single
81
+ // useEffect on `[visible, projectId, mode]` because the load is
82
+ // async and we want to drop stale results.
83
+ useEffect(() => {
84
+ if (!visible) return;
85
+ const loadKey = `${mode}:${projectId}`;
86
+ if (lastLoadedIdRef.current === loadKey) return;
87
+ lastLoadedIdRef.current = loadKey;
88
+
89
+ let cancelled = false;
90
+ setLoadError(null);
91
+
92
+ // Build the initial value map. For source-bound fields, seed
93
+ // from live GM via the AutoCoreTagProvider's rawValues. For
94
+ // non-source fields the seed is empty in `create` mode and
95
+ // gets overwritten by the persisted value in `edit` mode.
96
+ const seed: Record<string, any> = {};
97
+ for (const f of projectFields) {
98
+ if (!f.source) continue;
99
+ const tag = findTagByFqdn(f.source);
100
+ if (!tag) continue;
101
+ const v = rawValues[tag.tagName];
102
+ if (v !== undefined && v !== null) seed[f.name] = v;
103
+ }
104
+
105
+ if (mode === 'create') {
106
+ if (!cancelled) setValues(seed);
107
+ } else {
108
+ (async () => {
109
+ try {
110
+ const resp: any = await invoke(
111
+ 'tis.read_project' as any, MessageType.Request,
112
+ { project_id: projectId } as any,
113
+ );
114
+ if (cancelled) return;
115
+ if (resp?.success) {
116
+ const persisted = (resp.data?.project_fields ?? {}) as Record<string, any>;
117
+ // Seed (source-bound from GM) + persisted +
118
+ // GM wins on conflicts for source fields,
119
+ // because GM is the live source of truth.
120
+ const merged: Record<string, any> = { ...persisted };
121
+ for (const f of projectFields) {
122
+ if (f.source && seed[f.name] !== undefined) {
123
+ merged[f.name] = seed[f.name];
124
+ }
125
+ }
126
+ setValues(merged);
127
+ } else {
128
+ setLoadError(resp?.error_message ?? 'Failed to read project');
129
+ }
130
+ } catch (e) {
131
+ if (!cancelled) setLoadError(String(e instanceof Error ? e.message : e));
132
+ }
133
+ })();
134
+ }
135
+ return () => { cancelled = true; };
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [visible, projectId, mode]);
138
+
139
+ // When the dialog closes, clear the loaded-marker so a subsequent
140
+ // open re-fetches fresh state.
141
+ useEffect(() => {
142
+ if (!visible) lastLoadedIdRef.current = '';
143
+ }, [visible]);
144
+
145
+ const handleFieldChange = (field: TestFieldDef, val: any) => {
146
+ setValues(prev => ({ ...prev, [field.name]: val }));
147
+ };
148
+
149
+ // All required project_fields must be non-empty before we let the
150
+ // operator submit. Source-bound fields are validated the same way
151
+ // — they should already have a value from GM, but the dialog is
152
+ // honest about it if they don't.
153
+ const isValid = useMemo(() => {
154
+ for (const f of projectFields) {
155
+ if (!f.required) continue;
156
+ const v = values[f.name];
157
+ if (v === undefined || v === null || v === '') return false;
158
+ }
159
+ return true;
160
+ }, [projectFields, values]);
161
+
162
+ const handleSubmit = async () => {
163
+ if (!isValid || submitting) return;
164
+ setSubmitting(true);
165
+ try {
166
+ // For source-bound fields, push the value into GM in the
167
+ // same pass. We do this whether `create` or `edit` because
168
+ // the persisted project.json copy is just a snapshot —
169
+ // GM is the live source of truth and we want the rig to
170
+ // see the operator's changes immediately.
171
+ for (const f of projectFields) {
172
+ if (!f.source) continue;
173
+ const v = values[f.name];
174
+ if (v === undefined || v === null) continue;
175
+ try { await write(f.source, v); }
176
+ catch (e) { console.warn(`[ProjectInfoDialog] write to ${f.source} failed:`, e); }
177
+ }
178
+
179
+ // The persisted blob carries every project_field we
180
+ // present, including source-bound ones; this lets the
181
+ // form fold them into stage_test even if the live tag
182
+ // hasn't broadcast a value yet at staging time.
183
+ const projectFieldsPayload: Record<string, any> = {};
184
+ for (const f of projectFields) {
185
+ if (values[f.name] !== undefined) projectFieldsPayload[f.name] = values[f.name];
186
+ }
187
+
188
+ const topic = mode === 'create' ? 'tis.create_project' : 'tis.update_project';
189
+ const resp: any = await invoke(topic as any, MessageType.Request, {
190
+ project_id: projectId,
191
+ project_fields: projectFieldsPayload,
192
+ } as any);
193
+ if (resp?.success) {
194
+ onSubmitted(projectId, projectFieldsPayload);
195
+ onHide();
196
+ } else {
197
+ alert(`Failed: ${resp?.error_message ?? 'unknown error'}`);
198
+ }
199
+ } catch (e) {
200
+ alert(`Failed: ${e instanceof Error ? e.message : String(e)}`);
201
+ } finally {
202
+ setSubmitting(false);
203
+ }
204
+ };
205
+
206
+ const isFieldValid = (f: TestFieldDef) => {
207
+ if (!f.required) return true;
208
+ const v = values[f.name];
209
+ return v !== undefined && v !== null && v !== '';
210
+ };
211
+
212
+ const renderField = (field: TestFieldDef) => {
213
+ const valid = isFieldValid(field);
214
+ const isNum = field.type !== 'string' && field.type !== 'bool';
215
+ const tooltipId = `acProjInfo_${field.name}`;
216
+ return (
217
+ <React.Fragment key={field.name}>
218
+ <span className="ac-form-label">{labelOf(field)}</span>
219
+ {isNum ? (
220
+ <ValueInput
221
+ label={undefined}
222
+ value={values[field.name] != null ? Number(values[field.name]) : null}
223
+ onValueChanged={(val) => handleFieldChange(field, val)}
224
+ className={!valid ? 'p-invalid' : ''}
225
+ />
226
+ ) : (
227
+ <TextInput
228
+ label={undefined}
229
+ value={values[field.name] != null ? String(values[field.name]) : ''}
230
+ onValueChanged={(val) => handleFieldChange(field, val)}
231
+ className={!valid ? 'p-invalid' : ''}
232
+ />
233
+ )}
234
+ {hasDescription(field) ? (
235
+ <>
236
+ <Tooltip target={`#${tooltipId}`} position="left" />
237
+ <span
238
+ id={tooltipId}
239
+ data-pr-tooltip={field.description}
240
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
241
+ >
242
+ <i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
243
+ </span>
244
+ </>
245
+ ) : (
246
+ <span aria-hidden="true" />
247
+ )}
248
+ <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
249
+ <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
250
+ </span>
251
+ </React.Fragment>
252
+ );
253
+ };
254
+
255
+ const footer = (
256
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
257
+ <Button label="Cancel" icon="pi pi-times" onClick={onHide} disabled={submitting} text />
258
+ <Button
259
+ label={mode === 'create' ? 'Create Project' : 'Save'}
260
+ icon={submitting ? 'pi pi-spin pi-spinner' : (mode === 'create' ? 'pi pi-plus' : 'pi pi-check')}
261
+ onClick={handleSubmit}
262
+ disabled={!isValid || submitting}
263
+ />
264
+ </div>
265
+ );
266
+
267
+ const header = mode === 'create'
268
+ ? `Create project: ${projectId || '(no ID)'}`
269
+ : `Edit project information: ${projectId}`;
270
+
271
+ return (
272
+ <Dialog
273
+ header={header}
274
+ visible={visible}
275
+ onHide={onHide}
276
+ footer={footer}
277
+ modal
278
+ style={{ width: 'min(640px, 90vw)' }}
279
+ // PrimeReact's `closable` X — same as Cancel.
280
+ closable={!submitting}
281
+ >
282
+ {loadError && (
283
+ <div style={{ color: 'var(--red-500)', marginBottom: '1rem' }}>
284
+ {loadError}
285
+ </div>
286
+ )}
287
+ {projectFields.length === 0 ? (
288
+ <p style={{ color: 'var(--text-secondary-color)' }}>
289
+ {mode === 'create'
290
+ ? `Click "Create Project" to create the empty project "${projectId}". `
291
+ + `This method declares no project_fields, so there's nothing to fill in.`
292
+ : `This method declares no project_fields, so there's nothing to edit.`}
293
+ </p>
294
+ ) : (
295
+ <div
296
+ className="ac-form-grid"
297
+ style={{
298
+ padding: '0.25rem 0',
299
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
300
+ }}
301
+ >
302
+ {projectFields.map(renderField)}
303
+ </div>
304
+ )}
305
+ </Dialog>
306
+ );
307
+ };
@@ -65,6 +65,10 @@ export interface TestMethod {
65
65
  results_fields: TestFieldDef[];
66
66
  raw_data?: RawDataShape | null;
67
67
  views?: { [name: string]: ChartView };
68
+ /** Optional pretty label for the Test Method picker. */
69
+ label?: string;
70
+ /** Optional long-form description for the picker. */
71
+ description?: string;
68
72
  }
69
73
 
70
74
  export interface TestDataViewProps {
@@ -0,0 +1,147 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <TestMethodDialog> — picker UI for swapping the active test method.
5
+ *
6
+ * The main form displays a single "Test Method: <label>" row with an
7
+ * edit button. Clicking edit opens this dialog. The dialog shows a
8
+ * dropdown of every method declared in the current project's
9
+ * `test_methods` block, plus the long-form description for whichever
10
+ * method the operator has the dropdown highlighted on. OK commits the
11
+ * choice via the supplied callback; Cancel discards.
12
+ *
13
+ * This pattern scales past three or four methods where a SelectButton
14
+ * starts wrapping or eating horizontal space, and gives every method
15
+ * room to ship a description that disambiguates similar names.
16
+ */
17
+
18
+ import React, { useEffect, useMemo, useState } from 'react';
19
+ import { Button } from 'primereact/button';
20
+ import { Dialog } from 'primereact/dialog';
21
+ import { Dropdown } from 'primereact/dropdown';
22
+ import type { TestMethod } from './TestSetupForm';
23
+ import { useTisSchemas } from './TisProvider';
24
+
25
+ export interface TestMethodDialogProps {
26
+ visible: boolean;
27
+ onHide: () => void;
28
+ /** Method ID currently selected on the form. The dropdown opens
29
+ * pointing at this value so the dialog reflects current state. */
30
+ currentMethodId: string;
31
+ /**
32
+ * Called with the chosen method_id when the operator clicks OK.
33
+ * Cancel does not fire this callback. The parent is responsible
34
+ * for actually applying the new selection (e.g., updating the
35
+ * provider's selection or local state).
36
+ */
37
+ onSelected: (methodId: string) => void;
38
+ }
39
+
40
+ /** Display name for one method: prefer the schema's `label`, fall
41
+ * back to the canonical `method_id` key. */
42
+ const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
43
+ (schema?.label && schema.label.length > 0) ? schema.label : methodId;
44
+
45
+ export const TestMethodDialog: React.FC<TestMethodDialogProps> = ({
46
+ visible, onHide, currentMethodId, onSelected,
47
+ }) => {
48
+ const schemas = useTisSchemas();
49
+
50
+ // Local "draft" selection — the dropdown writes to this; OK
51
+ // applies it. We deliberately don't update the provider's
52
+ // selection on every dropdown change, so a Cancel really cancels.
53
+ const [draftMethodId, setDraftMethodId] = useState<string>(currentMethodId);
54
+
55
+ // Re-sync the draft whenever the dialog opens so a stale value
56
+ // from a previous open doesn't ghost the current selection.
57
+ useEffect(() => {
58
+ if (visible) setDraftMethodId(currentMethodId);
59
+ }, [visible, currentMethodId]);
60
+
61
+ const options = useMemo(() => {
62
+ return Object.keys(schemas).map(methodId => ({
63
+ label: methodLabelOf(methodId, schemas[methodId]),
64
+ value: methodId,
65
+ }));
66
+ }, [schemas]);
67
+
68
+ const draftSchema = schemas[draftMethodId];
69
+ const draftDescription =
70
+ (draftSchema?.description && draftSchema.description.length > 0)
71
+ ? draftSchema.description
72
+ : null;
73
+
74
+ const handleOk = () => {
75
+ if (draftMethodId && draftMethodId !== currentMethodId) {
76
+ onSelected(draftMethodId);
77
+ }
78
+ onHide();
79
+ };
80
+
81
+ const footer = (
82
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
83
+ <Button label="Cancel" icon="pi pi-times" onClick={onHide} text />
84
+ <Button
85
+ label="OK"
86
+ icon="pi pi-check"
87
+ onClick={handleOk}
88
+ disabled={!draftMethodId}
89
+ />
90
+ </div>
91
+ );
92
+
93
+ return (
94
+ <Dialog
95
+ header="Select Test Method"
96
+ visible={visible}
97
+ onHide={onHide}
98
+ footer={footer}
99
+ modal
100
+ style={{ width: 'min(560px, 90vw)' }}
101
+ >
102
+ {options.length === 0 ? (
103
+ <p style={{ color: 'var(--text-secondary-color)' }}>
104
+ No test methods are declared in this project's <code>test_methods</code> block.
105
+ </p>
106
+ ) : (
107
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
108
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
109
+ <label htmlFor="acTestMethodDropdown" style={{ flexShrink: 0 }}>
110
+ Test Method:
111
+ </label>
112
+ <Dropdown
113
+ inputId="acTestMethodDropdown"
114
+ value={draftMethodId}
115
+ options={options}
116
+ onChange={(e) => setDraftMethodId(e.value)}
117
+ placeholder="Select a method"
118
+ style={{ flex: 1 }}
119
+ />
120
+ </div>
121
+ {/*
122
+ * Show the description region whenever we have a
123
+ * draft selection — keeps layout stable even when
124
+ * the operator picks a method without one (we just
125
+ * render a muted placeholder rather than collapsing
126
+ * the dialog by ~3rem).
127
+ */}
128
+ <div
129
+ style={{
130
+ padding: '0.75rem 1rem',
131
+ background: 'var(--surface-100)',
132
+ borderRadius: '6px',
133
+ minHeight: '4.5rem',
134
+ color: draftDescription
135
+ ? 'var(--text-color)'
136
+ : 'var(--text-secondary-color)',
137
+ fontStyle: draftDescription ? 'normal' : 'italic',
138
+ whiteSpace: 'pre-wrap',
139
+ }}
140
+ >
141
+ {draftDescription ?? 'No description provided for this test method.'}
142
+ </div>
143
+ </div>
144
+ )}
145
+ </Dialog>
146
+ );
147
+ };
@@ -1,20 +1,30 @@
1
- import React, { useState, useEffect, useContext, useMemo } from 'react';
1
+ import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
2
2
  import { AutoComplete } from 'primereact/autocomplete';
3
3
  import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
4
- import { SelectButton } from 'primereact/selectbutton';
4
+ import { Button } from 'primereact/button';
5
+ import { InputText } from 'primereact/inputtext';
6
+ import { Tooltip } from 'primereact/tooltip';
5
7
  import { EventEmitterContext } from '../../core/EventEmitterContext';
6
8
  import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
7
9
  import { MessageType } from '../../hub/CommandMessage';
8
10
  import { ValueInput } from '../ValueInput';
9
11
  import { TextInput } from '../TextInput';
10
12
  import { useTis } from './TisProvider';
13
+ import { ProjectInfoDialog } from './ProjectInfoDialog';
14
+ import { TestMethodDialog } from './TestMethodDialog';
11
15
 
12
16
  export interface TestFieldDef {
17
+ /** Canonical key — wire format, generated code, on-disk JSON. */
13
18
  name: string;
14
19
  type: string;
15
20
  units?: string;
16
21
  required?: boolean;
17
22
  source?: string;
23
+ /** Pretty label rendered by the form. Falls back to `name`. Units
24
+ * are appended automatically; don't pre-format `[mm]` into label. */
25
+ label?: string;
26
+ /** Long-form guidance surfaced as a hover tooltip on an info icon. */
27
+ description?: string;
18
28
  }
19
29
 
20
30
  export interface TestMethod {
@@ -22,18 +32,17 @@ export interface TestMethod {
22
32
  config_fields: TestFieldDef[];
23
33
  cycle_fields: TestFieldDef[];
24
34
  results_fields: TestFieldDef[];
35
+ /** Optional pretty label for the Test Method picker. Falls back
36
+ * to the canonical method_id key. */
37
+ label?: string;
38
+ /** Optional long-form description shown in the picker dialog
39
+ * when this method is highlighted. */
40
+ description?: string;
25
41
  }
26
42
 
27
43
  /**
28
44
  * Props are all optional overrides — by default the form drives itself
29
- * from the surrounding `<TisProvider>`. Pass any of these to lock that
30
- * particular axis.
31
- *
32
- * - `schema`: bypass `useTisSchemas()` for the selected method.
33
- * - `defaultProjectId` / `defaultMethodId`: seed the form's local
34
- * state when the corresponding TIS-context fields are blank.
35
- * - `onProjectChange` / `onMethodChange` / `onValidationChange`:
36
- * receive callbacks in addition to the provider's selection state.
45
+ * from the surrounding `<TisProvider>`.
37
46
  */
38
47
  export interface TestSetupFormProps {
39
48
  schema?: TestMethod;
@@ -44,6 +53,32 @@ export interface TestSetupFormProps {
44
53
  onValidationChange?: (isValid: boolean, config: any) => void;
45
54
  }
46
55
 
56
+ // -------------------------------------------------------------------------
57
+ // Helpers
58
+ // -------------------------------------------------------------------------
59
+
60
+ const labelOf = (f: TestFieldDef): string => {
61
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
62
+ return f.units ? `${base} [${f.units}]` : base;
63
+ };
64
+
65
+ const hasDescription = (f: TestFieldDef): boolean =>
66
+ typeof f.description === 'string' && f.description.length > 0;
67
+
68
+ /** Display name for one method: prefer schema's `label`, fall back
69
+ * to the canonical method_id. Mirrors the helper in TestMethodDialog
70
+ * so the row label stays in sync with what the dialog shows. */
71
+ const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
72
+ (schema?.label && schema.label.length > 0) ? schema.label : methodId;
73
+
74
+ // Project IDs follow the same character class as the server's
75
+ // `tis.create_project` validator. Keep these in sync — see
76
+ // `src/tis_servelet.rs::create_project`.
77
+ const PROJECT_ID_RE = /^[A-Za-z0-9_-]+$/;
78
+ const isValidProjectIdFormat = (id: string) => PROJECT_ID_RE.test(id);
79
+
80
+ // -------------------------------------------------------------------------
81
+
47
82
  export const TestSetupForm: React.FC<TestSetupFormProps> = ({
48
83
  schema: schemaOverride,
49
84
  defaultProjectId,
@@ -58,9 +93,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
58
93
 
59
94
  const methodIds = useMemo(() => Object.keys(tis.schemas), [tis.schemas]);
60
95
 
61
- // Seed the form's local project/method state from the provider's
62
- // selection. Direct edits update the provider via setSelection so
63
- // the History tab and Data tab follow along.
64
96
  const [projectId, setProjectIdLocal] = useState<string>(
65
97
  tis.selection.projectId || defaultProjectId || ''
66
98
  );
@@ -70,13 +102,8 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
70
102
  const [sampleId, setSampleIdLocal] = useState<string>('');
71
103
  const [config, setConfig] = useState<any>({});
72
104
 
73
- // Resolve the schema for the active method. The override beats the
74
- // registry; if neither is available, render the empty state.
75
105
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
76
106
 
77
- // Push local edits back into the provider's selection so other
78
- // components react. Only push when the value actually differs to
79
- // avoid feedback loops.
80
107
  useEffect(() => {
81
108
  if (tis.selection.projectId !== projectId) {
82
109
  tis.setSelection({ projectId });
@@ -100,10 +127,6 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
100
127
  // eslint-disable-next-line react-hooks/exhaustive-deps
101
128
  }, [sampleId]);
102
129
 
103
- // Track the live broadcast scalars — when the form is staged for
104
- // a different sample externally (e.g., the operator types in
105
- // another tab), reflect that here. Only react on a true change
106
- // to avoid stomping local edits.
107
130
  useEffect(() => {
108
131
  if (tis.state.stagedSampleId && tis.state.stagedSampleId !== sampleId) {
109
132
  setSampleIdLocal(tis.state.stagedSampleId);
@@ -113,16 +136,90 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
113
136
 
114
137
  const [existingProjects, setExistingProjects] = useState<string[]>([]);
115
138
  const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
139
+ const justCreatedRef = useRef<Set<string>>(new Set());
140
+ const [justCreatedTick, setJustCreatedTick] = useState(0);
116
141
  const [isValid, setIsValid] = useState(false);
117
142
 
118
- // Seed and live-update fields that declare a `source` (FQDN).
143
+ // Cache of `project_fields` for the currently-selected project,
144
+ // fetched once on project selection. Folded into every
145
+ // `tis.stage_test` payload so the recorded test.json carries the
146
+ // project-level metadata even though the operator no longer sees
147
+ // those fields in the main form. Keyed by projectId so switching
148
+ // projects mid-session refetches cleanly.
149
+ const [projectFieldsCache, setProjectFieldsCache] = useState<Record<string, any>>({});
150
+ const projectFieldsForCurrent = projectFieldsCache[projectId] ?? {};
151
+
152
+ // Dialog state for create + edit + method-picker.
153
+ const [newProjectOpen, setNewProjectOpen] = useState(false);
154
+ const [editProjectOpen, setEditProjectOpen] = useState(false);
155
+ const [methodPickerOpen, setMethodPickerOpen] = useState(false);
156
+
157
+ const fetchProjects = async () => {
158
+ try {
159
+ const resp: any = await invoke('tis.list_projects' as any, MessageType.Request as any, {} as any);
160
+ if (resp.success && resp.data && resp.data.projects) {
161
+ setExistingProjects(resp.data.projects);
162
+ }
163
+ } catch (err) {
164
+ console.error('Failed to list projects', err);
165
+ }
166
+ };
167
+
168
+ useEffect(() => {
169
+ fetchProjects();
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [invoke]);
172
+
173
+ const knownProjects = useMemo(() => {
174
+ const s = new Set<string>(existingProjects);
175
+ for (const id of justCreatedRef.current) s.add(id);
176
+ return s;
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ }, [existingProjects, justCreatedTick]);
179
+
180
+ const projectExists = projectId.trim() !== '' && knownProjects.has(projectId.trim());
181
+ const projectIdFormatValid = isValidProjectIdFormat(projectId.trim());
182
+ const canCreateProject =
183
+ projectId.trim() !== ''
184
+ && projectIdFormatValid
185
+ && !knownProjects.has(projectId.trim());
186
+
187
+ // Whenever the user selects a known project (or the project
188
+ // gets created in-session), pull its persisted project_fields
189
+ // so we can fold them into stage_test. We don't refetch on
190
+ // every keystroke — only when projectId actually lands on an
191
+ // existing project we don't yet have cached.
192
+ useEffect(() => {
193
+ const pid = projectId.trim();
194
+ if (!pid || !projectExists) return;
195
+ if (projectFieldsCache[pid] !== undefined) return; // already cached
196
+ let cancelled = false;
197
+ (async () => {
198
+ try {
199
+ const resp: any = await invoke(
200
+ 'tis.read_project' as any, MessageType.Request,
201
+ { project_id: pid } as any,
202
+ );
203
+ if (cancelled) return;
204
+ if (resp?.success) {
205
+ const pf = (resp.data?.project_fields ?? {}) as Record<string, any>;
206
+ setProjectFieldsCache(prev => ({ ...prev, [pid]: pf }));
207
+ }
208
+ } catch (e) {
209
+ console.warn('[TestSetupForm] read_project failed:', e);
210
+ }
211
+ })();
212
+ return () => { cancelled = true; };
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ }, [projectId, projectExists]);
215
+
216
+ // Seed and live-update config_fields that declare a `source`.
119
217
  useEffect(() => {
120
218
  if (!schema) return;
121
- const allFields = [...schema.project_fields, ...schema.config_fields];
122
219
  setConfig((prev: any) => {
123
220
  let next = prev;
124
- for (const field of allFields) {
125
- if (field.name === 'sample_id') continue; // sample_id is top-level now
221
+ for (const field of schema.config_fields) {
222
+ if (field.name === 'sample_id') continue;
126
223
  if (!field.source) continue;
127
224
  const tag = findTagByFqdn(field.source);
128
225
  if (!tag) continue;
@@ -136,61 +233,64 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
136
233
  });
137
234
  }, [schema, rawValues, findTagByFqdn]);
138
235
 
139
- useEffect(() => {
140
- const fetchProjects = async () => {
141
- try {
142
- const resp: any = await invoke('tis.list_projects' as any, MessageType.Request as any, {} as any);
143
- if (resp.success && resp.data && resp.data.projects) {
144
- setExistingProjects(resp.data.projects);
145
- }
146
- } catch (err) {
147
- console.error('Failed to list projects', err);
148
- }
149
- };
150
- fetchProjects();
151
- }, [invoke]);
152
-
153
236
  const searchProjects = (event: AutoCompleteCompleteEvent) => {
154
237
  const query = event.query.toLowerCase();
155
238
  setFilteredProjects(existingProjects.filter(p => p.toLowerCase().includes(query)));
156
239
  };
157
240
 
158
- // Validation drives both the local UI and the auto-stage. We only
159
- // call `tis.stage_test` when every required piece is present —
160
- // otherwise the operator's edge-triggered Start button could fire
161
- // a half-baked stage.
241
+ // Validation drives both the local UI and the auto-stage. Project
242
+ // ID must be a known project — typing an unknown name is invalid
243
+ // until the operator goes through the New Project dialog. Each
244
+ // required *config_field* must also be filled in; project_fields
245
+ // are validated by the dialog at create/edit time, not here.
162
246
  useEffect(() => {
163
247
  if (!schema) { setIsValid(false); return; }
164
248
  let valid = true;
165
- if (!projectId.trim()) valid = false;
249
+ if (!projectExists) valid = false;
166
250
  if (!methodId.trim()) valid = false;
167
251
  if (!sampleId.trim()) valid = false;
168
252
 
169
- const allFields = [...schema.project_fields, ...schema.config_fields];
170
- for (const field of allFields) {
171
- if (field.name === 'sample_id') continue; // tracked separately
253
+ for (const field of schema.config_fields) {
254
+ if (field.name === 'sample_id') continue;
172
255
  if (field.required) {
173
256
  const v = config[field.name];
174
257
  if (v === undefined || v === '' || v === null) { valid = false; break; }
175
258
  }
176
259
  }
260
+
261
+ // We also gate validity on having loaded the project_fields
262
+ // for the selected project — staging *without* them would
263
+ // record a test.json that's missing project-level metadata
264
+ // for the lifetime of the run. The fetch happens automatically
265
+ // when the project is selected, so this is a tight transient
266
+ // window in practice.
267
+ if (valid && projectExists && projectFieldsCache[projectId.trim()] === undefined) {
268
+ valid = false;
269
+ }
270
+
177
271
  setIsValid(valid);
178
272
  if (onValidationChange) onValidationChange(valid, config);
179
273
 
180
- // Auto-stage when everything is valid. Stripping sample_id from
181
- // config (it's a top-level peer of project_id/method_id now;
182
- // the legacy nested form is server-side hoisted with a
183
- // deprecation warning, but new code shouldn't rely on it).
184
274
  if (valid) {
185
- const { sample_id: _drop, ...rest } = (config ?? {}) as any;
275
+ const { sample_id: _drop, ...configRest } = (config ?? {}) as any;
276
+ // Combine persisted project_fields (managerial setup) with
277
+ // the per-test config_fields the operator just filled in.
278
+ // Keys collide in pathological project.json, in which case
279
+ // the per-test value wins — operators are closer to the
280
+ // run than the project metadata.
281
+ const mergedConfig = {
282
+ ...projectFieldsForCurrent,
283
+ ...configRest,
284
+ };
186
285
  void invoke('tis.stage_test' as any, MessageType.Request, {
187
286
  project_id: projectId,
188
287
  method_id: methodId,
189
288
  sample_id: sampleId,
190
- config: rest,
289
+ config: mergedConfig,
191
290
  } as any).catch(e => console.error('[TestSetupForm] stage_test failed:', e));
192
291
  }
193
- }, [config, schema, projectId, methodId, sampleId, onValidationChange, invoke]);
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps
293
+ }, [config, schema, projectId, methodId, sampleId, projectExists, projectFieldsCache, onValidationChange, invoke]);
194
294
 
195
295
  const isFieldValid = (field: TestFieldDef) => {
196
296
  if (!field.required) return true;
@@ -199,7 +299,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
199
299
  };
200
300
 
201
301
  const handleProjectIdChange = (value: string | null | undefined) => {
202
- const sanitized = (value || '').replace(/[^a-zA-Z0-9_]/g, '');
302
+ const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
203
303
  setProjectIdLocal(sanitized);
204
304
  };
205
305
 
@@ -215,13 +315,34 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
215
315
  }
216
316
  };
217
317
 
218
- const renderField = (field: TestFieldDef) => {
318
+ // Called by ProjectInfoDialog after a successful create/update.
319
+ // Populates the local cache + known-projects set so the main form
320
+ // is immediately valid without requiring a refresh.
321
+ const handleProjectInfoSubmitted = (pid: string, projectFields: Record<string, any>) => {
322
+ justCreatedRef.current.add(pid);
323
+ setJustCreatedTick(t => t + 1);
324
+ setProjectFieldsCache(prev => ({ ...prev, [pid]: projectFields }));
325
+ // Refresh the dropdown so the new project shows up for any
326
+ // future searches in this session.
327
+ void fetchProjects();
328
+ // If the dialog created a brand-new project, also surface it
329
+ // as the current selection — the operator's intent is clearly
330
+ // "set up this project and start working on it."
331
+ if (projectId.trim() !== pid) setProjectIdLocal(pid);
332
+ };
333
+
334
+ // -----------------------------------------------------------------
335
+ // Per-config-field row renderer — same four-column layout as before:
336
+ // label[units] | input | info-icon | validity-icon
337
+ // -----------------------------------------------------------------
338
+ const renderConfigField = (field: TestFieldDef) => {
219
339
  if (field.name === 'sample_id') return null;
220
340
  const valid = isFieldValid(field);
221
341
  const isNum = field.type !== 'string' && field.type !== 'bool';
342
+ const tooltipId = `acFormInfo_${field.name}`;
222
343
  return (
223
344
  <React.Fragment key={field.name}>
224
- <span className="ac-form-label">{field.name}</span>
345
+ <span className="ac-form-label">{labelOf(field)}</span>
225
346
  {isNum ? (
226
347
  <ValueInput
227
348
  label={undefined}
@@ -237,7 +358,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
237
358
  className={!valid ? 'p-invalid' : ''}
238
359
  />
239
360
  )}
240
- <span className="ac-form-units">{field.units ?? ''}</span>
361
+ {hasDescription(field) ? (
362
+ <>
363
+ <Tooltip target={`#${tooltipId}`} position="left" />
364
+ <span
365
+ id={tooltipId}
366
+ data-pr-tooltip={field.description}
367
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help' }}
368
+ >
369
+ <i className="pi pi-info-circle" style={{ color: 'var(--text-secondary-color)' }} />
370
+ </span>
371
+ </>
372
+ ) : (
373
+ <span aria-hidden="true" />
374
+ )}
241
375
  <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
242
376
  <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
243
377
  </span>
@@ -255,8 +389,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
255
389
  );
256
390
  }
257
391
 
392
+ const gridStyle: React.CSSProperties = {
393
+ padding: '1.25rem',
394
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
395
+ };
396
+
397
+ const projectRowValid = projectExists;
398
+
258
399
  return (
259
- <div className="ac-form-grid" style={{ padding: '1.25rem' }}>
400
+ <div className="ac-form-grid" style={gridStyle}>
260
401
  <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
261
402
  Project &amp; Method
262
403
  <span style={{ color: isValid ? 'var(--green-500)' : 'var(--red-500)' }}>
@@ -265,18 +406,62 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
265
406
  </h3>
266
407
 
267
408
  <span className="ac-form-label">Project ID</span>
268
- <AutoComplete
269
- value={projectId}
270
- suggestions={filteredProjects}
271
- completeMethod={searchProjects}
272
- onChange={(e) => handleProjectIdChange(e.value)}
273
- dropdown
274
- placeholder="Enter or select Project ID"
275
- className={!projectId.trim() ? 'p-invalid' : ''}
276
- />
277
- <span />
278
- <span style={{ color: projectId.trim() ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
279
- <i className={projectId.trim() ? 'pi pi-check' : 'pi pi-times'} />
409
+ <div className="p-inputgroup" style={{ flex: 1 }}>
410
+ <AutoComplete
411
+ value={projectId}
412
+ suggestions={filteredProjects}
413
+ completeMethod={searchProjects}
414
+ onChange={(e) => handleProjectIdChange(e.value)}
415
+ dropdown
416
+ placeholder="Select an existing Project ID, or type a new one and click +"
417
+ className={!projectRowValid ? 'p-invalid' : ''}
418
+ style={{ flex: 1 }}
419
+ />
420
+ {/*
421
+ * + button → opens the New Project dialog where the
422
+ * operator (or manager) fills in project_fields.
423
+ * Enabled only when the typed ID is a fresh, valid
424
+ * candidate. Once the dialog completes, the project
425
+ * is created on the server and added to our local
426
+ * known-projects set so the main form becomes valid
427
+ * immediately.
428
+ */}
429
+ <Button
430
+ icon="pi pi-plus"
431
+ type="button"
432
+ onClick={() => setNewProjectOpen(true)}
433
+ disabled={!canCreateProject}
434
+ tooltip={
435
+ !projectId.trim() ? 'Type a project ID first' :
436
+ !projectIdFormatValid ? 'Letters, digits, _ and - only' :
437
+ knownProjects.has(projectId.trim()) ? 'Project already exists' :
438
+ `Create project "${projectId.trim()}"`
439
+ }
440
+ tooltipOptions={{ position: 'top' }}
441
+ />
442
+ {/*
443
+ * ✏️ button → opens the Edit Project Information
444
+ * dialog. Enabled only when the selected project
445
+ * actually exists. This is the only way to mutate
446
+ * project_fields after creation; the operator can't
447
+ * stumble into editing project metadata while running
448
+ * a sample, which keeps the future per-user permission
449
+ * gate (manager vs operator) clean.
450
+ */}
451
+ <Button
452
+ icon="pi pi-pencil"
453
+ type="button"
454
+ onClick={() => setEditProjectOpen(true)}
455
+ disabled={!projectExists}
456
+ tooltip={projectExists
457
+ ? `Edit information for "${projectId.trim()}"`
458
+ : 'Select an existing project to edit'}
459
+ tooltipOptions={{ position: 'top' }}
460
+ />
461
+ </div>
462
+ <span aria-hidden="true" />
463
+ <span style={{ color: projectRowValid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
464
+ <i className={projectRowValid ? 'pi pi-check' : 'pi pi-times'} />
280
465
  </span>
281
466
 
282
467
  <span className="ac-form-label">Sample ID</span>
@@ -286,29 +471,82 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
286
471
  onValueChanged={handleSampleIdChange}
287
472
  className={!sampleId.trim() ? 'p-invalid' : ''}
288
473
  />
289
- <span />
474
+ <span aria-hidden="true" />
290
475
  <span style={{ color: sampleId.trim() ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
291
476
  <i className={sampleId.trim() ? 'pi pi-check' : 'pi pi-times'} />
292
477
  </span>
293
478
 
294
- {methodIds.length > 1 && (
479
+ {/*
480
+ * Test Method row. Shows the current method's pretty
481
+ * label (or its canonical method_id when no label is
482
+ * declared) read-only, with an edit button that opens
483
+ * the picker dialog. The dialog scales past three or
484
+ * four methods where a SelectButton would wrap, and
485
+ * surfaces the per-method description so the operator
486
+ * can disambiguate similarly-named methods at the
487
+ * point of choice. The row is rendered even when only
488
+ * one method is declared so the operator can still open
489
+ * the picker and read its description.
490
+ */}
491
+ {methodIds.length > 0 && (
295
492
  <>
296
493
  <span className="ac-form-label">Test Method</span>
297
- <SelectButton
298
- value={methodId}
299
- options={methodIds.map(id => ({ label: id, value: id }))}
300
- onChange={(e) => e.value && setMethodIdLocal(e.value)}
301
- />
302
- <span />
303
- <span />
494
+ <div className="p-inputgroup" style={{ flex: 1 }}>
495
+ <InputText
496
+ value={methodLabelOf(methodId, schema)}
497
+ readOnly
498
+ style={{ flex: 1 }}
499
+ tabIndex={-1}
500
+ />
501
+ <Button
502
+ icon="pi pi-pencil"
503
+ type="button"
504
+ onClick={() => setMethodPickerOpen(true)}
505
+ tooltip={methodIds.length > 1
506
+ ? 'Change test method'
507
+ : 'View test method details'}
508
+ tooltipOptions={{ position: 'top' }}
509
+ />
510
+ </div>
511
+ <span aria-hidden="true" />
512
+ <span style={{ color: methodId ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
513
+ <i className={methodId ? 'pi pi-check' : 'pi pi-times'} />
514
+ </span>
304
515
  </>
305
516
  )}
306
517
 
307
- <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Project Information</h3>
308
- {schema.project_fields.map(renderField)}
309
-
310
518
  <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
311
- {schema.config_fields.map(renderField)}
519
+ {schema.config_fields.map(renderConfigField)}
520
+
521
+ {/*
522
+ * Project Information no longer renders inline. Use the
523
+ * + and ✏️ buttons in the Project ID row above to create
524
+ * or edit it. The dialogs persist project_fields on the
525
+ * server and the form folds them into stage_test
526
+ * automatically.
527
+ */}
528
+ <ProjectInfoDialog
529
+ visible={newProjectOpen}
530
+ onHide={() => setNewProjectOpen(false)}
531
+ mode="create"
532
+ projectId={projectId.trim()}
533
+ projectFields={schema.project_fields}
534
+ onSubmitted={handleProjectInfoSubmitted}
535
+ />
536
+ <ProjectInfoDialog
537
+ visible={editProjectOpen}
538
+ onHide={() => setEditProjectOpen(false)}
539
+ mode="edit"
540
+ projectId={projectId.trim()}
541
+ projectFields={schema.project_fields}
542
+ onSubmitted={handleProjectInfoSubmitted}
543
+ />
544
+ <TestMethodDialog
545
+ visible={methodPickerOpen}
546
+ onHide={() => setMethodPickerOpen(false)}
547
+ currentMethodId={methodId}
548
+ onSelected={(picked) => setMethodIdLocal(picked)}
549
+ />
312
550
  </div>
313
551
  );
314
552
  };