@servicetitan/dte-unlayer 0.122.0 → 0.124.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.
Files changed (47) hide show
  1. package/dist/display-conditions/ConditionGroup.d.ts +6 -2
  2. package/dist/display-conditions/ConditionGroup.d.ts.map +1 -1
  3. package/dist/display-conditions/ConditionGroup.js +8 -3
  4. package/dist/display-conditions/ConditionGroup.js.map +1 -1
  5. package/dist/display-conditions/ConditionGroupsSection.d.ts +6 -2
  6. package/dist/display-conditions/ConditionGroupsSection.d.ts.map +1 -1
  7. package/dist/display-conditions/ConditionGroupsSection.js +4 -1
  8. package/dist/display-conditions/ConditionGroupsSection.js.map +1 -1
  9. package/dist/display-conditions/ConditionRow.d.ts +6 -2
  10. package/dist/display-conditions/ConditionRow.d.ts.map +1 -1
  11. package/dist/display-conditions/ConditionRow.js +240 -110
  12. package/dist/display-conditions/ConditionRow.js.map +1 -1
  13. package/dist/display-conditions/DisplayConditionModal.d.ts +3 -0
  14. package/dist/display-conditions/DisplayConditionModal.d.ts.map +1 -1
  15. package/dist/display-conditions/DisplayConditionModal.js +123 -8
  16. package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
  17. package/dist/display-conditions/nunjucks.d.ts.map +1 -1
  18. package/dist/display-conditions/nunjucks.js +17 -6
  19. package/dist/display-conditions/nunjucks.js.map +1 -1
  20. package/dist/display-conditions/types.d.ts +3 -0
  21. package/dist/display-conditions/types.d.ts.map +1 -1
  22. package/dist/display-conditions/types.js.map +1 -1
  23. package/dist/editor-core-source.d.ts +1 -1
  24. package/dist/editor-core-source.d.ts.map +1 -1
  25. package/dist/editor-core-source.js +1 -1
  26. package/dist/editor-core-source.js.map +1 -1
  27. package/dist/editor.d.ts.map +1 -1
  28. package/dist/editor.js +3 -1
  29. package/dist/editor.js.map +1 -1
  30. package/dist/shared/forms.d.ts +26 -0
  31. package/dist/shared/forms.d.ts.map +1 -1
  32. package/dist/shared/forms.js +75 -1
  33. package/dist/shared/forms.js.map +1 -1
  34. package/dist/unlayer-interface.d.ts +2 -0
  35. package/dist/unlayer-interface.d.ts.map +1 -1
  36. package/dist/unlayer-interface.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/display-conditions/ConditionGroup.tsx +12 -3
  39. package/src/display-conditions/ConditionGroupsSection.tsx +11 -1
  40. package/src/display-conditions/ConditionRow.tsx +229 -86
  41. package/src/display-conditions/DisplayConditionModal.tsx +138 -5
  42. package/src/display-conditions/nunjucks.ts +18 -9
  43. package/src/display-conditions/types.ts +4 -0
  44. package/src/editor-core-source.ts +1 -1
  45. package/src/editor.tsx +7 -1
  46. package/src/shared/forms.ts +85 -0
  47. package/src/unlayer-interface.tsx +9 -0
@@ -1,14 +1,18 @@
1
1
  import { Button, Chip, Combobox, Flex, TextField } from '@servicetitan/anvil2';
2
2
  import TrashIcon from '@servicetitan/anvil2/assets/icons/material/round/delete.svg';
3
- import { useMemo } from 'react';
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { FormInfo, parseFormFieldKey } from '../shared/forms';
4
5
  import { NUMBER_OPERATORS, SingleCondition, STRING_OPERATORS, VALUE_LESS_OPERATORS } from './types';
5
- import type { DataPointOption } from './types';
6
+ import type { DataPointOption, FormFieldOption } from './types';
6
7
 
7
8
  export interface ConditionRowProps {
8
9
  canRemove: boolean;
9
10
  condition: SingleCondition;
10
11
  dataPointOptions: DataPointOption[];
12
+ formFieldOptions: FormFieldOption[];
13
+ forms: FormInfo[];
11
14
  onChange: (c: SingleCondition) => void;
15
+ onFormSelect: (formId: number) => void;
12
16
  onRemove: () => void;
13
17
  }
14
18
 
@@ -17,6 +21,12 @@ interface OperatorOption {
17
21
  value: string;
18
22
  }
19
23
 
24
+ interface SourceOption {
25
+ formId?: number;
26
+ label: string;
27
+ value: string;
28
+ }
29
+
20
30
  function sanitizeNumericInput(raw: string): string {
21
31
  // Allow only a single leading minus and one decimal separator.
22
32
  let value = raw.replaceAll(/[^0-9.-]/g, '');
@@ -36,12 +46,64 @@ export function ConditionRow({
36
46
  canRemove,
37
47
  condition,
38
48
  dataPointOptions,
49
+ formFieldOptions,
50
+ forms,
39
51
  onChange,
52
+ onFormSelect,
40
53
  onRemove,
41
54
  }: Readonly<ConditionRowProps>) {
55
+ const selectedFormFieldMeta = useMemo(
56
+ () => parseFormFieldKey(condition.dataPointKey),
57
+ [condition.dataPointKey],
58
+ );
59
+ const [selectedSourceValue, setSelectedSourceValue] = useState<string>(
60
+ selectedFormFieldMeta ? `form:${selectedFormFieldMeta.formId}` : 'schema',
61
+ );
62
+
63
+ const sourceOptions = useMemo<SourceOption[]>(
64
+ () => [
65
+ { label: 'Data point', value: 'schema' },
66
+ ...forms.map(form => ({
67
+ formId: form.id,
68
+ label: form.name,
69
+ value: `form:${form.id}`,
70
+ })),
71
+ ],
72
+ [forms],
73
+ );
74
+
75
+ const selectedSource = useMemo<SourceOption | null>(() => {
76
+ return (
77
+ sourceOptions.find(opt => opt.value === selectedSourceValue) ?? sourceOptions[0] ?? null
78
+ );
79
+ }, [selectedSourceValue, sourceOptions]);
80
+
81
+ useEffect(() => {
82
+ if (selectedFormFieldMeta) {
83
+ setSelectedSourceValue(`form:${selectedFormFieldMeta.formId}`);
84
+ return;
85
+ }
86
+ if (!condition.dataPointKey) {
87
+ return;
88
+ }
89
+ const hasSchemaMatch = dataPointOptions.some(opt => opt.fullKey === condition.dataPointKey);
90
+ if (hasSchemaMatch) {
91
+ setSelectedSourceValue('schema');
92
+ }
93
+ }, [condition.dataPointKey, dataPointOptions, selectedFormFieldMeta]);
94
+
95
+ const currentFormId = selectedSource?.formId;
96
+ const activeDataPointOptions = useMemo(
97
+ () =>
98
+ currentFormId
99
+ ? formFieldOptions.filter(option => option.formId === currentFormId)
100
+ : dataPointOptions,
101
+ [currentFormId, dataPointOptions, formFieldOptions],
102
+ );
103
+
42
104
  const selectedDataPoint = useMemo(
43
- () => dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey) ?? null,
44
- [dataPointOptions, condition.dataPointKey],
105
+ () => activeDataPointOptions.find(opt => opt.fullKey === condition.dataPointKey) ?? null,
106
+ [activeDataPointOptions, condition.dataPointKey],
45
107
  );
46
108
 
47
109
  const fieldType = selectedDataPoint?.fieldType ?? 'string';
@@ -67,8 +129,7 @@ export function ConditionRow({
67
129
  };
68
130
 
69
131
  const handleDataPointChange = (item: DataPointOption | null) => {
70
- const nextFieldType = item?.fieldType ?? 'string';
71
- const nextIsNumber = nextFieldType === 'number';
132
+ const nextIsNumber = item?.fieldType === 'number';
72
133
  const nextOperatorItems = nextIsNumber ? NUMBER_OPERATORS : STRING_OPERATORS;
73
134
  const operatorReset = !nextOperatorItems.some(op => op.value === condition.operator);
74
135
  onChange({
@@ -82,71 +143,74 @@ export function ConditionRow({
82
143
  });
83
144
  };
84
145
 
146
+ const handleSourceChange = (item: SourceOption | null) => {
147
+ if (!item) {
148
+ return;
149
+ }
150
+ setSelectedSourceValue(item.value);
151
+
152
+ if (!item.formId) {
153
+ const schemaField =
154
+ dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey) ?? null;
155
+ const nextFieldType = schemaField?.fieldType ?? 'string';
156
+ const nextIsNumber = nextFieldType === 'number';
157
+ const nextOperatorItems = nextIsNumber ? NUMBER_OPERATORS : STRING_OPERATORS;
158
+ const operatorReset = !nextOperatorItems.some(op => op.value === condition.operator);
159
+
160
+ onChange({
161
+ ...condition,
162
+ dataPointKey: schemaField?.fullKey ?? '',
163
+ operator: operatorReset
164
+ ? nextIsNumber
165
+ ? 'num_eq'
166
+ : 'is_equal_to'
167
+ : condition.operator,
168
+ });
169
+ return;
170
+ }
171
+
172
+ onFormSelect(item.formId);
173
+ const nextFormFieldOptions = formFieldOptions.filter(
174
+ option => option.formId === item.formId,
175
+ );
176
+ const currentSelected = nextFormFieldOptions.find(
177
+ opt => opt.fullKey === condition.dataPointKey,
178
+ );
179
+ const nextSelected = currentSelected ?? nextFormFieldOptions[0] ?? null;
180
+ const nextFieldType = nextSelected?.fieldType ?? 'string';
181
+ const nextIsNumber = nextFieldType === 'number';
182
+ const nextOperatorItems = nextIsNumber ? NUMBER_OPERATORS : STRING_OPERATORS;
183
+ const operatorReset = !nextOperatorItems.some(op => op.value === condition.operator);
184
+
185
+ onChange({
186
+ ...condition,
187
+ dataPointKey: nextSelected?.fullKey ?? '',
188
+ operator: operatorReset
189
+ ? nextIsNumber
190
+ ? 'num_eq'
191
+ : 'is_equal_to'
192
+ : condition.operator,
193
+ });
194
+ };
195
+
85
196
  return (
86
- <Flex
87
- direction="row"
88
- alignItems="flex-end"
89
- gap="2"
90
- style={{ flexWrap: 'wrap', rowGap: 8, width: '100%' }}
91
- >
92
- <div
93
- style={{
94
- display: 'flex',
95
- alignItems: 'flex-end',
96
- flexShrink: 0,
97
- padding: '4px 8px',
98
- }}
99
- >
100
- <Chip label="IF" size="small" />
101
- </div>
102
- <div style={{ flex: '2 1 240px', minWidth: 200 }}>
103
- <Combobox
104
- {...({ disableClearSelection: true } as object)}
105
- itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
106
- itemToString={(item: DataPointOption | null) => item?.title ?? ''}
107
- items={dataPointOptions}
108
- selectedItem={selectedDataPoint}
109
- onChange={handleDataPointChange}
110
- >
111
- <Combobox.SelectTrigger
112
- label="Data point"
113
- placeholder="Select data point..."
114
- size="small"
115
- />
116
- <Combobox.Content>
117
- {({ items }: { items: DataPointOption[] }) => (
118
- <Combobox.List>
119
- {items.map((item, i) => (
120
- <Combobox.Item index={i} item={item} key={item.fullKey}>
121
- {item.title}
122
- </Combobox.Item>
123
- ))}
124
- </Combobox.List>
125
- )}
126
- </Combobox.Content>
127
- </Combobox>
128
- </div>
129
- <div style={{ flex: '1 1 180px', minWidth: 150 }}>
197
+ <Flex direction="column" gap="3" style={{ width: '100%' }}>
198
+ <div style={{ width: '100%' }}>
130
199
  <Combobox
131
200
  {...({ disableClearSelection: true } as object)}
132
- itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
133
- itemToString={(item: OperatorOption | null) => item?.label ?? ''}
134
- items={operatorItems}
135
- selectedItem={selectedOperator}
136
- onChange={(item: OperatorOption | null) =>
137
- onChange({
138
- ...condition,
139
- operator: (item?.value ?? 'is_equal_to') as SingleCondition['operator'],
140
- })
141
- }
201
+ itemToKey={(item: SourceOption | null) => item?.value ?? ''}
202
+ itemToString={(item: SourceOption | null) => item?.label ?? ''}
203
+ items={sourceOptions}
204
+ selectedItem={selectedSource}
205
+ onChange={handleSourceChange}
142
206
  >
143
207
  <Combobox.SelectTrigger
144
- label="Condition"
145
- placeholder="Select..."
208
+ label="Data Type"
209
+ placeholder="Select source..."
146
210
  size="small"
147
211
  />
148
212
  <Combobox.Content>
149
- {({ items }: { items: OperatorOption[] }) => (
213
+ {({ items }: { items: SourceOption[] }) => (
150
214
  <Combobox.List>
151
215
  {items.map((item, i) => (
152
216
  <Combobox.Item index={i} item={item} key={item.value}>
@@ -158,21 +222,12 @@ export function ConditionRow({
158
222
  </Combobox.Content>
159
223
  </Combobox>
160
224
  </div>
161
- {!isValueLess && (
162
- <div style={{ flex: '1 1 180px', minWidth: 150 }}>
163
- <TextField
164
- label="Value"
165
- size="small"
166
- value={condition.value}
167
- onChange={e => handleValueChange(e.target.value)}
168
- placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
169
- style={{ width: '100%' }}
170
- aria-label="Value"
171
- {...(fieldType === 'number' && { inputMode: 'decimal' })}
172
- />
173
- </div>
174
- )}
175
- {canRemove && (
225
+ <Flex
226
+ direction="row"
227
+ alignItems="flex-end"
228
+ gap="2"
229
+ style={{ flexWrap: 'wrap', rowGap: 8, width: '100%' }}
230
+ >
176
231
  <div
177
232
  style={{
178
233
  display: 'flex',
@@ -181,15 +236,103 @@ export function ConditionRow({
181
236
  padding: '4px 8px',
182
237
  }}
183
238
  >
184
- <Button
185
- appearance="secondary"
186
- aria-label="Remove condition"
187
- icon={{ before: TrashIcon }}
188
- size="small"
189
- onClick={onRemove}
190
- />
239
+ <Chip label="IF" size="small" />
240
+ </div>
241
+ <div style={{ flex: '2 1 240px', minWidth: 200 }}>
242
+ <Combobox
243
+ {...({ disableClearSelection: true } as object)}
244
+ itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
245
+ itemToString={(item: DataPointOption | null) => item?.title ?? ''}
246
+ items={activeDataPointOptions}
247
+ selectedItem={selectedDataPoint}
248
+ onChange={handleDataPointChange}
249
+ >
250
+ <Combobox.SelectTrigger
251
+ label="Data Point"
252
+ placeholder={
253
+ currentFormId ? 'Select form field...' : 'Select data point...'
254
+ }
255
+ size="small"
256
+ />
257
+ <Combobox.Content>
258
+ {({ items }: { items: DataPointOption[] }) => (
259
+ <Combobox.List>
260
+ {items.map((item, i) => (
261
+ <Combobox.Item index={i} item={item} key={item.fullKey}>
262
+ {item.title}
263
+ </Combobox.Item>
264
+ ))}
265
+ </Combobox.List>
266
+ )}
267
+ </Combobox.Content>
268
+ </Combobox>
269
+ </div>
270
+ <div style={{ flex: '1 1 180px', minWidth: 150 }}>
271
+ <Combobox
272
+ {...({ disableClearSelection: true } as object)}
273
+ itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
274
+ itemToString={(item: OperatorOption | null) => item?.label ?? ''}
275
+ items={operatorItems}
276
+ selectedItem={selectedOperator}
277
+ onChange={(item: OperatorOption | null) =>
278
+ onChange({
279
+ ...condition,
280
+ operator: (item?.value ??
281
+ 'is_equal_to') as SingleCondition['operator'],
282
+ })
283
+ }
284
+ >
285
+ <Combobox.SelectTrigger
286
+ label="Condition"
287
+ placeholder="Select..."
288
+ size="small"
289
+ />
290
+ <Combobox.Content>
291
+ {({ items }: { items: OperatorOption[] }) => (
292
+ <Combobox.List>
293
+ {items.map((item, i) => (
294
+ <Combobox.Item index={i} item={item} key={item.value}>
295
+ {item.label}
296
+ </Combobox.Item>
297
+ ))}
298
+ </Combobox.List>
299
+ )}
300
+ </Combobox.Content>
301
+ </Combobox>
191
302
  </div>
192
- )}
303
+ {!isValueLess && (
304
+ <div style={{ flex: '1 1 180px', minWidth: 150 }}>
305
+ <TextField
306
+ label="Value"
307
+ size="small"
308
+ value={condition.value}
309
+ onChange={e => handleValueChange(e.target.value)}
310
+ placeholder={fieldType === 'number' ? 'e.g. 1.5' : 'Enter value...'}
311
+ style={{ width: '100%' }}
312
+ aria-label="Value"
313
+ {...(fieldType === 'number' && { inputMode: 'decimal' })}
314
+ />
315
+ </div>
316
+ )}
317
+ {canRemove && (
318
+ <div
319
+ style={{
320
+ display: 'flex',
321
+ alignItems: 'flex-end',
322
+ flexShrink: 0,
323
+ padding: '4px 8px',
324
+ }}
325
+ >
326
+ <Button
327
+ appearance="secondary"
328
+ aria-label="Remove condition"
329
+ icon={{ before: TrashIcon }}
330
+ size="small"
331
+ onClick={onRemove}
332
+ />
333
+ </div>
334
+ )}
335
+ </Flex>
193
336
  </Flex>
194
337
  );
195
338
  }
@@ -1,6 +1,13 @@
1
1
  import { Button, Combobox, Dialog, Flex, Text } from '@servicetitan/anvil2';
2
- import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
+ import {
5
+ buildFormFieldKey,
6
+ FormFieldInfo,
7
+ FormInfo,
8
+ getConditionalFieldTypeFromFormItemType,
9
+ parseFormFieldKey,
10
+ } from '../shared/forms';
4
11
  import { ConditionGroupsSection, MatchType } from './ConditionGroupsSection';
5
12
  import { defaultState, MODAL_CONTENT_MAX_HEIGHT } from './constants';
6
13
  import { DisplayConditionRequest, onDisplayCondition } from './displayConditionController';
@@ -10,6 +17,7 @@ import {
10
17
  ConditionGroup as ConditionGroupType,
11
18
  DisplayBehavior,
12
19
  DisplayConditionState,
20
+ FormFieldOption,
13
21
  LogicalOperator,
14
22
  } from './types';
15
23
 
@@ -51,16 +59,90 @@ function applyMatchTypeToState(
51
59
  }
52
60
 
53
61
  export interface DisplayConditionModalProps {
62
+ onConditionFormSelect?: (
63
+ formId: number,
64
+ sendFormFields: (formId: number, fields: FormFieldInfo[]) => void,
65
+ ) => void;
66
+ onConditionalsOpen?: (
67
+ usedFormIds: number[],
68
+ sendFormList: (forms: FormInfo[]) => void,
69
+ sendFormFields: (formId: number, fields: FormFieldInfo[]) => void,
70
+ ) => void;
54
71
  schema?: import('../shared/schema').SchemaObject;
55
72
  }
56
73
 
57
74
  export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
75
+ const { onConditionFormSelect, onConditionalsOpen, schema } = props;
58
76
  const [request, setRequest] = useState<DisplayConditionRequest | null>(null);
59
77
  const [isOpen, setIsOpen] = useState(false);
60
78
  const [state, setState] = useState(defaultState);
61
79
  const [matchType, setMatchType] = useState<MatchType>('all');
80
+ const [forms, setForms] = useState<FormInfo[]>([]);
81
+ const [formFieldsByFormId, setFormFieldsByFormId] = useState<Record<number, FormFieldInfo[]>>(
82
+ {},
83
+ );
84
+ const [loadingFormIds, setLoadingFormIds] = useState<number[]>([]);
85
+ const [isFormsListLoading, setIsFormsListLoading] = useState(false);
86
+ const formFieldsByFormIdRef = useRef<Record<number, FormFieldInfo[]>>({});
87
+ const loadingFormIdsRef = useRef<number[]>([]);
88
+
89
+ const dataPointOptions = useMemo(() => getSchemaDataPointOptions(schema), [schema]);
90
+ const formFieldOptions = useMemo<FormFieldOption[]>(() => {
91
+ const nextOptions: FormFieldOption[] = [];
92
+ for (const form of forms) {
93
+ const fields = formFieldsByFormId[form.id];
94
+ if (!fields) {
95
+ continue;
96
+ }
97
+ for (const field of fields) {
98
+ const fieldType = getConditionalFieldTypeFromFormItemType(field.itemType);
99
+ if (!fieldType) {
100
+ continue;
101
+ }
102
+ nextOptions.push({
103
+ fieldType,
104
+ formId: form.id,
105
+ fullKey: buildFormFieldKey(form.id, field.id),
106
+ title: field.header,
107
+ });
108
+ }
109
+ }
110
+ return nextOptions.sort((left, right) => left.title.localeCompare(right.title));
111
+ }, [forms, formFieldsByFormId]);
112
+ const allFieldOptions = useMemo(
113
+ () => [...dataPointOptions, ...formFieldOptions],
114
+ [dataPointOptions, formFieldOptions],
115
+ );
116
+ const isFieldDataLoading = isFormsListLoading || loadingFormIds.length > 0;
62
117
 
63
- const dataPointOptions = useMemo(() => getSchemaDataPointOptions(props.schema), [props.schema]);
118
+ useEffect(() => {
119
+ formFieldsByFormIdRef.current = formFieldsByFormId;
120
+ }, [formFieldsByFormId]);
121
+
122
+ useEffect(() => {
123
+ loadingFormIdsRef.current = loadingFormIds;
124
+ }, [loadingFormIds]);
125
+
126
+ const requestFormFields = useCallback(
127
+ (formId: number) => {
128
+ if (!onConditionFormSelect) {
129
+ return;
130
+ }
131
+ if (
132
+ formFieldsByFormIdRef.current[formId] ||
133
+ loadingFormIdsRef.current.includes(formId)
134
+ ) {
135
+ return;
136
+ }
137
+
138
+ setLoadingFormIds(prev => (prev.includes(formId) ? prev : [...prev, formId]));
139
+ onConditionFormSelect(formId, (selectedFormId, fields) => {
140
+ setFormFieldsByFormId(prev => ({ ...prev, [selectedFormId]: fields }));
141
+ setLoadingFormIds(prev => prev.filter(id => id !== selectedFormId));
142
+ });
143
+ },
144
+ [onConditionFormSelect],
145
+ );
64
146
 
65
147
  const portalTarget = useMemo(() => {
66
148
  if (typeof document === 'undefined') {
@@ -75,11 +157,49 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
75
157
  const existing = nextRequest.data;
76
158
  const parsed = parseUnlayerDisplayCondition(existing);
77
159
  const initialState = parsed ?? defaultState();
160
+ const usedFormIds = Array.from(
161
+ new Set(
162
+ initialState.groups
163
+ .flatMap(group => group.conditions)
164
+ .map(condition => parseFormFieldKey(condition.dataPointKey)?.formId)
165
+ .filter((formId): formId is number => formId != null),
166
+ ),
167
+ );
168
+
169
+ setForms([]);
170
+ setFormFieldsByFormId({});
171
+ setIsFormsListLoading(!!onConditionalsOpen);
172
+ setLoadingFormIds([]);
173
+
174
+ if (onConditionalsOpen) {
175
+ onConditionalsOpen(
176
+ usedFormIds,
177
+ nextForms => {
178
+ setForms(nextForms);
179
+ setIsFormsListLoading(false);
180
+ },
181
+ (formId, fields) => {
182
+ setFormFieldsByFormId(prev => ({ ...prev, [formId]: fields }));
183
+ setLoadingFormIds(prev => prev.filter(id => id !== formId));
184
+ },
185
+ );
186
+
187
+ /*
188
+ * Keep display-condition flow aligned with calculated-field flow:
189
+ * when modal opens, request fields for already-used forms so labels
190
+ * are available in edit mode.
191
+ */
192
+ usedFormIds.forEach(formId => requestFormFields(formId));
193
+ } else {
194
+ setIsFormsListLoading(false);
195
+ setLoadingFormIds([]);
196
+ }
197
+
78
198
  setState(initialState);
79
199
  setMatchType(deriveMatchType(initialState));
80
200
  setIsOpen(true);
81
201
  });
82
- }, []);
202
+ }, [onConditionalsOpen, requestFormFields]);
83
203
 
84
204
  const handleClose = useCallback(() => {
85
205
  setRequest(null);
@@ -120,7 +240,17 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
120
240
  }
121
241
  }, []);
122
242
 
243
+ const handleFormSelect = useCallback(
244
+ (formId: number) => {
245
+ requestFormFields(formId);
246
+ },
247
+ [requestFormFields],
248
+ );
249
+
123
250
  const canSave = useMemo(() => {
251
+ if (isFieldDataLoading) {
252
+ return false;
253
+ }
124
254
  for (const group of state.groups) {
125
255
  for (const condition of group.conditions) {
126
256
  if (!condition.dataPointKey) {
@@ -134,7 +264,7 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
134
264
  return false;
135
265
  }
136
266
  const fieldType =
137
- dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey)
267
+ allFieldOptions.find(opt => opt.fullKey === condition.dataPointKey)
138
268
  ?.fieldType ?? 'string';
139
269
  if (fieldType === 'number' && !NUMERIC_VALUE_RE.test(trimmedValue)) {
140
270
  return false;
@@ -142,7 +272,7 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
142
272
  }
143
273
  }
144
274
  return state.groups.length > 0;
145
- }, [dataPointOptions, state.groups]);
275
+ }, [allFieldOptions, isFieldDataLoading, state.groups]);
146
276
 
147
277
  if (!portalTarget || !isOpen) {
148
278
  return null;
@@ -211,8 +341,11 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
211
341
  </Flex>
212
342
  }
213
343
  dataPointOptions={dataPointOptions}
344
+ formFieldOptions={formFieldOptions}
345
+ forms={forms}
214
346
  groups={state.groups}
215
347
  matchType={matchType}
348
+ onFormSelect={handleFormSelect}
216
349
  onMatchTypeChange={setMatchType}
217
350
  onUpdateGroup={updateGroup}
218
351
  />
@@ -1,3 +1,4 @@
1
+ import { buildFormFieldKey, parseFormFieldKey, toNunjucksFieldReference } from '../shared/forms';
1
2
  import { generateId } from './constants';
2
3
  import {
3
4
  CONDITION_OPERATORS,
@@ -44,7 +45,7 @@ function buildSingleConditionExpression(
44
45
  operator: string,
45
46
  value: string,
46
47
  ): string {
47
- const path = dataPointKey;
48
+ const path = toNunjucksFieldReference(dataPointKey);
48
49
  const defaulted = `(${path} | default(''))`;
49
50
  const numDefaulted = `(${path} | default(0))`;
50
51
  const normalizedValue = value.trim();
@@ -171,9 +172,7 @@ function generateTypeAndLabel(state: DisplayConditionState): { type: string; lab
171
172
  if (isValueLessOperator(c.operator)) {
172
173
  parts.push(`${c.dataPointKey} ${operatorLabel(c.operator)}`);
173
174
  } else if (c.value.trim()) {
174
- parts.push(
175
- `${c.dataPointKey} ${operatorLabel(c.operator)} ${c.value.trim()}`,
176
- );
175
+ parts.push(`${c.dataPointKey} ${operatorLabel(c.operator)} ${c.value.trim()}`);
177
176
  }
178
177
  }
179
178
  }
@@ -273,8 +272,11 @@ function unescapeNunjucksString(s: string): string {
273
272
  return s.replaceAll('\\\\', '\\').replaceAll(String.raw`\'`, "'");
274
273
  }
275
274
 
276
- const RE_STR_DEFAULT = String.raw`\(([\w.]+)\s*\|\s*default\s*\(\s*''\s*\)\)`;
277
- const RE_NUM_DEFAULT = String.raw`\(([\w.]+)\s*\|\s*default\s*\(\s*0\s*\)\)`;
275
+ const SIMPLE_DATA_POINT_PATH = String.raw`[\w.]+`;
276
+ const FORM_RUNTIME_DATA_POINT_PATH = String.raw`__submission_fields\["\d+"\]\["[A-Za-z0-9]+"\]`;
277
+ const DATA_POINT_PATH = String.raw`(${SIMPLE_DATA_POINT_PATH}|${FORM_RUNTIME_DATA_POINT_PATH})`;
278
+ const RE_STR_DEFAULT = String.raw`\(${DATA_POINT_PATH}\s*\|\s*default\s*\(\s*''\s*\)\)`;
279
+ const RE_NUM_DEFAULT = String.raw`\(${DATA_POINT_PATH}\s*\|\s*default\s*\(\s*0\s*\)\)`;
278
280
  const QUOTED_CONTENT = String.raw`((?:[^'\\]|\\.)*)`;
279
281
 
280
282
  function toSingleCondition(
@@ -283,8 +285,15 @@ function toSingleCondition(
283
285
  value: string,
284
286
  logicalOp?: LogicalOperator,
285
287
  ): SingleCondition {
288
+ const normalizedDataPointKey = (() => {
289
+ const parsed = parseFormFieldKey(dataPointKey.trim());
290
+ if (!parsed) {
291
+ return dataPointKey.trim();
292
+ }
293
+ return buildFormFieldKey(parsed.formId, parsed.fieldId);
294
+ })();
286
295
  const condition: SingleCondition = {
287
- dataPointKey: dataPointKey.trim(),
296
+ dataPointKey: normalizedDataPointKey,
288
297
  id: generateId(),
289
298
  operator,
290
299
  value,
@@ -330,7 +339,7 @@ const PARSE_PATTERNS: ParsePattern[] = [
330
339
  operator: 'is_empty',
331
340
  pathGroup: 1,
332
341
  pattern: new RegExp(
333
- String.raw`^${RE_STR_DEFAULT}\s*==\s*''\s+or\s+\(\([\w.]+\s*\|\s*default\s*\(\s*''\s*\)\)\s*\|\s*length\)\s*==\s*0$`,
342
+ String.raw`^${RE_STR_DEFAULT}\s*==\s*''\s+or\s+\(\(${DATA_POINT_PATH}\s*\|\s*default\s*\(\s*''\s*\)\)\s*\|\s*length\)\s*==\s*0$`,
334
343
  ),
335
344
  },
336
345
  {
@@ -338,7 +347,7 @@ const PARSE_PATTERNS: ParsePattern[] = [
338
347
  operator: 'is_not_empty',
339
348
  pathGroup: 1,
340
349
  pattern: new RegExp(
341
- String.raw`^${RE_STR_DEFAULT}\s*!=\s*''\s+and\s+\(\([\w.]+\s*\|\s*default\s*\(\s*''\s*\)\)\s*\|\s*length\)\s*>\s*0$`,
350
+ String.raw`^${RE_STR_DEFAULT}\s*!=\s*''\s+and\s+\(\(${DATA_POINT_PATH}\s*\|\s*default\s*\(\s*''\s*\)\)\s*\|\s*length\)\s*>\s*0$`,
342
351
  ),
343
352
  },
344
353
  // string with value
@@ -65,6 +65,10 @@ export interface DataPointOption {
65
65
  title: string;
66
66
  }
67
67
 
68
+ export interface FormFieldOption extends DataPointOption {
69
+ formId: number;
70
+ }
71
+
68
72
  /** Unlayer display condition shape passed to done() */
69
73
  export interface UnlayerDisplayCondition {
70
74
  type: string;