@servicetitan/dte-unlayer 0.140.0 → 0.142.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,37 @@
1
1
  import { LookupOption } from './lookupOptionsController';
2
2
  /**
3
- * - `idle` -> no lookup source (or searchable source): use free-text input.
4
- * - `loading` -> request in flight: show a disabled/loading value control.
5
- * - `ready` -> host returned options: render the dropdown.
6
- * - `fallback` -> empty/error/timeout: fall back to free-text so the user is never blocked.
3
+ * - `idle` -> no lookup source: use free-text input.
4
+ * - `loading` -> initial request in flight: show a disabled/loading value control.
5
+ * - `static` -> host returned a full list: render a locally-filtered dropdown.
6
+ * - `searchable` -> host wants per-query search: render an async dropdown driven by `loadOptions`.
7
+ * - `fallback` -> empty/error/timeout: fall back to free-text so the user is never blocked.
7
8
  */
8
- export type LookupOptionsStatus = 'idle' | 'loading' | 'ready' | 'fallback';
9
+ export type LookupOptionsStatus = 'idle' | 'loading' | 'static' | 'searchable' | 'fallback';
9
10
  interface UseLookupOptionsResult {
10
11
  status: LookupOptionsStatus;
12
+ /** Full option list for `static` sources; empty for `searchable`/`idle`/`fallback`. */
11
13
  options: LookupOption[];
14
+ /**
15
+ * Per-query resolver for `searchable` sources. Emits a request with the typed
16
+ * `query` and resolves with the host's fresh list (or `[]` on timeout). Wire
17
+ * this into an async dropdown (e.g. Anvil2 `SelectField`'s `loadOptions`).
18
+ */
19
+ loadOptions: (query: string) => Promise<LookupOption[]>;
20
+ /**
21
+ * Resolve the option(s) for already-stored keys (exact match, not a text
22
+ * search). Used on open to turn a saved searchable value back into its
23
+ * human-readable label. Resolves with `[]` on timeout.
24
+ */
25
+ resolveOptions: (keys: string[]) => Promise<LookupOption[]>;
12
26
  }
13
27
  /**
14
28
  * Requests host-resolved dropdown options for a field's `fieldLookupSource`.
15
29
  *
16
- * Re-runs whenever `sourceKey` changes, ignores stale responses whose requestId
17
- * does not match the latest request, and cleans up its listener/timeout on
18
- * unmount or source change.
30
+ * Fires a single query-less request whenever `sourceKey` changes. The host's
31
+ * response decides the mode: a full list (`static`, filtered locally) or a
32
+ * searchable source (`searchable`, driven by `loadOptions` per keystroke).
33
+ * Stale responses (mismatched requestId) are ignored and the listener/timeout
34
+ * are cleaned up on unmount or source change.
19
35
  */
20
36
  export declare const useLookupOptions: (sourceKey: string | undefined) => UseLookupOptionsResult;
21
37
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"useLookupOptions.d.ts","sourceRoot":"","sources":["../../src/display-conditions/useLookupOptions.ts"],"names":[],"mappings":"AAEA,OAAO,EAGH,YAAY,EAEf,MAAM,2BAA2B,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,CAAC;AAK5E,UAAU,sBAAsB;IAC5B,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,YAAY,EAAE,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,GAAG,SAAS,KAAG,sBAmDhE,CAAC"}
1
+ {"version":3,"file":"useLookupOptions.d.ts","sourceRoot":"","sources":["../../src/display-conditions/useLookupOptions.ts"],"names":[],"mappings":"AAEA,OAAO,EAEH,YAAY,EAEf,MAAM,2BAA2B,CAAC;AAEnC;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,YAAY,GAAG,UAAU,CAAC;AAK5F,UAAU,sBAAsB;IAC5B,MAAM,EAAE,mBAAmB,CAAC;IAC5B,uFAAuF;IACvF,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB;;;;OAIG;IACH,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IACxD;;;;OAIG;IACH,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAC/D;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,GAAG,SAAS,KAAG,sBAmGhE,CAAC"}
@@ -1,20 +1,24 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { generateId } from './constants';
3
- import { emitLookupOptionsRequest, isSearchableLookupSource, onLookupOptionsResponse } from './lookupOptionsController';
3
+ import { emitLookupOptionsRequest, onLookupOptionsResponse } from './lookupOptionsController';
4
4
  /** Time to wait for the host before falling back to free-text input. */ const LOOKUP_OPTIONS_TIMEOUT_MS = 10000;
5
5
  /**
6
6
  * Requests host-resolved dropdown options for a field's `fieldLookupSource`.
7
7
  *
8
- * Re-runs whenever `sourceKey` changes, ignores stale responses whose requestId
9
- * does not match the latest request, and cleans up its listener/timeout on
10
- * unmount or source change.
8
+ * Fires a single query-less request whenever `sourceKey` changes. The host's
9
+ * response decides the mode: a full list (`static`, filtered locally) or a
10
+ * searchable source (`searchable`, driven by `loadOptions` per keystroke).
11
+ * Stale responses (mismatched requestId) are ignored and the listener/timeout
12
+ * are cleaned up on unmount or source change.
11
13
  */ export const useLookupOptions = (sourceKey)=>{
12
14
  const [status, setStatus] = useState('idle');
13
15
  const [options, setOptions] = useState([]);
14
16
  const latestRequestIdRef = useRef(null);
17
+ const sourceKeyRef = useRef(sourceKey);
15
18
  useEffect(()=>{
16
- // No source, or a searchable source we intentionally skip for now.
17
- if (!sourceKey || isSearchableLookupSource(sourceKey)) {
19
+ sourceKeyRef.current = sourceKey;
20
+ const normalizedSourceKey = sourceKey === null || sourceKey === void 0 ? void 0 : sourceKey.trim();
21
+ if (!normalizedSourceKey) {
18
22
  latestRequestIdRef.current = null;
19
23
  setStatus('idle');
20
24
  setOptions([]);
@@ -37,9 +41,14 @@ import { emitLookupOptionsRequest, isSearchableLookupSource, onLookupOptionsResp
37
41
  return;
38
42
  }
39
43
  clearTimeout(timeoutId);
44
+ if (response.searchable) {
45
+ setOptions([]);
46
+ setStatus('searchable');
47
+ return;
48
+ }
40
49
  if (response.options.length > 0) {
41
50
  setOptions(response.options);
42
- setStatus('ready');
51
+ setStatus('static');
43
52
  } else {
44
53
  setOptions([]);
45
54
  setStatus('fallback');
@@ -47,7 +56,7 @@ import { emitLookupOptionsRequest, isSearchableLookupSource, onLookupOptionsResp
47
56
  });
48
57
  emitLookupOptionsRequest({
49
58
  requestId,
50
- sourceKey
59
+ sourceKey: normalizedSourceKey
51
60
  });
52
61
  return ()=>{
53
62
  clearTimeout(timeoutId);
@@ -56,9 +65,53 @@ import { emitLookupOptionsRequest, isSearchableLookupSource, onLookupOptionsResp
56
65
  }, [
57
66
  sourceKey
58
67
  ]);
68
+ const requestOptions = useCallback((payload)=>{
69
+ var _sourceKeyRef_current;
70
+ const normalizedSourceKey = (_sourceKeyRef_current = sourceKeyRef.current) === null || _sourceKeyRef_current === void 0 ? void 0 : _sourceKeyRef_current.trim();
71
+ if (!normalizedSourceKey) {
72
+ return Promise.resolve([]);
73
+ }
74
+ return new Promise((resolve)=>{
75
+ const requestId = generateId();
76
+ let settled = false;
77
+ const finish = (result)=>{
78
+ if (settled) {
79
+ return;
80
+ }
81
+ settled = true;
82
+ clearTimeout(timeoutId);
83
+ unsubscribe();
84
+ resolve(result);
85
+ };
86
+ const timeoutId = setTimeout(()=>finish([]), LOOKUP_OPTIONS_TIMEOUT_MS);
87
+ const unsubscribe = onLookupOptionsResponse((response)=>{
88
+ if (response.requestId !== requestId) {
89
+ return;
90
+ }
91
+ finish(response.options);
92
+ });
93
+ emitLookupOptionsRequest({
94
+ requestId,
95
+ sourceKey: normalizedSourceKey,
96
+ ...payload
97
+ });
98
+ });
99
+ }, []);
100
+ const loadOptions = useCallback((query)=>requestOptions({
101
+ query
102
+ }), [
103
+ requestOptions
104
+ ]);
105
+ const resolveOptions = useCallback((keys)=>requestOptions({
106
+ keys
107
+ }), [
108
+ requestOptions
109
+ ]);
59
110
  return {
60
111
  status,
61
- options
112
+ options,
113
+ loadOptions,
114
+ resolveOptions
62
115
  };
63
116
  };
64
117
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/display-conditions/useLookupOptions.ts"],"sourcesContent":["import { useEffect, useRef, useState } from 'react';\nimport { generateId } from './constants';\nimport {\n emitLookupOptionsRequest,\n isSearchableLookupSource,\n LookupOption,\n onLookupOptionsResponse,\n} from './lookupOptionsController';\n\n/**\n * - `idle` -> no lookup source (or searchable source): use free-text input.\n * - `loading` -> request in flight: show a disabled/loading value control.\n * - `ready` -> host returned options: render the dropdown.\n * - `fallback` -> empty/error/timeout: fall back to free-text so the user is never blocked.\n */\nexport type LookupOptionsStatus = 'idle' | 'loading' | 'ready' | 'fallback';\n\n/** Time to wait for the host before falling back to free-text input. */\nconst LOOKUP_OPTIONS_TIMEOUT_MS = 10_000;\n\ninterface UseLookupOptionsResult {\n status: LookupOptionsStatus;\n options: LookupOption[];\n}\n\n/**\n * Requests host-resolved dropdown options for a field's `fieldLookupSource`.\n *\n * Re-runs whenever `sourceKey` changes, ignores stale responses whose requestId\n * does not match the latest request, and cleans up its listener/timeout on\n * unmount or source change.\n */\nexport const useLookupOptions = (sourceKey: string | undefined): UseLookupOptionsResult => {\n const [status, setStatus] = useState<LookupOptionsStatus>('idle');\n const [options, setOptions] = useState<LookupOption[]>([]);\n const latestRequestIdRef = useRef<string | null>(null);\n\n useEffect(() => {\n // No source, or a searchable source we intentionally skip for now.\n if (!sourceKey || isSearchableLookupSource(sourceKey)) {\n latestRequestIdRef.current = null;\n setStatus('idle');\n setOptions([]);\n return;\n }\n\n const requestId = generateId();\n latestRequestIdRef.current = requestId;\n setStatus('loading');\n setOptions([]);\n\n const timeoutId = setTimeout(() => {\n if (latestRequestIdRef.current !== requestId) {\n return;\n }\n setStatus('fallback');\n setOptions([]);\n }, LOOKUP_OPTIONS_TIMEOUT_MS);\n\n const unsubscribe = onLookupOptionsResponse(response => {\n // Ignore stale responses (different request, or superseded selection).\n if (response.requestId !== requestId || latestRequestIdRef.current !== requestId) {\n return;\n }\n clearTimeout(timeoutId);\n if (response.options.length > 0) {\n setOptions(response.options);\n setStatus('ready');\n } else {\n setOptions([]);\n setStatus('fallback');\n }\n });\n\n emitLookupOptionsRequest({ requestId, sourceKey });\n\n return () => {\n clearTimeout(timeoutId);\n unsubscribe();\n };\n }, [sourceKey]);\n\n return { status, options };\n};\n"],"names":["useEffect","useRef","useState","generateId","emitLookupOptionsRequest","isSearchableLookupSource","onLookupOptionsResponse","LOOKUP_OPTIONS_TIMEOUT_MS","useLookupOptions","sourceKey","status","setStatus","options","setOptions","latestRequestIdRef","current","requestId","timeoutId","setTimeout","unsubscribe","response","clearTimeout","length"],"mappings":"AAAA,SAASA,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AACpD,SAASC,UAAU,QAAQ,cAAc;AACzC,SACIC,wBAAwB,EACxBC,wBAAwB,EAExBC,uBAAuB,QACpB,4BAA4B;AAUnC,sEAAsE,GACtE,MAAMC,4BAA4B;AAOlC;;;;;;CAMC,GACD,OAAO,MAAMC,mBAAmB,CAACC;IAC7B,MAAM,CAACC,QAAQC,UAAU,GAAGT,SAA8B;IAC1D,MAAM,CAACU,SAASC,WAAW,GAAGX,SAAyB,EAAE;IACzD,MAAMY,qBAAqBb,OAAsB;IAEjDD,UAAU;QACN,mEAAmE;QACnE,IAAI,CAACS,aAAaJ,yBAAyBI,YAAY;YACnDK,mBAAmBC,OAAO,GAAG;YAC7BJ,UAAU;YACVE,WAAW,EAAE;YACb;QACJ;QAEA,MAAMG,YAAYb;QAClBW,mBAAmBC,OAAO,GAAGC;QAC7BL,UAAU;QACVE,WAAW,EAAE;QAEb,MAAMI,YAAYC,WAAW;YACzB,IAAIJ,mBAAmBC,OAAO,KAAKC,WAAW;gBAC1C;YACJ;YACAL,UAAU;YACVE,WAAW,EAAE;QACjB,GAAGN;QAEH,MAAMY,cAAcb,wBAAwBc,CAAAA;YACxC,uEAAuE;YACvE,IAAIA,SAASJ,SAAS,KAAKA,aAAaF,mBAAmBC,OAAO,KAAKC,WAAW;gBAC9E;YACJ;YACAK,aAAaJ;YACb,IAAIG,SAASR,OAAO,CAACU,MAAM,GAAG,GAAG;gBAC7BT,WAAWO,SAASR,OAAO;gBAC3BD,UAAU;YACd,OAAO;gBACHE,WAAW,EAAE;gBACbF,UAAU;YACd;QACJ;QAEAP,yBAAyB;YAAEY;YAAWP;QAAU;QAEhD,OAAO;YACHY,aAAaJ;YACbE;QACJ;IACJ,GAAG;QAACV;KAAU;IAEd,OAAO;QAAEC;QAAQE;IAAQ;AAC7B,EAAE"}
1
+ {"version":3,"sources":["../../src/display-conditions/useLookupOptions.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { generateId } from './constants';\nimport {\n emitLookupOptionsRequest,\n LookupOption,\n onLookupOptionsResponse,\n} from './lookupOptionsController';\n\n/**\n * - `idle` -> no lookup source: use free-text input.\n * - `loading` -> initial request in flight: show a disabled/loading value control.\n * - `static` -> host returned a full list: render a locally-filtered dropdown.\n * - `searchable` -> host wants per-query search: render an async dropdown driven by `loadOptions`.\n * - `fallback` -> empty/error/timeout: fall back to free-text so the user is never blocked.\n */\nexport type LookupOptionsStatus = 'idle' | 'loading' | 'static' | 'searchable' | 'fallback';\n\n/** Time to wait for the host before falling back to free-text input. */\nconst LOOKUP_OPTIONS_TIMEOUT_MS = 10_000;\n\ninterface UseLookupOptionsResult {\n status: LookupOptionsStatus;\n /** Full option list for `static` sources; empty for `searchable`/`idle`/`fallback`. */\n options: LookupOption[];\n /**\n * Per-query resolver for `searchable` sources. Emits a request with the typed\n * `query` and resolves with the host's fresh list (or `[]` on timeout). Wire\n * this into an async dropdown (e.g. Anvil2 `SelectField`'s `loadOptions`).\n */\n loadOptions: (query: string) => Promise<LookupOption[]>;\n /**\n * Resolve the option(s) for already-stored keys (exact match, not a text\n * search). Used on open to turn a saved searchable value back into its\n * human-readable label. Resolves with `[]` on timeout.\n */\n resolveOptions: (keys: string[]) => Promise<LookupOption[]>;\n}\n\n/**\n * Requests host-resolved dropdown options for a field's `fieldLookupSource`.\n *\n * Fires a single query-less request whenever `sourceKey` changes. The host's\n * response decides the mode: a full list (`static`, filtered locally) or a\n * searchable source (`searchable`, driven by `loadOptions` per keystroke).\n * Stale responses (mismatched requestId) are ignored and the listener/timeout\n * are cleaned up on unmount or source change.\n */\nexport const useLookupOptions = (sourceKey: string | undefined): UseLookupOptionsResult => {\n const [status, setStatus] = useState<LookupOptionsStatus>('idle');\n const [options, setOptions] = useState<LookupOption[]>([]);\n const latestRequestIdRef = useRef<string | null>(null);\n const sourceKeyRef = useRef<string | undefined>(sourceKey);\n\n useEffect(() => {\n sourceKeyRef.current = sourceKey;\n const normalizedSourceKey = sourceKey?.trim();\n\n if (!normalizedSourceKey) {\n latestRequestIdRef.current = null;\n setStatus('idle');\n setOptions([]);\n return;\n }\n\n const requestId = generateId();\n latestRequestIdRef.current = requestId;\n setStatus('loading');\n setOptions([]);\n\n const timeoutId = setTimeout(() => {\n if (latestRequestIdRef.current !== requestId) {\n return;\n }\n setStatus('fallback');\n setOptions([]);\n }, LOOKUP_OPTIONS_TIMEOUT_MS);\n\n const unsubscribe = onLookupOptionsResponse(response => {\n // Ignore stale responses (different request, or superseded selection).\n if (response.requestId !== requestId || latestRequestIdRef.current !== requestId) {\n return;\n }\n clearTimeout(timeoutId);\n if (response.searchable) {\n setOptions([]);\n setStatus('searchable');\n return;\n }\n if (response.options.length > 0) {\n setOptions(response.options);\n setStatus('static');\n } else {\n setOptions([]);\n setStatus('fallback');\n }\n });\n\n emitLookupOptionsRequest({ requestId, sourceKey: normalizedSourceKey });\n\n return () => {\n clearTimeout(timeoutId);\n unsubscribe();\n };\n }, [sourceKey]);\n\n const requestOptions = useCallback((payload: { query?: string; keys?: string[] }) => {\n const normalizedSourceKey = sourceKeyRef.current?.trim();\n if (!normalizedSourceKey) {\n return Promise.resolve<LookupOption[]>([]);\n }\n\n return new Promise<LookupOption[]>(resolve => {\n const requestId = generateId();\n let settled = false;\n\n const finish = (result: LookupOption[]) => {\n if (settled) {\n return;\n }\n settled = true;\n clearTimeout(timeoutId);\n unsubscribe();\n resolve(result);\n };\n\n const timeoutId = setTimeout(() => finish([]), LOOKUP_OPTIONS_TIMEOUT_MS);\n\n const unsubscribe = onLookupOptionsResponse(response => {\n if (response.requestId !== requestId) {\n return;\n }\n finish(response.options);\n });\n\n emitLookupOptionsRequest({ requestId, sourceKey: normalizedSourceKey, ...payload });\n });\n }, []);\n\n const loadOptions = useCallback((query: string) => requestOptions({ query }), [requestOptions]);\n\n const resolveOptions = useCallback(\n (keys: string[]) => requestOptions({ keys }),\n [requestOptions],\n );\n\n return { status, options, loadOptions, resolveOptions };\n};\n"],"names":["useCallback","useEffect","useRef","useState","generateId","emitLookupOptionsRequest","onLookupOptionsResponse","LOOKUP_OPTIONS_TIMEOUT_MS","useLookupOptions","sourceKey","status","setStatus","options","setOptions","latestRequestIdRef","sourceKeyRef","current","normalizedSourceKey","trim","requestId","timeoutId","setTimeout","unsubscribe","response","clearTimeout","searchable","length","requestOptions","payload","Promise","resolve","settled","finish","result","loadOptions","query","resolveOptions","keys"],"mappings":"AAAA,SAASA,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AACjE,SAASC,UAAU,QAAQ,cAAc;AACzC,SACIC,wBAAwB,EAExBC,uBAAuB,QACpB,4BAA4B;AAWnC,sEAAsE,GACtE,MAAMC,4BAA4B;AAoBlC;;;;;;;;CAQC,GACD,OAAO,MAAMC,mBAAmB,CAACC;IAC7B,MAAM,CAACC,QAAQC,UAAU,GAAGR,SAA8B;IAC1D,MAAM,CAACS,SAASC,WAAW,GAAGV,SAAyB,EAAE;IACzD,MAAMW,qBAAqBZ,OAAsB;IACjD,MAAMa,eAAeb,OAA2BO;IAEhDR,UAAU;QACNc,aAAaC,OAAO,GAAGP;QACvB,MAAMQ,sBAAsBR,sBAAAA,gCAAAA,UAAWS,IAAI;QAE3C,IAAI,CAACD,qBAAqB;YACtBH,mBAAmBE,OAAO,GAAG;YAC7BL,UAAU;YACVE,WAAW,EAAE;YACb;QACJ;QAEA,MAAMM,YAAYf;QAClBU,mBAAmBE,OAAO,GAAGG;QAC7BR,UAAU;QACVE,WAAW,EAAE;QAEb,MAAMO,YAAYC,WAAW;YACzB,IAAIP,mBAAmBE,OAAO,KAAKG,WAAW;gBAC1C;YACJ;YACAR,UAAU;YACVE,WAAW,EAAE;QACjB,GAAGN;QAEH,MAAMe,cAAchB,wBAAwBiB,CAAAA;YACxC,uEAAuE;YACvE,IAAIA,SAASJ,SAAS,KAAKA,aAAaL,mBAAmBE,OAAO,KAAKG,WAAW;gBAC9E;YACJ;YACAK,aAAaJ;YACb,IAAIG,SAASE,UAAU,EAAE;gBACrBZ,WAAW,EAAE;gBACbF,UAAU;gBACV;YACJ;YACA,IAAIY,SAASX,OAAO,CAACc,MAAM,GAAG,GAAG;gBAC7Bb,WAAWU,SAASX,OAAO;gBAC3BD,UAAU;YACd,OAAO;gBACHE,WAAW,EAAE;gBACbF,UAAU;YACd;QACJ;QAEAN,yBAAyB;YAAEc;YAAWV,WAAWQ;QAAoB;QAErE,OAAO;YACHO,aAAaJ;YACbE;QACJ;IACJ,GAAG;QAACb;KAAU;IAEd,MAAMkB,iBAAiB3B,YAAY,CAAC4B;YACJb;QAA5B,MAAME,uBAAsBF,wBAAAA,aAAaC,OAAO,cAApBD,4CAAAA,sBAAsBG,IAAI;QACtD,IAAI,CAACD,qBAAqB;YACtB,OAAOY,QAAQC,OAAO,CAAiB,EAAE;QAC7C;QAEA,OAAO,IAAID,QAAwBC,CAAAA;YAC/B,MAAMX,YAAYf;YAClB,IAAI2B,UAAU;YAEd,MAAMC,SAAS,CAACC;gBACZ,IAAIF,SAAS;oBACT;gBACJ;gBACAA,UAAU;gBACVP,aAAaJ;gBACbE;gBACAQ,QAAQG;YACZ;YAEA,MAAMb,YAAYC,WAAW,IAAMW,OAAO,EAAE,GAAGzB;YAE/C,MAAMe,cAAchB,wBAAwBiB,CAAAA;gBACxC,IAAIA,SAASJ,SAAS,KAAKA,WAAW;oBAClC;gBACJ;gBACAa,OAAOT,SAASX,OAAO;YAC3B;YAEAP,yBAAyB;gBAAEc;gBAAWV,WAAWQ;gBAAqB,GAAGW,OAAO;YAAC;QACrF;IACJ,GAAG,EAAE;IAEL,MAAMM,cAAclC,YAAY,CAACmC,QAAkBR,eAAe;YAAEQ;QAAM,IAAI;QAACR;KAAe;IAE9F,MAAMS,iBAAiBpC,YACnB,CAACqC,OAAmBV,eAAe;YAAEU;QAAK,IAC1C;QAACV;KAAe;IAGpB,OAAO;QAAEjB;QAAQE;QAASsB;QAAaE;IAAe;AAC1D,EAAE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/dte-unlayer",
3
- "version": "0.140.0",
3
+ "version": "0.142.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "typings": "./dist/index.d.ts",
@@ -1,11 +1,10 @@
1
- import { Button, Chip, Combobox, Flex, TextField } from '@servicetitan/anvil2';
1
+ import { Button, Chip, Combobox, Flex } from '@servicetitan/anvil2';
2
2
  import TrashIcon from '@servicetitan/anvil2/assets/icons/material/round/delete.svg';
3
3
  import { useEffect, useMemo, useState } from 'react';
4
4
  import { FormInfo, parseFormFieldKey } from '../shared/forms';
5
- import type { LookupOption } from './lookupOptionsController';
5
+ import { LookupValueControl } from './LookupValueControl';
6
6
  import { NUMBER_OPERATORS, SingleCondition, STRING_OPERATORS, VALUE_LESS_OPERATORS } from './types';
7
7
  import type { DataPointOption, FormFieldOption } from './types';
8
- import { useLookupOptions } from './useLookupOptions';
9
8
 
10
9
  export interface ConditionRowProps {
11
10
  canRemove: boolean;
@@ -29,21 +28,6 @@ interface SourceOption {
29
28
  value: string;
30
29
  }
31
30
 
32
- function sanitizeNumericInput(raw: string): string {
33
- // Allow only a single leading minus and one decimal separator.
34
- let value = raw.replaceAll(/[^0-9.-]/g, '');
35
- const isNegative = value.startsWith('-');
36
- value = value.replaceAll('-', '');
37
- if (isNegative) {
38
- value = `-${value}`;
39
- }
40
- const firstDot = value.indexOf('.');
41
- if (firstDot >= 0) {
42
- value = `${value.slice(0, firstDot + 1)}${value.slice(firstDot + 1).replaceAll('.', '')}`;
43
- }
44
- return value;
45
- }
46
-
47
31
  export function ConditionRow({
48
32
  canRemove,
49
33
  condition,
@@ -112,15 +96,6 @@ export function ConditionRow({
112
96
 
113
97
  const isValueLess = VALUE_LESS_OPERATORS.includes(condition.operator);
114
98
 
115
- // Value-less operators (is empty / is not empty) need no value, so skip the host lookup.
116
- const { options: lookupOptions, status: lookupStatus } = useLookupOptions(
117
- isValueLess ? undefined : selectedDataPoint?.lookupSource,
118
- );
119
- const selectedLookupOption = useMemo(
120
- () => lookupOptions.find(opt => opt.key === condition.value) ?? null,
121
- [lookupOptions, condition.value],
122
- );
123
-
124
99
  const operatorItems: OperatorOption[] = useMemo(
125
100
  () =>
126
101
  fieldType === 'number'
@@ -134,73 +109,6 @@ export function ConditionRow({
134
109
  [operatorItems, condition.operator],
135
110
  );
136
111
 
137
- const handleValueChange = (raw: string) => {
138
- const value = fieldType === 'number' ? sanitizeNumericInput(raw) : raw;
139
- onChange({ ...condition, value });
140
- };
141
-
142
- const renderValueControl = () => {
143
- if (lookupStatus === 'loading') {
144
- return (
145
- <TextField
146
- disabled
147
- label="Value"
148
- size="small"
149
- value=""
150
- placeholder=""
151
- style={{ width: '100%' }}
152
- aria-label="Value"
153
- />
154
- );
155
- }
156
-
157
- if (lookupStatus === 'ready') {
158
- return (
159
- <Combobox.Select
160
- disableClearSelection
161
- itemToKey={(item: LookupOption | null) => item?.key ?? ''}
162
- itemToString={(item: LookupOption | null) => item?.label ?? ''}
163
- items={lookupOptions}
164
- selectedItem={selectedLookupOption}
165
- onChange={(item: LookupOption | null) =>
166
- onChange({ ...condition, value: item?.key ?? '' })
167
- }
168
- >
169
- <Combobox.SelectTrigger
170
- label="Value"
171
- placeholder="Select value..."
172
- size="small"
173
- />
174
- <Combobox.Content>
175
- {({ items }: { items: LookupOption[] }) => (
176
- <Combobox.List>
177
- {items.map((item, i) => (
178
- <Combobox.Item index={i} item={item} key={item.key}>
179
- {item.label}
180
- </Combobox.Item>
181
- ))}
182
- </Combobox.List>
183
- )}
184
- </Combobox.Content>
185
- </Combobox.Select>
186
- );
187
- }
188
-
189
- // idle / fallback (empty/error/timeout) -> never block the user.
190
- return (
191
- <TextField
192
- label="Value"
193
- size="small"
194
- value={condition.value}
195
- onChange={e => handleValueChange(e.target.value)}
196
- placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
197
- style={{ width: '100%' }}
198
- aria-label="Value"
199
- {...(fieldType === 'number' && { inputMode: 'decimal' })}
200
- />
201
- );
202
- };
203
-
204
112
  const handleDataPointChange = (item: DataPointOption | null) => {
205
113
  const nextIsNumber = item?.fieldType === 'number';
206
114
  const nextOperatorItems = nextIsNumber ? NUMBER_OPERATORS : STRING_OPERATORS;
@@ -374,7 +282,14 @@ export function ConditionRow({
374
282
  </Combobox.Select>
375
283
  </div>
376
284
  {!isValueLess && (
377
- <div style={{ flex: '1 1 180px', minWidth: 150 }}>{renderValueControl()}</div>
285
+ <div style={{ flex: '1 1 180px', minWidth: 150 }}>
286
+ <LookupValueControl
287
+ value={condition.value}
288
+ fieldType={fieldType}
289
+ lookupSource={selectedDataPoint?.lookupSource}
290
+ onChange={value => onChange({ ...condition, value })}
291
+ />
292
+ </div>
378
293
  )}
379
294
  {canRemove && (
380
295
  <div
@@ -0,0 +1,164 @@
1
+ import { TextField } from '@servicetitan/anvil2';
2
+ import { SelectField, SelectFieldOption, SelectFieldSync } from '@servicetitan/anvil2/beta';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { LookupOption } from './lookupOptionsController';
5
+ import { useLookupOptions } from './useLookupOptions';
6
+
7
+ export interface LookupValueControlProps {
8
+ value: string;
9
+ fieldType: 'number' | 'string';
10
+ /** The selected data point's `options.fieldLookupSource`; empty -> free-text input. */
11
+ lookupSource?: string;
12
+ onChange: (value: string) => void;
13
+ }
14
+
15
+ const toOption = (option: LookupOption): SelectFieldOption => ({
16
+ id: option.key,
17
+ label: option.label,
18
+ });
19
+
20
+ const sanitizeNumericInput = (raw: string): string => {
21
+ let value = raw.replaceAll(/[^0-9.-]/g, '');
22
+ const isNegative = value.startsWith('-');
23
+ value = value.replaceAll('-', '');
24
+ if (isNegative) {
25
+ value = `-${value}`;
26
+ }
27
+ const firstDot = value.indexOf('.');
28
+ if (firstDot >= 0) {
29
+ value = `${value.slice(0, firstDot + 1)}${value.slice(firstDot + 1).replaceAll('.', '')}`;
30
+ }
31
+ return value;
32
+ };
33
+
34
+ /**
35
+ * Renders the value control for a single condition based on the field's lookup source:
36
+ * - `static` -> full list returned by the host, filtered locally (`SelectFieldSync`).
37
+ * - `searchable` -> host searches per typed query (`SelectField` + `loadOptions`).
38
+ * - `idle`/`fallback`/`loading` -> free-text input (never block the user).
39
+ *
40
+ * Unlayer persists conditions as a Nunjucks string, so a saved searchable value
41
+ * has no stored label — we display the saved key as the label until the user
42
+ * re-searches and re-selects.
43
+ */
44
+ export function LookupValueControl({
45
+ fieldType,
46
+ lookupSource,
47
+ onChange,
48
+ value,
49
+ }: Readonly<LookupValueControlProps>) {
50
+ const { loadOptions, options, resolveOptions, status } = useLookupOptions(lookupSource);
51
+
52
+ const staticSelected = useMemo<SelectFieldOption | null>(
53
+ () => options.filter(option => option.key === value).map(toOption)[0] ?? null,
54
+ [options, value],
55
+ );
56
+
57
+ const [searchableSelected, setSearchableSelected] = useState<SelectFieldOption | null>(
58
+ value ? { id: value, label: value } : null,
59
+ );
60
+
61
+ // Tracks the key we've already turned into a human-readable label so we don't re-resolve it.
62
+ const resolvedKeyRef = useRef<string | null>(null);
63
+
64
+ useEffect(() => {
65
+ setSearchableSelected(prev => {
66
+ if (!value) {
67
+ return null;
68
+ }
69
+ if (prev?.id === value) {
70
+ return prev;
71
+ }
72
+ return { id: value, label: value };
73
+ });
74
+ }, [value]);
75
+
76
+ // On open (or when a saved searchable value appears), ask the host to resolve key -> label.
77
+ useEffect(() => {
78
+ if (status !== 'searchable' || !value) {
79
+ resolvedKeyRef.current = null;
80
+ return;
81
+ }
82
+ if (resolvedKeyRef.current === value) {
83
+ return;
84
+ }
85
+
86
+ let cancelled = false;
87
+ resolveOptions([value]).then(result => {
88
+ if (cancelled) {
89
+ return;
90
+ }
91
+ const match = result.find(option => option.key === value);
92
+ if (match) {
93
+ resolvedKeyRef.current = value;
94
+ setSearchableSelected({ id: match.key, label: match.label });
95
+ }
96
+ });
97
+
98
+ return () => {
99
+ cancelled = true;
100
+ };
101
+ }, [status, value, resolveOptions]);
102
+
103
+ const handleLoadOptions = useCallback(
104
+ async (query: string) => {
105
+ const result = await loadOptions(query);
106
+ return result.map(toOption);
107
+ },
108
+ [loadOptions],
109
+ );
110
+
111
+ if (status === 'loading') {
112
+ return (
113
+ <TextField label="Value" flex={1} disabled value="" placeholder="" aria-label="Value" />
114
+ );
115
+ }
116
+
117
+ if (status === 'static') {
118
+ return (
119
+ <SelectFieldSync
120
+ flex={1}
121
+ label="Value"
122
+ placeholder="Search value..."
123
+ options={options.map(toOption)}
124
+ value={staticSelected}
125
+ onSelectedOptionChange={option => onChange(option ? String(option.id) : '')}
126
+ />
127
+ );
128
+ }
129
+
130
+ if (status === 'searchable') {
131
+ return (
132
+ <SelectField
133
+ flex={1}
134
+ label="Value"
135
+ placeholder="Type to search..."
136
+ initialLoad="open"
137
+ loadOptions={handleLoadOptions}
138
+ value={searchableSelected}
139
+ onSelectedOptionChange={option => {
140
+ resolvedKeyRef.current = option ? String(option.id) : null;
141
+ setSearchableSelected(option);
142
+ onChange(option ? String(option.id) : '');
143
+ }}
144
+ />
145
+ );
146
+ }
147
+
148
+ // idle / fallback (no source, empty, error, or timeout) -> free text.
149
+ const handleTextChange = (raw: string) => {
150
+ onChange(fieldType === 'number' ? sanitizeNumericInput(raw) : raw);
151
+ };
152
+
153
+ return (
154
+ <TextField
155
+ label="Value"
156
+ flex={1}
157
+ value={value}
158
+ onChange={e => handleTextChange(e.target.value)}
159
+ placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
160
+ aria-label="Value"
161
+ {...(fieldType === 'number' && { inputMode: 'decimal' })}
162
+ />
163
+ );
164
+ }
@@ -6,6 +6,16 @@
6
6
  * UI, the editor does NOT own the option data — it asks the HOST for it over the
7
7
  * EventTarget bus below and renders whatever the host returns.
8
8
  *
9
+ * Two host-driven modes are supported (the host decides, the editor adapts):
10
+ * 1. Full-list source (e.g. "state", "jobType"): the host returns every option
11
+ * on the first request and omits `searchable`. The editor caches that list
12
+ * and filters it LOCALLY as the user types — no further requests are sent.
13
+ * 2. Searchable source (e.g. "city"): the list is too large to send at once.
14
+ * The host returns `searchable: true` (typically with an empty list on the
15
+ * first, query-less request). The editor then re-requests with the typed
16
+ * `query` on each keystroke and the host returns a fresh, server-filtered
17
+ * list per query.
18
+ *
9
19
  * Transport mirrors the existing `displayConditionController` EventBus pattern.
10
20
  *
11
21
  * Event names (host <-> editor):
@@ -31,28 +41,37 @@ export interface LookupOptionsRequest {
31
41
  requestId: string;
32
42
  /** The selected field's `options.fieldLookupSource`. */
33
43
  sourceKey: string;
34
- /** Reserved for searchable sources (e.g. "city"); omitted for now. */
44
+ /**
45
+ * The text the user has typed. Sent on each (debounced) keystroke once the
46
+ * host has declared the source `searchable`. Empty/omitted on the first
47
+ * request, where the host decides whether the source is searchable.
48
+ */
35
49
  query?: string;
50
+ /**
51
+ * Resolve mode: the editor is asking the host to return the option(s) for
52
+ * these EXACT stored keys (not a text search). Used on open to turn a saved
53
+ * searchable value (a bare key) back into a human-readable label. When
54
+ * present the host should match by key and ignore `query`.
55
+ */
56
+ keys?: string[];
36
57
  }
37
58
 
38
59
  /** host -> editor */
39
60
  export interface LookupOptionsResponse {
40
61
  requestId: string;
41
62
  options: LookupOption[];
63
+ /**
64
+ * Set to `true` by the host when the source must be searched per query
65
+ * (server-side). When omitted/false the editor treats `options` as the
66
+ * complete list and switches to local filtering. Only read from the first
67
+ * (query-less) response for a given source.
68
+ */
69
+ searchable?: boolean;
42
70
  }
43
71
 
44
72
  export const LOOKUP_OPTIONS_REQUEST_EVENT = 'dte-unlayer:lookup-options-request';
45
73
  export const LOOKUP_OPTIONS_RESPONSE_EVENT = 'dte-unlayer:lookup-options-response';
46
74
 
47
- /**
48
- * Searchable sources require the host to query an API per typed query. These are
49
- * skipped for now and fall back to the free-text input.
50
- */
51
- export const SEARCHABLE_LOOKUP_SOURCES = ['city'] as const;
52
-
53
- export const isSearchableLookupSource = (sourceKey: string | undefined): boolean =>
54
- !!sourceKey && (SEARCHABLE_LOOKUP_SOURCES as readonly string[]).includes(sourceKey);
55
-
56
75
  const lookupOptionsEvents = new EventTarget();
57
76
 
58
77
  /** editor -> host: ask the host to resolve options for a lookup source. */