@servicetitan/dte-unlayer 0.121.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.
- package/dist/display-conditions/ConditionGroup.d.ts +6 -2
- package/dist/display-conditions/ConditionGroup.d.ts.map +1 -1
- package/dist/display-conditions/ConditionGroup.js +8 -3
- package/dist/display-conditions/ConditionGroup.js.map +1 -1
- package/dist/display-conditions/ConditionGroupsSection.d.ts +6 -2
- package/dist/display-conditions/ConditionGroupsSection.d.ts.map +1 -1
- package/dist/display-conditions/ConditionGroupsSection.js +4 -1
- package/dist/display-conditions/ConditionGroupsSection.js.map +1 -1
- package/dist/display-conditions/ConditionRow.d.ts +6 -2
- package/dist/display-conditions/ConditionRow.d.ts.map +1 -1
- package/dist/display-conditions/ConditionRow.js +240 -110
- package/dist/display-conditions/ConditionRow.js.map +1 -1
- package/dist/display-conditions/DisplayConditionModal.d.ts +3 -0
- package/dist/display-conditions/DisplayConditionModal.d.ts.map +1 -1
- package/dist/display-conditions/DisplayConditionModal.js +97 -7
- package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
- package/dist/display-conditions/nunjucks.d.ts.map +1 -1
- package/dist/display-conditions/nunjucks.js +17 -6
- package/dist/display-conditions/nunjucks.js.map +1 -1
- package/dist/display-conditions/types.d.ts +3 -0
- package/dist/display-conditions/types.d.ts.map +1 -1
- package/dist/display-conditions/types.js.map +1 -1
- package/dist/editor-core-source.d.ts +2 -2
- package/dist/editor-core-source.d.ts.map +1 -1
- package/dist/editor-core-source.js +2 -2
- package/dist/editor-core-source.js.map +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +3 -1
- package/dist/editor.js.map +1 -1
- package/dist/shared/forms.d.ts +26 -0
- package/dist/shared/forms.d.ts.map +1 -1
- package/dist/shared/forms.js +75 -1
- package/dist/shared/forms.js.map +1 -1
- package/dist/unlayer-interface.d.ts +2 -0
- package/dist/unlayer-interface.d.ts.map +1 -1
- package/dist/unlayer-interface.js.map +1 -1
- package/package.json +1 -1
- package/src/display-conditions/ConditionGroup.tsx +12 -3
- package/src/display-conditions/ConditionGroupsSection.tsx +11 -1
- package/src/display-conditions/ConditionRow.tsx +229 -86
- package/src/display-conditions/DisplayConditionModal.tsx +106 -4
- package/src/display-conditions/nunjucks.ts +18 -9
- package/src/display-conditions/types.ts +4 -0
- package/src/editor-core-source.ts +2 -2
- package/src/editor.tsx +7 -1
- package/src/shared/forms.ts +85 -0
- 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
|
-
() =>
|
|
44
|
-
[
|
|
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
|
|
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
|
-
|
|
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:
|
|
133
|
-
itemToString={(item:
|
|
134
|
-
items={
|
|
135
|
-
selectedItem={
|
|
136
|
-
onChange={
|
|
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="
|
|
145
|
-
placeholder="Select..."
|
|
208
|
+
label="Data Type"
|
|
209
|
+
placeholder="Select source..."
|
|
146
210
|
size="small"
|
|
147
211
|
/>
|
|
148
212
|
<Combobox.Content>
|
|
149
|
-
{({ items }: { items:
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
277
|
-
const
|
|
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:
|
|
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+\(\(
|
|
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+\(\(
|
|
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;
|