@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.
- 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 +123 -8
- 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 +1 -1
- package/dist/editor-core-source.d.ts.map +1 -1
- package/dist/editor-core-source.js +1 -1
- 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 +138 -5
- package/src/display-conditions/nunjucks.ts +18 -9
- package/src/display-conditions/types.ts +4 -0
- package/src/editor-core-source.ts +1 -1
- 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
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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;
|