@servicetitan/dte-unlayer 0.122.0 → 0.123.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 +97 -7
  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 +106 -4
  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
2
  import { useCallback, useEffect, useMemo, 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,59 @@ 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);
62
86
 
63
- const dataPointOptions = useMemo(() => getSchemaDataPointOptions(props.schema), [props.schema]);
87
+ const dataPointOptions = useMemo(() => getSchemaDataPointOptions(schema), [schema]);
88
+ const formFieldOptions = useMemo<FormFieldOption[]>(() => {
89
+ const nextOptions: FormFieldOption[] = [];
90
+ for (const form of forms) {
91
+ const fields = formFieldsByFormId[form.id];
92
+ if (!fields) {
93
+ continue;
94
+ }
95
+ for (const field of fields) {
96
+ const fieldType = getConditionalFieldTypeFromFormItemType(field.itemType);
97
+ if (!fieldType) {
98
+ continue;
99
+ }
100
+ nextOptions.push({
101
+ fieldType,
102
+ formId: form.id,
103
+ fullKey: buildFormFieldKey(form.id, field.id),
104
+ title: field.header,
105
+ });
106
+ }
107
+ }
108
+ return nextOptions.sort((left, right) => left.title.localeCompare(right.title));
109
+ }, [forms, formFieldsByFormId]);
110
+ const allFieldOptions = useMemo(
111
+ () => [...dataPointOptions, ...formFieldOptions],
112
+ [dataPointOptions, formFieldOptions],
113
+ );
114
+ const isFieldDataLoading = isFormsListLoading || loadingFormIds.length > 0;
64
115
 
65
116
  const portalTarget = useMemo(() => {
66
117
  if (typeof document === 'undefined') {
@@ -75,11 +126,42 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
75
126
  const existing = nextRequest.data;
76
127
  const parsed = parseUnlayerDisplayCondition(existing);
77
128
  const initialState = parsed ?? defaultState();
129
+ const usedFormIds = Array.from(
130
+ new Set(
131
+ initialState.groups
132
+ .flatMap(group => group.conditions)
133
+ .map(condition => parseFormFieldKey(condition.dataPointKey)?.formId)
134
+ .filter((formId): formId is number => formId != null),
135
+ ),
136
+ );
137
+
138
+ setForms([]);
139
+ setFormFieldsByFormId({});
140
+ setIsFormsListLoading(!!onConditionalsOpen);
141
+ setLoadingFormIds(usedFormIds);
142
+
143
+ if (onConditionalsOpen) {
144
+ onConditionalsOpen(
145
+ usedFormIds,
146
+ nextForms => {
147
+ setForms(nextForms);
148
+ setIsFormsListLoading(false);
149
+ },
150
+ (formId, fields) => {
151
+ setFormFieldsByFormId(prev => ({ ...prev, [formId]: fields }));
152
+ setLoadingFormIds(prev => prev.filter(id => id !== formId));
153
+ },
154
+ );
155
+ } else {
156
+ setIsFormsListLoading(false);
157
+ setLoadingFormIds([]);
158
+ }
159
+
78
160
  setState(initialState);
79
161
  setMatchType(deriveMatchType(initialState));
80
162
  setIsOpen(true);
81
163
  });
82
- }, []);
164
+ }, [onConditionalsOpen]);
83
165
 
84
166
  const handleClose = useCallback(() => {
85
167
  setRequest(null);
@@ -120,7 +202,24 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
120
202
  }
121
203
  }, []);
122
204
 
205
+ const handleFormSelect = useCallback(
206
+ (formId: number) => {
207
+ if (!onConditionFormSelect || formFieldsByFormId[formId]) {
208
+ return;
209
+ }
210
+ setLoadingFormIds(prev => (prev.includes(formId) ? prev : [...prev, formId]));
211
+ onConditionFormSelect(formId, (selectedFormId, fields) => {
212
+ setFormFieldsByFormId(prev => ({ ...prev, [selectedFormId]: fields }));
213
+ setLoadingFormIds(prev => prev.filter(id => id !== selectedFormId));
214
+ });
215
+ },
216
+ [formFieldsByFormId, onConditionFormSelect],
217
+ );
218
+
123
219
  const canSave = useMemo(() => {
220
+ if (isFieldDataLoading) {
221
+ return false;
222
+ }
124
223
  for (const group of state.groups) {
125
224
  for (const condition of group.conditions) {
126
225
  if (!condition.dataPointKey) {
@@ -134,7 +233,7 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
134
233
  return false;
135
234
  }
136
235
  const fieldType =
137
- dataPointOptions.find(opt => opt.fullKey === condition.dataPointKey)
236
+ allFieldOptions.find(opt => opt.fullKey === condition.dataPointKey)
138
237
  ?.fieldType ?? 'string';
139
238
  if (fieldType === 'number' && !NUMERIC_VALUE_RE.test(trimmedValue)) {
140
239
  return false;
@@ -142,7 +241,7 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
142
241
  }
143
242
  }
144
243
  return state.groups.length > 0;
145
- }, [dataPointOptions, state.groups]);
244
+ }, [allFieldOptions, isFieldDataLoading, state.groups]);
146
245
 
147
246
  if (!portalTarget || !isOpen) {
148
247
  return null;
@@ -211,8 +310,11 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
211
310
  </Flex>
212
311
  }
213
312
  dataPointOptions={dataPointOptions}
313
+ formFieldOptions={formFieldOptions}
314
+ forms={forms}
214
315
  groups={state.groups}
215
316
  matchType={matchType}
317
+ onFormSelect={handleFormSelect}
216
318
  onMatchTypeChange={setMatchType}
217
319
  onUpdateGroup={updateGroup}
218
320
  />
@@ -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;