@servicetitan/dte-unlayer 0.154.0 → 0.155.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 (60) hide show
  1. package/dist/display-conditions/ConditionRow.d.ts.map +1 -1
  2. package/dist/display-conditions/ConditionRow.js +111 -95
  3. package/dist/display-conditions/ConditionRow.js.map +1 -1
  4. package/dist/display-conditions/DisplayConditionModal.d.ts +3 -3
  5. package/dist/display-conditions/DisplayConditionModal.d.ts.map +1 -1
  6. package/dist/display-conditions/DisplayConditionModal.js +8 -95
  7. package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
  8. package/dist/display-conditions/useConditionForms.d.ts +22 -0
  9. package/dist/display-conditions/useConditionForms.d.ts.map +1 -0
  10. package/dist/display-conditions/useConditionForms.js +110 -0
  11. package/dist/display-conditions/useConditionForms.js.map +1 -0
  12. package/dist/e-sign-offset/ESignDateSignedOffsetModal.d.ts +11 -0
  13. package/dist/e-sign-offset/ESignDateSignedOffsetModal.d.ts.map +1 -0
  14. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js +192 -0
  15. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js.map +1 -0
  16. package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts +20 -0
  17. package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts.map +1 -0
  18. package/dist/e-sign-offset/ESignOffsetRuleRow.js +87 -0
  19. package/dist/e-sign-offset/ESignOffsetRuleRow.js.map +1 -0
  20. package/dist/e-sign-offset/eSignOffsetController.d.ts +10 -0
  21. package/dist/e-sign-offset/eSignOffsetController.d.ts.map +1 -0
  22. package/dist/e-sign-offset/eSignOffsetController.js +27 -0
  23. package/dist/e-sign-offset/eSignOffsetController.js.map +1 -0
  24. package/dist/editor-core-source.d.ts +2 -2
  25. package/dist/editor-core-source.d.ts.map +1 -1
  26. package/dist/editor-core-source.js +2 -2
  27. package/dist/editor-core-source.js.map +1 -1
  28. package/dist/editor.d.ts.map +1 -1
  29. package/dist/editor.js +8 -0
  30. package/dist/editor.js.map +1 -1
  31. package/dist/shared/const.d.ts +30 -0
  32. package/dist/shared/const.d.ts.map +1 -1
  33. package/dist/shared/const.js +5 -0
  34. package/dist/shared/const.js.map +1 -1
  35. package/dist/shared/e-sign-offset.d.ts +21 -0
  36. package/dist/shared/e-sign-offset.d.ts.map +1 -0
  37. package/dist/shared/e-sign-offset.js +130 -0
  38. package/dist/shared/e-sign-offset.js.map +1 -0
  39. package/dist/store.d.ts +1 -0
  40. package/dist/store.d.ts.map +1 -1
  41. package/dist/store.js +30 -3
  42. package/dist/store.js.map +1 -1
  43. package/dist/tools.d.ts +6 -0
  44. package/dist/tools.d.ts.map +1 -1
  45. package/dist/tools.js +21 -1
  46. package/dist/tools.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/__tests__/e-sign-offset.test.ts +281 -0
  49. package/src/display-conditions/ConditionRow.tsx +86 -81
  50. package/src/display-conditions/DisplayConditionModal.tsx +12 -116
  51. package/src/display-conditions/useConditionForms.ts +146 -0
  52. package/src/e-sign-offset/ESignDateSignedOffsetModal.tsx +197 -0
  53. package/src/e-sign-offset/ESignOffsetRuleRow.tsx +101 -0
  54. package/src/e-sign-offset/eSignOffsetController.ts +39 -0
  55. package/src/editor-core-source.ts +2 -2
  56. package/src/editor.tsx +9 -0
  57. package/src/shared/const.ts +37 -0
  58. package/src/shared/e-sign-offset.ts +173 -0
  59. package/src/store.ts +45 -4
  60. package/src/tools.ts +36 -1
@@ -0,0 +1,281 @@
1
+ import {
2
+ emitESignOffsetRequest,
3
+ onESignOffsetRequest,
4
+ } from '../e-sign-offset/eSignOffsetController';
5
+ import type { SingleCondition } from '../display-conditions/types';
6
+ import {
7
+ type ESignDateSignedOffsetRule,
8
+ type ESignDateSignedOffsetSettings,
9
+ MAX_E_SIGN_OFFSET_RULES,
10
+ } from '../shared/const';
11
+ import {
12
+ addESignOffsetRule,
13
+ buildESignOffsetSettings,
14
+ canAddESignOffsetRule,
15
+ clampESignOffsetDays,
16
+ createESignOffsetRule,
17
+ createESignOffsetSettings,
18
+ getESignOffsetConditionText,
19
+ getESignOffsetRuleCondition,
20
+ isCompleteESignOffsetCondition,
21
+ isESignOffsetSettings,
22
+ normalizeESignOffsetSettings,
23
+ removeESignOffsetRule,
24
+ splitESignOffsetSettings,
25
+ updateESignOffsetRule,
26
+ } from '../shared/e-sign-offset';
27
+ import { unlayerToolsBuildDesign, unlayerToolsNormalizeESignOffsets } from '../tools';
28
+ import type { UnlayerDesignCustomTool } from '../unlayer-interface';
29
+
30
+ const createCondition = (overrides: Partial<SingleCondition> = {}): SingleCondition => ({
31
+ dataPointKey: 'job.status',
32
+ id: 'c1',
33
+ operator: 'is_equal_to',
34
+ value: 'Completed',
35
+ ...overrides,
36
+ });
37
+
38
+ const createRule = (days: number, condition = createCondition()): ESignDateSignedOffsetRule => ({
39
+ condition: { behavior: 'show', groups: [{ conditions: [condition], id: `g-${days}` }] },
40
+ days,
41
+ });
42
+
43
+ describe('clampESignOffsetDays', () => {
44
+ test.each([
45
+ [14, 14],
46
+ [0, 0],
47
+ [-5, 0],
48
+ [400, 365],
49
+ [3.7, 3],
50
+ [Number.NaN, 0],
51
+ [null, 0],
52
+ ['7', 0],
53
+ ])('clamps %p to %p', (input, expected) => {
54
+ expect(clampESignOffsetDays(input)).toBe(expected);
55
+ });
56
+ });
57
+
58
+ describe('createESignOffsetSettings', () => {
59
+ test('defaults to a single unconditional zero-day rule', () => {
60
+ expect(createESignOffsetSettings()).toEqual({ rules: [{ days: 0 }] });
61
+ });
62
+
63
+ test('clamps the provided days', () => {
64
+ expect(createESignOffsetSettings(500)).toEqual({ rules: [{ days: 365 }] });
65
+ });
66
+ });
67
+
68
+ describe('createESignOffsetRule', () => {
69
+ test('creates a show rule with a single blank condition', () => {
70
+ const rule = createESignOffsetRule();
71
+
72
+ expect(rule.days).toBe(0);
73
+ expect(rule.condition.behavior).toBe('show');
74
+ expect(rule.condition.groups).toHaveLength(1);
75
+ expect(rule.condition.groups[0].logicalOperator).toBeUndefined();
76
+ expect(rule.condition.groups[0].conditions).toHaveLength(1);
77
+ expect(rule.condition.groups[0].conditions[0].logicalOperator).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe('buildESignOffsetSettings / splitESignOffsetSettings', () => {
82
+ test('appends the unconditional final rule after the conditioned rules', () => {
83
+ const settings = buildESignOffsetSettings([createRule(3), createRule(7)], 14);
84
+
85
+ expect(settings.rules).toHaveLength(3);
86
+ expect(settings.rules[2]).toEqual({ days: 14 });
87
+ expect(splitESignOffsetSettings(settings)).toEqual({
88
+ conditionedRules: [createRule(3), createRule(7)],
89
+ finalDays: 14,
90
+ });
91
+ });
92
+
93
+ test('clamps days of every rule', () => {
94
+ const settings = buildESignOffsetSettings([createRule(900)], -2);
95
+
96
+ expect(settings.rules.map(rule => rule.days)).toEqual([365, 0]);
97
+ });
98
+ });
99
+
100
+ describe('isESignOffsetSettings', () => {
101
+ test('accepts a lone final rule and a conditioned chain', () => {
102
+ expect(isESignOffsetSettings({ rules: [{ days: 5 }] })).toBe(true);
103
+ expect(isESignOffsetSettings({ rules: [createRule(3), { days: 5 }] })).toBe(true);
104
+ });
105
+
106
+ test.each([
107
+ ['missing value', undefined],
108
+ ['missing rules', {}],
109
+ ['empty rules', { rules: [] }],
110
+ ['condition on the last rule', { rules: [createRule(3)] }],
111
+ ['unconditioned rule before the last', { rules: [{ days: 1 }, { days: 5 }] }],
112
+ ['days out of range', { rules: [{ days: 366 }] }],
113
+ ['negative days', { rules: [{ days: -1 }] }],
114
+ [
115
+ 'too many rules',
116
+ {
117
+ rules: [
118
+ ...Array.from({ length: MAX_E_SIGN_OFFSET_RULES }, (_, i) => createRule(i)),
119
+ { days: 0 },
120
+ ],
121
+ },
122
+ ],
123
+ ])('rejects %s', (_, value) => {
124
+ expect(isESignOffsetSettings(value)).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('normalizeESignOffsetSettings', () => {
129
+ test('returns valid settings unchanged', () => {
130
+ const settings: ESignDateSignedOffsetSettings = { rules: [createRule(3), { days: 5 }] };
131
+
132
+ expect(normalizeESignOffsetSettings(settings)).toBe(settings);
133
+ });
134
+
135
+ test.each([undefined, null, 'x', {}, { rules: 'x' }])('defaults %p', value => {
136
+ expect(normalizeESignOffsetSettings(value)).toEqual({ rules: [{ days: 0 }] });
137
+ });
138
+
139
+ test('repairs malformed chains', () => {
140
+ const normalized = normalizeESignOffsetSettings({
141
+ rules: [createRule(500), { days: 2 }, createRule(20)],
142
+ });
143
+
144
+ expect(normalized).toEqual({ rules: [{ ...createRule(500), days: 365 }, { days: 20 }] });
145
+ expect(isESignOffsetSettings(normalized)).toBe(true);
146
+ });
147
+
148
+ test('drops conditioned rules beyond the maximum', () => {
149
+ const normalized = normalizeESignOffsetSettings({
150
+ rules: [
151
+ ...Array.from({ length: MAX_E_SIGN_OFFSET_RULES + 2 }, (_, i) => createRule(i)),
152
+ { days: 9 },
153
+ ],
154
+ });
155
+
156
+ expect(normalized.rules).toHaveLength(MAX_E_SIGN_OFFSET_RULES);
157
+ expect(normalized.rules[normalized.rules.length - 1]).toEqual({ days: 9 });
158
+ });
159
+ });
160
+
161
+ describe('rule list operations', () => {
162
+ test('adds rules up to the maximum, counting the final rule', () => {
163
+ let rules: ESignDateSignedOffsetRule[] = [];
164
+
165
+ for (let i = 0; i < MAX_E_SIGN_OFFSET_RULES + 2; i++) {
166
+ rules = addESignOffsetRule(rules);
167
+ }
168
+
169
+ expect(rules).toHaveLength(MAX_E_SIGN_OFFSET_RULES - 1);
170
+ expect(canAddESignOffsetRule(rules)).toBe(false);
171
+ });
172
+
173
+ test('updates and removes by index without mutating the input', () => {
174
+ const rules = [createRule(1), createRule(2)];
175
+ const updated = updateESignOffsetRule(rules, 1, createRule(9));
176
+ const removed = removeESignOffsetRule(rules, 0);
177
+
178
+ expect(updated.map(rule => rule.days)).toEqual([1, 9]);
179
+ expect(removed.map(rule => rule.days)).toEqual([2]);
180
+ expect(rules.map(rule => rule.days)).toEqual([1, 2]);
181
+ });
182
+
183
+ test('reads the single condition of a rule', () => {
184
+ expect(getESignOffsetRuleCondition(createRule(1))).toEqual(createCondition());
185
+ });
186
+ });
187
+
188
+ describe('isCompleteESignOffsetCondition', () => {
189
+ test.each([
190
+ ['complete string condition', createCondition(), 'string', true],
191
+ ['missing condition', undefined, 'string', false],
192
+ ['missing data point', createCondition({ dataPointKey: '' }), 'string', false],
193
+ ['blank value', createCondition({ value: ' ' }), 'string', false],
194
+ [
195
+ 'value-less operator',
196
+ createCondition({ operator: 'is_empty', value: '' }),
197
+ 'string',
198
+ true,
199
+ ],
200
+ ['numeric value', createCondition({ operator: 'num_gt', value: '64' }), 'number', true],
201
+ [
202
+ 'non-numeric value',
203
+ createCondition({ operator: 'num_gt', value: 'abc' }),
204
+ 'number',
205
+ false,
206
+ ],
207
+ ] as const)('%s', (_, condition, fieldType, expected) => {
208
+ expect(isCompleteESignOffsetCondition(condition, fieldType)).toBe(expected);
209
+ });
210
+ });
211
+
212
+ describe('getESignOffsetConditionText', () => {
213
+ test('formats string, number and value-less operators', () => {
214
+ expect(getESignOffsetConditionText(createCondition(), 'Job Status')).toBe(
215
+ 'Job Status is equal to Completed',
216
+ );
217
+ expect(
218
+ getESignOffsetConditionText(
219
+ createCondition({ operator: 'num_eq', value: '3' }),
220
+ 'Visits',
221
+ ),
222
+ ).toBe('Visits = 3');
223
+ expect(
224
+ getESignOffsetConditionText(createCondition({ operator: 'is_empty' }), 'Notes'),
225
+ ).toBe('Notes is empty');
226
+ });
227
+ });
228
+
229
+ describe('unlayerToolsNormalizeESignOffsets', () => {
230
+ const createESignTool = (values: Record<string, unknown>): UnlayerDesignCustomTool => ({
231
+ type: 'custom',
232
+ slug: 'e-sign',
233
+ values: { eSignRecipient: 'customer', ...values },
234
+ });
235
+
236
+ test('fills settings on offset placements saved without them and leaves the rest alone', () => {
237
+ const valid = { rules: [createRule(3), { days: 5 }] };
238
+ const legacyOffset = createESignTool({ eSignFieldType: 'dateSignedOffset' });
239
+ const configuredOffset = createESignTool({
240
+ eSignDateSignedOffset: valid,
241
+ eSignFieldType: 'dateSignedOffset',
242
+ });
243
+ const signature = createESignTool({ eSignFieldType: 'signature' });
244
+ const design = unlayerToolsBuildDesign([legacyOffset, configuredOffset, signature]);
245
+
246
+ expect(unlayerToolsNormalizeESignOffsets(design)).toBe(true);
247
+ expect(legacyOffset.values.eSignDateSignedOffset).toEqual({ rules: [{ days: 0 }] });
248
+ expect(configuredOffset.values.eSignDateSignedOffset).toBe(valid);
249
+ expect(signature.values).not.toHaveProperty('eSignDateSignedOffset');
250
+ expect(unlayerToolsNormalizeESignOffsets(design)).toBe(false);
251
+ });
252
+
253
+ test('tolerates a missing design', () => {
254
+ expect(unlayerToolsNormalizeESignOffsets(undefined as never)).toBe(false);
255
+ });
256
+ });
257
+
258
+ describe('eSignOffsetController', () => {
259
+ test('resolves with the original value when no modal is listening', () => {
260
+ const value = createESignOffsetSettings(4);
261
+ const done = jest.fn();
262
+
263
+ emitESignOffsetRequest({ done, value });
264
+
265
+ expect(done).toHaveBeenCalledWith(value);
266
+ });
267
+
268
+ test('delivers requests to the listener until it unsubscribes', () => {
269
+ const listener = jest.fn();
270
+ const unsubscribe = onESignOffsetRequest(listener);
271
+ const request = { done: jest.fn(), value: createESignOffsetSettings(4) };
272
+
273
+ emitESignOffsetRequest(request);
274
+ unsubscribe();
275
+ emitESignOffsetRequest(request);
276
+
277
+ expect(listener).toHaveBeenCalledTimes(1);
278
+ expect(listener).toHaveBeenCalledWith(request);
279
+ expect(request.done).toHaveBeenCalledTimes(1);
280
+ });
281
+ });
@@ -178,6 +178,8 @@ export function ConditionRow({
178
178
  <Flex direction="column" gap="3" style={{ width: '100%' }}>
179
179
  <div style={{ width: '100%' }}>
180
180
  <Combobox.Select
181
+ flex={1}
182
+ style={{ width: '100%' }}
181
183
  disableClearSelection
182
184
  itemToKey={(item: SourceOption | null) => item?.value ?? ''}
183
185
  itemToString={(item: SourceOption | null) => item?.label ?? ''}
@@ -203,12 +205,7 @@ export function ConditionRow({
203
205
  </Combobox.Content>
204
206
  </Combobox.Select>
205
207
  </div>
206
- <Flex
207
- direction="row"
208
- alignItems="flex-end"
209
- gap="2"
210
- style={{ flexWrap: 'wrap', rowGap: 8, width: '100%' }}
211
- >
208
+ <Flex direction="row" alignItems="flex-end" gap="2" style={{ width: '100%' }}>
212
209
  <div
213
210
  style={{
214
211
  display: 'flex',
@@ -219,89 +216,97 @@ export function ConditionRow({
219
216
  >
220
217
  <Chip label="IF" size="small" />
221
218
  </div>
222
- <Flex flex={1} style={{ minWidth: 160 }}>
223
- <Combobox.Select
224
- flex={1}
225
- style={{ width: '100%' }}
226
- disableClearSelection
227
- itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
228
- itemToString={(item: DataPointOption | null) => item?.title ?? ''}
229
- items={activeDataPointOptions}
230
- selectedItem={selectedDataPoint}
231
- onChange={handleDataPointChange}
232
- >
233
- <Combobox.SelectTrigger
234
- label="Data Point"
235
- placeholder={
236
- currentFormId ? 'Select form field...' : 'Select data point...'
219
+ <Flex
220
+ flex={1}
221
+ direction="row"
222
+ alignItems="flex-end"
223
+ gap="2"
224
+ style={{ minWidth: 0, width: '100%' }}
225
+ >
226
+ <Flex flex={1} style={{ minWidth: 0, width: '100%' }}>
227
+ <Combobox.Select
228
+ flex={1}
229
+ style={{ width: '100%' }}
230
+ disableClearSelection
231
+ itemToKey={(item: DataPointOption | null) => item?.fullKey ?? ''}
232
+ itemToString={(item: DataPointOption | null) => item?.title ?? ''}
233
+ items={activeDataPointOptions}
234
+ selectedItem={selectedDataPoint}
235
+ onChange={handleDataPointChange}
236
+ >
237
+ <Combobox.SelectTrigger
238
+ label="Data Point"
239
+ placeholder={
240
+ currentFormId ? 'Select form field...' : 'Select data point...'
241
+ }
242
+ size="small"
243
+ />
244
+ <Combobox.Content>
245
+ {({ items }: { items: DataPointOption[] }) => (
246
+ <Combobox.List>
247
+ {items.map((item, i) => (
248
+ <Combobox.Item index={i} item={item} key={item.fullKey}>
249
+ {item.title}
250
+ </Combobox.Item>
251
+ ))}
252
+ </Combobox.List>
253
+ )}
254
+ </Combobox.Content>
255
+ </Combobox.Select>
256
+ </Flex>
257
+ <Flex flex={1} style={{ minWidth: 0, width: '100%' }}>
258
+ <Combobox.Select
259
+ flex={1}
260
+ style={{ width: '100%' }}
261
+ disableClearSelection
262
+ itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
263
+ itemToString={(item: OperatorOption | null) => item?.label ?? ''}
264
+ items={operatorItems}
265
+ selectedItem={selectedOperator}
266
+ onChange={(item: OperatorOption | null) =>
267
+ onChange({
268
+ ...condition,
269
+ operator: (item?.value ??
270
+ 'is_equal_to') as SingleCondition['operator'],
271
+ })
237
272
  }
238
- size="small"
239
- />
240
- <Combobox.Content>
241
- {({ items }: { items: DataPointOption[] }) => (
242
- <Combobox.List>
243
- {items.map((item, i) => (
244
- <Combobox.Item index={i} item={item} key={item.fullKey}>
245
- {item.title}
246
- </Combobox.Item>
247
- ))}
248
- </Combobox.List>
249
- )}
250
- </Combobox.Content>
251
- </Combobox.Select>
252
- </Flex>
253
- <Flex flex={1} style={{ minWidth: 160 }}>
254
- <Combobox.Select
255
- flex={1}
256
- style={{ width: '100%' }}
257
- disableClearSelection
258
- itemToKey={(item: OperatorOption | null) => item?.value ?? ''}
259
- itemToString={(item: OperatorOption | null) => item?.label ?? ''}
260
- items={operatorItems}
261
- selectedItem={selectedOperator}
262
- onChange={(item: OperatorOption | null) =>
263
- onChange({
264
- ...condition,
265
- operator: (item?.value ??
266
- 'is_equal_to') as SingleCondition['operator'],
267
- })
268
- }
269
- >
270
- <Combobox.SelectTrigger
271
- label="Condition"
272
- placeholder="Select..."
273
- size="small"
274
- />
275
- <Combobox.Content>
276
- {({ items }: { items: OperatorOption[] }) => (
277
- <Combobox.List>
278
- {items.map((item, i) => (
279
- <Combobox.Item index={i} item={item} key={item.value}>
280
- {item.label}
281
- </Combobox.Item>
282
- ))}
283
- </Combobox.List>
284
- )}
285
- </Combobox.Content>
286
- </Combobox.Select>
287
- </Flex>
288
- {!isValueLess && (
289
- <Flex flex={1} style={{ minWidth: 160 }}>
290
- <LookupValueControl
291
- value={condition.value}
292
- fieldType={fieldType}
293
- lookupSource={selectedDataPoint?.lookupSource}
294
- onChange={value => onChange({ ...condition, value })}
295
- />
273
+ >
274
+ <Combobox.SelectTrigger
275
+ label="Condition"
276
+ placeholder="Select..."
277
+ size="small"
278
+ />
279
+ <Combobox.Content>
280
+ {({ items }: { items: OperatorOption[] }) => (
281
+ <Combobox.List>
282
+ {items.map((item, i) => (
283
+ <Combobox.Item index={i} item={item} key={item.value}>
284
+ {item.label}
285
+ </Combobox.Item>
286
+ ))}
287
+ </Combobox.List>
288
+ )}
289
+ </Combobox.Content>
290
+ </Combobox.Select>
296
291
  </Flex>
297
- )}
292
+ {!isValueLess && (
293
+ <Flex flex="0 0 186px" style={{ minWidth: 0 }}>
294
+ <LookupValueControl
295
+ value={condition.value}
296
+ fieldType={fieldType}
297
+ lookupSource={selectedDataPoint?.lookupSource}
298
+ onChange={value => onChange({ ...condition, value })}
299
+ />
300
+ </Flex>
301
+ )}
302
+ </Flex>
298
303
  {canRemove && (
299
304
  <div
300
305
  style={{
301
306
  display: 'flex',
302
307
  alignItems: 'flex-end',
303
308
  flexShrink: 0,
304
- padding: '4px 8px',
309
+ paddingBottom: 4,
305
310
  }}
306
311
  >
307
312
  <Button
@@ -1,13 +1,6 @@
1
1
  import { Button, Combobox, Dialog, Flex, Text, ThemeProvider } from '@servicetitan/anvil2';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
11
4
  import { ConditionGroupsSection, MatchType } from './ConditionGroupsSection';
12
5
  import { defaultState, MODAL_CONTENT_MAX_HEIGHT } from './constants';
13
6
  import { DisplayConditionRequest, onDisplayCondition } from './displayConditionController';
@@ -17,9 +10,13 @@ import {
17
10
  ConditionGroup as ConditionGroupType,
18
11
  DisplayBehavior,
19
12
  DisplayConditionState,
20
- FormFieldOption,
21
13
  LogicalOperator,
22
14
  } from './types';
15
+ import {
16
+ ConditionalsOpenHandler,
17
+ ConditionFormSelectHandler,
18
+ useConditionForms,
19
+ } from './useConditionForms';
23
20
 
24
21
  const BEHAVIOR_OPTIONS = [
25
22
  { label: 'Show', value: 'show' },
@@ -65,15 +62,8 @@ export interface DisplayConditionModalProps {
65
62
  * content is wrapped in `ThemeProvider` so Anvil2 styles still apply after portaling.
66
63
  */
67
64
  portalContainer?: Element | DocumentFragment | null;
68
- onConditionFormSelect?: (
69
- formId: number,
70
- sendFormFields: (formId: number, fields: FormFieldInfo[]) => void,
71
- ) => void;
72
- onConditionalsOpen?: (
73
- usedFormIds: number[],
74
- sendFormList: (forms: FormInfo[]) => void,
75
- sendFormFields: (formId: number, fields: FormFieldInfo[]) => void,
76
- ) => void;
65
+ onConditionFormSelect?: ConditionFormSelectHandler;
66
+ onConditionalsOpen?: ConditionalsOpenHandler;
77
67
  schema?: import('../shared/schema').SchemaObject;
78
68
  }
79
69
 
@@ -88,72 +78,14 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
88
78
  const [isOpen, setIsOpen] = useState(false);
89
79
  const [state, setState] = useState(defaultState);
90
80
  const [matchType, setMatchType] = useState<MatchType>('all');
91
- const [forms, setForms] = useState<FormInfo[]>([]);
92
- const [formFieldsByFormId, setFormFieldsByFormId] = useState<Record<number, FormFieldInfo[]>>(
93
- {},
94
- );
95
- const [loadingFormIds, setLoadingFormIds] = useState<number[]>([]);
96
- const [isFormsListLoading, setIsFormsListLoading] = useState(false);
97
- const formFieldsByFormIdRef = useRef<Record<number, FormFieldInfo[]>>({});
98
- const loadingFormIdsRef = useRef<number[]>([]);
81
+ const { formFieldOptions, forms, isFieldDataLoading, loadForms, requestFormFields } =
82
+ useConditionForms({ onConditionFormSelect, onConditionalsOpen });
99
83
 
100
84
  const dataPointOptions = useMemo(() => getSchemaDataPointOptions(schema), [schema]);
101
- const formFieldOptions = useMemo<FormFieldOption[]>(() => {
102
- const nextOptions: FormFieldOption[] = [];
103
- for (const form of forms) {
104
- const fields = formFieldsByFormId[form.id];
105
- if (!fields) {
106
- continue;
107
- }
108
- for (const field of fields) {
109
- const fieldType = getConditionalFieldTypeFromFormItemType(field.itemType);
110
- if (!fieldType) {
111
- continue;
112
- }
113
- nextOptions.push({
114
- fieldType,
115
- formId: form.id,
116
- fullKey: buildFormFieldKey(form.id, field.id),
117
- title: field.header,
118
- });
119
- }
120
- }
121
- return nextOptions.sort((left, right) => left.title.localeCompare(right.title));
122
- }, [forms, formFieldsByFormId]);
123
85
  const allFieldOptions = useMemo(
124
86
  () => [...dataPointOptions, ...formFieldOptions],
125
87
  [dataPointOptions, formFieldOptions],
126
88
  );
127
- const isFieldDataLoading = isFormsListLoading || loadingFormIds.length > 0;
128
-
129
- useEffect(() => {
130
- formFieldsByFormIdRef.current = formFieldsByFormId;
131
- }, [formFieldsByFormId]);
132
-
133
- useEffect(() => {
134
- loadingFormIdsRef.current = loadingFormIds;
135
- }, [loadingFormIds]);
136
-
137
- const requestFormFields = useCallback(
138
- (formId: number) => {
139
- if (!onConditionFormSelect) {
140
- return;
141
- }
142
- if (
143
- formFieldsByFormIdRef.current[formId] ||
144
- loadingFormIdsRef.current.includes(formId)
145
- ) {
146
- return;
147
- }
148
-
149
- setLoadingFormIds(prev => (prev.includes(formId) ? prev : [...prev, formId]));
150
- onConditionFormSelect(formId, (selectedFormId, fields) => {
151
- setFormFieldsByFormId(prev => ({ ...prev, [selectedFormId]: fields }));
152
- setLoadingFormIds(prev => prev.filter(id => id !== selectedFormId));
153
- });
154
- },
155
- [onConditionFormSelect],
156
- );
157
89
 
158
90
  useEffect(() => {
159
91
  return onDisplayCondition(nextRequest => {
@@ -161,49 +93,13 @@ export const DisplayConditionModal = (props: DisplayConditionModalProps) => {
161
93
  const existing = nextRequest.data;
162
94
  const parsed = parseUnlayerDisplayCondition(existing);
163
95
  const initialState = parsed ?? defaultState();
164
- const usedFormIds = Array.from(
165
- new Set(
166
- initialState.groups
167
- .flatMap(group => group.conditions)
168
- .map(condition => parseFormFieldKey(condition.dataPointKey)?.formId)
169
- .filter((formId): formId is number => formId != null),
170
- ),
171
- );
172
-
173
- setForms([]);
174
- setFormFieldsByFormId({});
175
- setIsFormsListLoading(!!onConditionalsOpen);
176
- setLoadingFormIds([]);
177
-
178
- if (onConditionalsOpen) {
179
- onConditionalsOpen(
180
- usedFormIds,
181
- nextForms => {
182
- setForms(nextForms);
183
- setIsFormsListLoading(false);
184
- },
185
- (formId, fields) => {
186
- setFormFieldsByFormId(prev => ({ ...prev, [formId]: fields }));
187
- setLoadingFormIds(prev => prev.filter(id => id !== formId));
188
- },
189
- );
190
-
191
- /*
192
- * Keep display-condition flow aligned with calculated-field flow:
193
- * when modal opens, request fields for already-used forms so labels
194
- * are available in edit mode.
195
- */
196
- usedFormIds.forEach(formId => requestFormFields(formId));
197
- } else {
198
- setIsFormsListLoading(false);
199
- setLoadingFormIds([]);
200
- }
201
96
 
97
+ loadForms(initialState.groups);
202
98
  setState(initialState);
203
99
  setMatchType(deriveMatchType(initialState));
204
100
  setIsOpen(true);
205
101
  });
206
- }, [onConditionalsOpen, requestFormFields]);
102
+ }, [loadForms]);
207
103
 
208
104
  const handleClose = useCallback(() => {
209
105
  setRequest(null);