@servicetitan/dte-unlayer 0.141.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.
- package/dist/display-conditions/ConditionRow.d.ts.map +1 -1
- package/dist/display-conditions/ConditionRow.js +11 -105
- package/dist/display-conditions/ConditionRow.js.map +1 -1
- package/dist/display-conditions/LookupValueControl.d.ts +19 -0
- package/dist/display-conditions/LookupValueControl.d.ts.map +1 -0
- package/dist/display-conditions/LookupValueControl.js +154 -0
- package/dist/display-conditions/LookupValueControl.js.map +1 -0
- package/dist/display-conditions/lookupOptionsController.d.ts +29 -7
- package/dist/display-conditions/lookupOptionsController.d.ts.map +1 -1
- package/dist/display-conditions/lookupOptionsController.js +10 -7
- package/dist/display-conditions/lookupOptionsController.js.map +1 -1
- package/dist/display-conditions/useLookupOptions.d.ts +24 -8
- package/dist/display-conditions/useLookupOptions.d.ts.map +1 -1
- package/dist/display-conditions/useLookupOptions.js +63 -10
- package/dist/display-conditions/useLookupOptions.js.map +1 -1
- package/package.json +1 -1
- package/src/display-conditions/ConditionRow.tsx +10 -95
- package/src/display-conditions/LookupValueControl.tsx +164 -0
- package/src/display-conditions/lookupOptionsController.ts +29 -10
- package/src/display-conditions/useLookupOptions.ts +78 -15
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
import { LookupOption } from './lookupOptionsController';
|
|
2
2
|
/**
|
|
3
|
-
* - `idle`
|
|
4
|
-
* - `loading`
|
|
5
|
-
* - `
|
|
6
|
-
* - `
|
|
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' | '
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
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('
|
|
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
|
|
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,11 +1,10 @@
|
|
|
1
|
-
import { Button, Chip, Combobox, Flex
|
|
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
|
|
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
|
|
160
|
-
itemToKey={(item: LookupOption | null) => item?.key ?? ''}
|
|
161
|
-
itemToString={(item: LookupOption | null) => item?.label ?? ''}
|
|
162
|
-
items={lookupOptions}
|
|
163
|
-
selectedItem={selectedLookupOption}
|
|
164
|
-
onChange={(item: LookupOption | null) =>
|
|
165
|
-
onChange({ ...condition, value: item?.key ?? '' })
|
|
166
|
-
}
|
|
167
|
-
filterOptions={{ keys: ['label'] }}
|
|
168
|
-
>
|
|
169
|
-
<Combobox.SearchField
|
|
170
|
-
label="Value"
|
|
171
|
-
placeholder="Search 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>
|
|
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 }}>
|
|
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
|
-
/**
|
|
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. */
|