@servicetitan/dte-unlayer 0.141.0 → 0.143.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 +28 -114
- 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 +158 -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 +18 -99
- package/src/display-conditions/LookupValueControl.tsx +175 -0
- package/src/display-conditions/lookupOptionsController.ts +29 -10
- package/src/display-conditions/useLookupOptions.ts +78 -15
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/display-conditions/lookupOptionsController.ts"],"sourcesContent":["/**\n * Host <-> editor contract for \"data-model lookup source\" dropdown options.\n *\n * A schema field can be tagged (in the template-type editor) with\n * `options.fieldLookupSource`. When such a field is selected in the conditional\n * UI, the editor does NOT own the option data — it asks the HOST for it over the\n * EventTarget bus below and renders whatever the host returns.\n *\n * Transport mirrors the existing `displayConditionController` EventBus pattern.\n *\n * Event names (host <-> editor):\n * - \"dte-unlayer:lookup-options-request\" (editor -> host)\n * - \"dte-unlayer:lookup-options-response\" (host -> editor)\n *\n * The host-side resolver is intentionally NOT implemented in this package. The\n * host listens via `onLookupOptionsRequest` and replies via\n * `emitLookupOptionsResponse`.\n */\n\n/** The single dropdown option shape used end-to-end. */\nexport interface LookupOption {\n /** Human-readable label shown in the dropdown. */\n label: string;\n /** Stable value baked into the stored condition. */\n key: string;\n}\n\n/** editor -> host */\nexport interface LookupOptionsRequest {\n /** Correlates a response to its originating request. */\n requestId: string;\n /** The selected field's `options.fieldLookupSource`. */\n sourceKey: string;\n
|
|
1
|
+
{"version":3,"sources":["../../src/display-conditions/lookupOptionsController.ts"],"sourcesContent":["/**\n * Host <-> editor contract for \"data-model lookup source\" dropdown options.\n *\n * A schema field can be tagged (in the template-type editor) with\n * `options.fieldLookupSource`. When such a field is selected in the conditional\n * UI, the editor does NOT own the option data — it asks the HOST for it over the\n * EventTarget bus below and renders whatever the host returns.\n *\n * Two host-driven modes are supported (the host decides, the editor adapts):\n * 1. Full-list source (e.g. \"state\", \"jobType\"): the host returns every option\n * on the first request and omits `searchable`. The editor caches that list\n * and filters it LOCALLY as the user types — no further requests are sent.\n * 2. Searchable source (e.g. \"city\"): the list is too large to send at once.\n * The host returns `searchable: true` (typically with an empty list on the\n * first, query-less request). The editor then re-requests with the typed\n * `query` on each keystroke and the host returns a fresh, server-filtered\n * list per query.\n *\n * Transport mirrors the existing `displayConditionController` EventBus pattern.\n *\n * Event names (host <-> editor):\n * - \"dte-unlayer:lookup-options-request\" (editor -> host)\n * - \"dte-unlayer:lookup-options-response\" (host -> editor)\n *\n * The host-side resolver is intentionally NOT implemented in this package. The\n * host listens via `onLookupOptionsRequest` and replies via\n * `emitLookupOptionsResponse`.\n */\n\n/** The single dropdown option shape used end-to-end. */\nexport interface LookupOption {\n /** Human-readable label shown in the dropdown. */\n label: string;\n /** Stable value baked into the stored condition. */\n key: string;\n}\n\n/** editor -> host */\nexport interface LookupOptionsRequest {\n /** Correlates a response to its originating request. */\n requestId: string;\n /** The selected field's `options.fieldLookupSource`. */\n sourceKey: string;\n /**\n * The text the user has typed. Sent on each (debounced) keystroke once the\n * host has declared the source `searchable`. Empty/omitted on the first\n * request, where the host decides whether the source is searchable.\n */\n query?: string;\n /**\n * Resolve mode: the editor is asking the host to return the option(s) for\n * these EXACT stored keys (not a text search). Used on open to turn a saved\n * searchable value (a bare key) back into a human-readable label. When\n * present the host should match by key and ignore `query`.\n */\n keys?: string[];\n}\n\n/** host -> editor */\nexport interface LookupOptionsResponse {\n requestId: string;\n options: LookupOption[];\n /**\n * Set to `true` by the host when the source must be searched per query\n * (server-side). When omitted/false the editor treats `options` as the\n * complete list and switches to local filtering. Only read from the first\n * (query-less) response for a given source.\n */\n searchable?: boolean;\n}\n\nexport const LOOKUP_OPTIONS_REQUEST_EVENT = 'dte-unlayer:lookup-options-request';\nexport const LOOKUP_OPTIONS_RESPONSE_EVENT = 'dte-unlayer:lookup-options-response';\n\nconst lookupOptionsEvents = new EventTarget();\n\n/** editor -> host: ask the host to resolve options for a lookup source. */\nexport const emitLookupOptionsRequest = (request: LookupOptionsRequest) => {\n lookupOptionsEvents.dispatchEvent(\n new CustomEvent<LookupOptionsRequest>(LOOKUP_OPTIONS_REQUEST_EVENT, { detail: request }),\n );\n};\n\n/** host side: listen for editor requests. Returns an unsubscribe function. */\nexport const onLookupOptionsRequest = (listener: (request: LookupOptionsRequest) => void) => {\n const handler = (event: Event) => {\n const detail = (event as CustomEvent<LookupOptionsRequest>).detail;\n if (!detail) {\n return;\n }\n listener(detail);\n };\n\n lookupOptionsEvents.addEventListener(LOOKUP_OPTIONS_REQUEST_EVENT, handler);\n\n return () => {\n lookupOptionsEvents.removeEventListener(LOOKUP_OPTIONS_REQUEST_EVENT, handler);\n };\n};\n\n/** host -> editor: deliver resolved options back to the editor. */\nexport const emitLookupOptionsResponse = (response: LookupOptionsResponse) => {\n lookupOptionsEvents.dispatchEvent(\n new CustomEvent<LookupOptionsResponse>(LOOKUP_OPTIONS_RESPONSE_EVENT, { detail: response }),\n );\n};\n\n/** editor side: listen for host responses. Returns an unsubscribe function. */\nexport const onLookupOptionsResponse = (listener: (response: LookupOptionsResponse) => void) => {\n const handler = (event: Event) => {\n const detail = (event as CustomEvent<LookupOptionsResponse>).detail;\n if (!detail) {\n return;\n }\n listener(detail);\n };\n\n lookupOptionsEvents.addEventListener(LOOKUP_OPTIONS_RESPONSE_EVENT, handler);\n\n return () => {\n lookupOptionsEvents.removeEventListener(LOOKUP_OPTIONS_RESPONSE_EVENT, handler);\n };\n};\n"],"names":["LOOKUP_OPTIONS_REQUEST_EVENT","LOOKUP_OPTIONS_RESPONSE_EVENT","lookupOptionsEvents","EventTarget","emitLookupOptionsRequest","request","dispatchEvent","CustomEvent","detail","onLookupOptionsRequest","listener","handler","event","addEventListener","removeEventListener","emitLookupOptionsResponse","response","onLookupOptionsResponse"],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GAED,sDAAsD,GA0CtD,OAAO,MAAMA,+BAA+B,qCAAqC;AACjF,OAAO,MAAMC,gCAAgC,sCAAsC;AAEnF,MAAMC,sBAAsB,IAAIC;AAEhC,yEAAyE,GACzE,OAAO,MAAMC,2BAA2B,CAACC;IACrCH,oBAAoBI,aAAa,CAC7B,IAAIC,YAAkCP,8BAA8B;QAAEQ,QAAQH;IAAQ;AAE9F,EAAE;AAEF,4EAA4E,GAC5E,OAAO,MAAMI,yBAAyB,CAACC;IACnC,MAAMC,UAAU,CAACC;QACb,MAAMJ,SAAS,AAACI,MAA4CJ,MAAM;QAClE,IAAI,CAACA,QAAQ;YACT;QACJ;QACAE,SAASF;IACb;IAEAN,oBAAoBW,gBAAgB,CAACb,8BAA8BW;IAEnE,OAAO;QACHT,oBAAoBY,mBAAmB,CAACd,8BAA8BW;IAC1E;AACJ,EAAE;AAEF,iEAAiE,GACjE,OAAO,MAAMI,4BAA4B,CAACC;IACtCd,oBAAoBI,aAAa,CAC7B,IAAIC,YAAmCN,+BAA+B;QAAEO,QAAQQ;IAAS;AAEjG,EAAE;AAEF,6EAA6E,GAC7E,OAAO,MAAMC,0BAA0B,CAACP;IACpC,MAAMC,UAAU,CAACC;QACb,MAAMJ,SAAS,AAACI,MAA6CJ,MAAM;QACnE,IAAI,CAACA,QAAQ;YACT;QACJ;QACAE,SAASF;IACb;IAEAN,oBAAoBW,gBAAgB,CAACZ,+BAA+BU;IAEpE,OAAO;QACHT,oBAAoBY,mBAAmB,CAACb,+BAA+BU;IAC3E;AACJ,EAAE"}
|
|
@@ -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;
|
|
@@ -311,8 +219,10 @@ export function ConditionRow({
|
|
|
311
219
|
>
|
|
312
220
|
<Chip label="IF" size="small" />
|
|
313
221
|
</div>
|
|
314
|
-
<
|
|
222
|
+
<Flex flex={1} style={{ minWidth: 160 }}>
|
|
315
223
|
<Combobox.Select
|
|
224
|
+
flex={1}
|
|
225
|
+
style={{ width: '100%' }}
|
|
316
226
|
disableClearSelection
|
|
317
227
|
itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
|
|
318
228
|
itemToString={(item: DataPointOption | null) => item?.title ?? ''}
|
|
@@ -339,9 +249,11 @@ export function ConditionRow({
|
|
|
339
249
|
)}
|
|
340
250
|
</Combobox.Content>
|
|
341
251
|
</Combobox.Select>
|
|
342
|
-
</
|
|
343
|
-
<
|
|
252
|
+
</Flex>
|
|
253
|
+
<Flex flex={1} style={{ minWidth: 160 }}>
|
|
344
254
|
<Combobox.Select
|
|
255
|
+
flex={1}
|
|
256
|
+
style={{ width: '100%' }}
|
|
345
257
|
disableClearSelection
|
|
346
258
|
itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
|
|
347
259
|
itemToString={(item: OperatorOption | null) => item?.label ?? ''}
|
|
@@ -372,9 +284,16 @@ export function ConditionRow({
|
|
|
372
284
|
)}
|
|
373
285
|
</Combobox.Content>
|
|
374
286
|
</Combobox.Select>
|
|
375
|
-
</
|
|
287
|
+
</Flex>
|
|
376
288
|
{!isValueLess && (
|
|
377
|
-
<
|
|
289
|
+
<Flex flex={1} style={{ minWidth: 160 }}>
|
|
290
|
+
<LookupValueControl
|
|
291
|
+
value={condition.value}
|
|
292
|
+
fieldType={fieldType}
|
|
293
|
+
lookupSource={selectedDataPoint?.lookupSource}
|
|
294
|
+
onChange={value => onChange({ ...condition, value })}
|
|
295
|
+
/>
|
|
296
|
+
</Flex>
|
|
378
297
|
)}
|
|
379
298
|
{canRemove && (
|
|
380
299
|
<div
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
|
114
|
+
label="Value"
|
|
115
|
+
size="small"
|
|
116
|
+
flex={1}
|
|
117
|
+
disabled
|
|
118
|
+
value=""
|
|
119
|
+
placeholder=""
|
|
120
|
+
aria-label="Value"
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (status === 'static') {
|
|
126
|
+
return (
|
|
127
|
+
<SelectFieldSync
|
|
128
|
+
flex={1}
|
|
129
|
+
size="small"
|
|
130
|
+
label="Value"
|
|
131
|
+
placeholder="Search value..."
|
|
132
|
+
options={options.map(toOption)}
|
|
133
|
+
value={staticSelected}
|
|
134
|
+
onSelectedOptionChange={option => onChange(option ? String(option.id) : '')}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (status === 'searchable') {
|
|
140
|
+
return (
|
|
141
|
+
<SelectField
|
|
142
|
+
flex={1}
|
|
143
|
+
size="small"
|
|
144
|
+
label="Value"
|
|
145
|
+
placeholder="Type to search..."
|
|
146
|
+
initialLoad="open"
|
|
147
|
+
loadOptions={handleLoadOptions}
|
|
148
|
+
value={searchableSelected}
|
|
149
|
+
onSelectedOptionChange={option => {
|
|
150
|
+
resolvedKeyRef.current = option ? String(option.id) : null;
|
|
151
|
+
setSearchableSelected(option);
|
|
152
|
+
onChange(option ? String(option.id) : '');
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// idle / fallback (no source, empty, error, or timeout) -> free text.
|
|
159
|
+
const handleTextChange = (raw: string) => {
|
|
160
|
+
onChange(fieldType === 'number' ? sanitizeNumericInput(raw) : raw);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<TextField
|
|
165
|
+
label="Value"
|
|
166
|
+
size="small"
|
|
167
|
+
flex={1}
|
|
168
|
+
value={value}
|
|
169
|
+
onChange={e => handleTextChange(e.target.value)}
|
|
170
|
+
placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
|
|
171
|
+
aria-label="Value"
|
|
172
|
+
{...(fieldType === 'number' && { inputMode: 'decimal' })}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -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. */
|