@servicetitan/dte-unlayer 0.154.0 → 0.156.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 (71) 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/image-upload-error.d.ts +5 -0
  32. package/dist/image-upload-error.d.ts.map +1 -0
  33. package/dist/image-upload-error.js +8 -0
  34. package/dist/image-upload-error.js.map +1 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/shared/const.d.ts +30 -0
  40. package/dist/shared/const.d.ts.map +1 -1
  41. package/dist/shared/const.js +5 -0
  42. package/dist/shared/const.js.map +1 -1
  43. package/dist/shared/e-sign-offset.d.ts +21 -0
  44. package/dist/shared/e-sign-offset.d.ts.map +1 -0
  45. package/dist/shared/e-sign-offset.js +130 -0
  46. package/dist/shared/e-sign-offset.js.map +1 -0
  47. package/dist/store.d.ts +1 -0
  48. package/dist/store.d.ts.map +1 -1
  49. package/dist/store.js +36 -11
  50. package/dist/store.js.map +1 -1
  51. package/dist/tools.d.ts +6 -0
  52. package/dist/tools.d.ts.map +1 -1
  53. package/dist/tools.js +21 -1
  54. package/dist/tools.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/__tests__/e-sign-offset.test.ts +281 -0
  57. package/src/__tests__/store.image-upload.test.ts +132 -0
  58. package/src/display-conditions/ConditionRow.tsx +86 -81
  59. package/src/display-conditions/DisplayConditionModal.tsx +12 -116
  60. package/src/display-conditions/useConditionForms.ts +146 -0
  61. package/src/e-sign-offset/ESignDateSignedOffsetModal.tsx +197 -0
  62. package/src/e-sign-offset/ESignOffsetRuleRow.tsx +101 -0
  63. package/src/e-sign-offset/eSignOffsetController.ts +39 -0
  64. package/src/editor-core-source.ts +2 -2
  65. package/src/editor.tsx +9 -0
  66. package/src/image-upload-error.ts +7 -0
  67. package/src/index.ts +1 -0
  68. package/src/shared/const.ts +37 -0
  69. package/src/shared/e-sign-offset.ts +173 -0
  70. package/src/store.ts +53 -8
  71. package/src/tools.ts +36 -1
@@ -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);
@@ -0,0 +1,146 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ buildFormFieldKey,
4
+ FormFieldInfo,
5
+ FormInfo,
6
+ getConditionalFieldTypeFromFormItemType,
7
+ parseFormFieldKey,
8
+ } from '../shared/forms';
9
+ import { ConditionGroup, FormFieldOption } from './types';
10
+
11
+ type SendFormFields = (formId: number, fields: FormFieldInfo[]) => void;
12
+
13
+ export type ConditionFormSelectHandler = (formId: number, sendFormFields: SendFormFields) => void;
14
+
15
+ export type ConditionalsOpenHandler = (
16
+ usedFormIds: number[],
17
+ sendFormList: (forms: FormInfo[]) => void,
18
+ sendFormFields: SendFormFields,
19
+ ) => void;
20
+
21
+ export interface UseConditionFormsOptions {
22
+ onConditionFormSelect?: ConditionFormSelectHandler;
23
+ onConditionalsOpen?: ConditionalsOpenHandler;
24
+ }
25
+
26
+ /**
27
+ * Loads the host's forms and form fields for a condition-editing modal, so form fields can be
28
+ * picked as condition data points alongside schema fields.
29
+ */
30
+ export const useConditionForms = ({
31
+ onConditionFormSelect,
32
+ onConditionalsOpen,
33
+ }: UseConditionFormsOptions) => {
34
+ const [forms, setForms] = useState<FormInfo[]>([]);
35
+ const [formFieldsByFormId, setFormFieldsByFormId] = useState<Record<number, FormFieldInfo[]>>(
36
+ {},
37
+ );
38
+ const [loadingFormIds, setLoadingFormIds] = useState<number[]>([]);
39
+ const [isFormsListLoading, setIsFormsListLoading] = useState(false);
40
+ const formFieldsByFormIdRef = useRef<Record<number, FormFieldInfo[]>>({});
41
+ const loadingFormIdsRef = useRef<number[]>([]);
42
+
43
+ const formFieldOptions = useMemo<FormFieldOption[]>(() => {
44
+ const nextOptions: FormFieldOption[] = [];
45
+ for (const form of forms) {
46
+ const fields = formFieldsByFormId[form.id];
47
+ if (!fields) {
48
+ continue;
49
+ }
50
+ for (const field of fields) {
51
+ const fieldType = getConditionalFieldTypeFromFormItemType(field.itemType);
52
+ if (!fieldType) {
53
+ continue;
54
+ }
55
+ nextOptions.push({
56
+ fieldType,
57
+ formId: form.id,
58
+ fullKey: buildFormFieldKey(form.id, field.id),
59
+ title: field.header,
60
+ });
61
+ }
62
+ }
63
+ return nextOptions.sort((left, right) => left.title.localeCompare(right.title));
64
+ }, [forms, formFieldsByFormId]);
65
+
66
+ useEffect(() => {
67
+ formFieldsByFormIdRef.current = formFieldsByFormId;
68
+ }, [formFieldsByFormId]);
69
+
70
+ useEffect(() => {
71
+ loadingFormIdsRef.current = loadingFormIds;
72
+ }, [loadingFormIds]);
73
+
74
+ const requestFormFields = useCallback(
75
+ (formId: number) => {
76
+ if (!onConditionFormSelect) {
77
+ return;
78
+ }
79
+ if (
80
+ formFieldsByFormIdRef.current[formId] ||
81
+ loadingFormIdsRef.current.includes(formId)
82
+ ) {
83
+ return;
84
+ }
85
+
86
+ setLoadingFormIds(prev => (prev.includes(formId) ? prev : [...prev, formId]));
87
+ onConditionFormSelect(formId, (selectedFormId, fields) => {
88
+ setFormFieldsByFormId(prev => ({ ...prev, [selectedFormId]: fields }));
89
+ setLoadingFormIds(prev => prev.filter(id => id !== selectedFormId));
90
+ });
91
+ },
92
+ [onConditionFormSelect],
93
+ );
94
+
95
+ /** Resets form data and requests the forms list plus fields of forms the groups already use. */
96
+ const loadForms = useCallback(
97
+ (groups: ConditionGroup[]) => {
98
+ const usedFormIds = Array.from(
99
+ new Set(
100
+ groups
101
+ .flatMap(group => group.conditions)
102
+ .map(condition => parseFormFieldKey(condition.dataPointKey)?.formId)
103
+ .filter((formId): formId is number => formId != null),
104
+ ),
105
+ );
106
+
107
+ /*
108
+ * Reset synchronously so `requestFormFields` below does not skip forms loaded by a
109
+ * previous open whose state is being cleared.
110
+ */
111
+ formFieldsByFormIdRef.current = {};
112
+ loadingFormIdsRef.current = [];
113
+ setForms([]);
114
+ setFormFieldsByFormId({});
115
+ setIsFormsListLoading(!!onConditionalsOpen);
116
+ setLoadingFormIds([]);
117
+
118
+ if (!onConditionalsOpen) {
119
+ return;
120
+ }
121
+
122
+ onConditionalsOpen(
123
+ usedFormIds,
124
+ nextForms => {
125
+ setForms(nextForms);
126
+ setIsFormsListLoading(false);
127
+ },
128
+ (formId, fields) => {
129
+ setFormFieldsByFormId(prev => ({ ...prev, [formId]: fields }));
130
+ setLoadingFormIds(prev => prev.filter(id => id !== formId));
131
+ },
132
+ );
133
+
134
+ usedFormIds.forEach(formId => requestFormFields(formId));
135
+ },
136
+ [onConditionalsOpen, requestFormFields],
137
+ );
138
+
139
+ return {
140
+ formFieldOptions,
141
+ forms,
142
+ isFieldDataLoading: isFormsListLoading || loadingFormIds.length > 0,
143
+ loadForms,
144
+ requestFormFields,
145
+ };
146
+ };
@@ -0,0 +1,197 @@
1
+ import { Button, Card, Dialog, Flex, Text, ThemeProvider } from '@servicetitan/anvil2';
2
+ import PlusCircleIcon from '@servicetitan/anvil2/assets/icons/material/round/add_circle.svg';
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { MODAL_CONTENT_MAX_HEIGHT } from '../display-conditions/constants';
6
+ import { getSchemaDataPointOptions } from '../display-conditions/schemaDataPoints';
7
+ import {
8
+ ConditionalsOpenHandler,
9
+ ConditionFormSelectHandler,
10
+ useConditionForms,
11
+ } from '../display-conditions/useConditionForms';
12
+ import type { ESignDateSignedOffsetRule } from '../shared/const';
13
+ import {
14
+ addESignOffsetRule,
15
+ buildESignOffsetSettings,
16
+ canAddESignOffsetRule,
17
+ createESignOffsetRule,
18
+ getESignOffsetRuleCondition,
19
+ isCompleteESignOffsetCondition,
20
+ normalizeESignOffsetSettings,
21
+ removeESignOffsetRule,
22
+ splitESignOffsetSettings,
23
+ updateESignOffsetRule,
24
+ } from '../shared/e-sign-offset';
25
+ import type { SchemaObject } from '../shared/schema';
26
+ import { ESignOffsetRequest, onESignOffsetRequest } from './eSignOffsetController';
27
+ import { ESignOffsetDaysField, ESignOffsetRuleRow } from './ESignOffsetRuleRow';
28
+
29
+ export interface ESignDateSignedOffsetModalProps {
30
+ /** Same contract as `DisplayConditionModalProps.portalContainer`. */
31
+ portalContainer?: Element | DocumentFragment | null;
32
+ onConditionFormSelect?: ConditionFormSelectHandler;
33
+ onConditionalsOpen?: ConditionalsOpenHandler;
34
+ schema?: SchemaObject;
35
+ }
36
+
37
+ export function ESignDateSignedOffsetModal({
38
+ onConditionFormSelect,
39
+ onConditionalsOpen,
40
+ portalContainer,
41
+ schema,
42
+ }: Readonly<ESignDateSignedOffsetModalProps>) {
43
+ const [request, setRequest] = useState<ESignOffsetRequest | null>(null);
44
+ const [rules, setRules] = useState<ESignDateSignedOffsetRule[]>([]);
45
+ const [finalDays, setFinalDays] = useState(0);
46
+ const { formFieldOptions, forms, isFieldDataLoading, loadForms, requestFormFields } =
47
+ useConditionForms({ onConditionFormSelect, onConditionalsOpen });
48
+
49
+ const dataPointOptions = useMemo(() => getSchemaDataPointOptions(schema), [schema]);
50
+ const allFieldOptions = useMemo(
51
+ () => [...dataPointOptions, ...formFieldOptions],
52
+ [dataPointOptions, formFieldOptions],
53
+ );
54
+
55
+ useEffect(() => {
56
+ return onESignOffsetRequest(nextRequest => {
57
+ const { conditionedRules, finalDays: nextFinalDays } = splitESignOffsetSettings(
58
+ normalizeESignOffsetSettings(nextRequest.value),
59
+ );
60
+ const initialRules = conditionedRules.length
61
+ ? conditionedRules
62
+ : [createESignOffsetRule()];
63
+
64
+ loadForms(initialRules.flatMap(rule => rule.condition.groups));
65
+ setRules(initialRules);
66
+ setFinalDays(nextFinalDays);
67
+ setRequest(nextRequest);
68
+ });
69
+ }, [loadForms]);
70
+
71
+ const canSave = useMemo(() => {
72
+ if (isFieldDataLoading) {
73
+ return false;
74
+ }
75
+
76
+ return rules.every(rule => {
77
+ const condition = getESignOffsetRuleCondition(rule);
78
+ const fieldType =
79
+ allFieldOptions.find(option => option.fullKey === condition?.dataPointKey)
80
+ ?.fieldType ?? 'string';
81
+
82
+ return isCompleteESignOffsetCondition(condition, fieldType);
83
+ });
84
+ }, [allFieldOptions, isFieldDataLoading, rules]);
85
+
86
+ const handleClose = useCallback(() => {
87
+ setRequest(null);
88
+ }, []);
89
+
90
+ const handleSave = useCallback(() => {
91
+ request?.done(buildESignOffsetSettings(rules, finalDays));
92
+ setRequest(null);
93
+ }, [finalDays, request, rules]);
94
+
95
+ const handleClearRules = useCallback(() => {
96
+ setRules([]);
97
+ setFinalDays(0);
98
+ }, []);
99
+
100
+ const handleAddRule = useCallback(() => {
101
+ setRules(prev => addESignOffsetRule(prev));
102
+ }, []);
103
+
104
+ const handleUpdateRule = useCallback((index: number, rule: ESignDateSignedOffsetRule) => {
105
+ setRules(prev => updateESignOffsetRule(prev, index, rule));
106
+ }, []);
107
+
108
+ const handleRemoveRule = useCallback((index: number) => {
109
+ setRules(prev => removeESignOffsetRule(prev, index));
110
+ }, []);
111
+
112
+ if (!request) {
113
+ return null;
114
+ }
115
+
116
+ const dialog = (
117
+ <Dialog open onClose={handleClose} size="xlarge">
118
+ <Dialog.Header>Date Signed Offset Rules</Dialog.Header>
119
+ <Dialog.Content>
120
+ <Flex
121
+ direction="column"
122
+ gap="4"
123
+ style={{
124
+ maxHeight: MODAL_CONTENT_MAX_HEIGHT,
125
+ overflowY: 'auto',
126
+ paddingRight: 8,
127
+ width: '100%',
128
+ }}
129
+ >
130
+ {rules.length === 0 && (
131
+ <Flex justifyContent="center" alignItems="center">
132
+ <Text variant="body" size="xlarge" subdued>
133
+ Rule not configured yet.
134
+ </Text>
135
+ </Flex>
136
+ )}
137
+ {rules.map((rule, index) => (
138
+ <ESignOffsetRuleRow
139
+ key={rule.condition.groups[0]?.id ?? index}
140
+ dataPointOptions={dataPointOptions}
141
+ formFieldOptions={formFieldOptions}
142
+ forms={forms}
143
+ onChange={nextRule => handleUpdateRule(index, nextRule)}
144
+ onFormSelect={requestFormFields}
145
+ onRemove={() => handleRemoveRule(index)}
146
+ rule={rule}
147
+ />
148
+ ))}
149
+ <Flex>
150
+ <Button
151
+ size="small"
152
+ appearance="ghost"
153
+ icon={{ before: PlusCircleIcon }}
154
+ onClick={handleAddRule}
155
+ disabled={!canAddESignOffsetRule(rules)}
156
+ >
157
+ {rules.length === 0 ? 'Add Condition' : 'Add Else Condition'}
158
+ </Button>
159
+ </Flex>
160
+ <Card background="strong" padding="small">
161
+ <ESignOffsetDaysField
162
+ chipLabel={rules.length === 0 ? 'THEN' : 'ELSE'}
163
+ days={finalDays}
164
+ onChange={setFinalDays}
165
+ />
166
+ </Card>
167
+ </Flex>
168
+ </Dialog.Content>
169
+ <Dialog.Footer sticky>
170
+ <Flex
171
+ justifyContent="flex-end"
172
+ alignItems="center"
173
+ gap="3"
174
+ style={{ width: '100%' }}
175
+ >
176
+ {rules.length > 0 && (
177
+ <Button appearance="ghost" onClick={handleClearRules}>
178
+ Clear Rules
179
+ </Button>
180
+ )}
181
+ <Button appearance="secondary" onClick={handleClose}>
182
+ Cancel
183
+ </Button>
184
+ <Button appearance="primary" disabled={!canSave} onClick={handleSave}>
185
+ Save Changes
186
+ </Button>
187
+ </Flex>
188
+ </Dialog.Footer>
189
+ </Dialog>
190
+ );
191
+
192
+ if (portalContainer) {
193
+ return createPortal(<ThemeProvider>{dialog}</ThemeProvider>, portalContainer);
194
+ }
195
+
196
+ return dialog;
197
+ }
@@ -0,0 +1,101 @@
1
+ import { Card, Chip, Flex, NumberField } from '@servicetitan/anvil2';
2
+ import { ConditionRow } from '../display-conditions/ConditionRow';
3
+ import { defaultCondition, defaultGroup } from '../display-conditions/constants';
4
+ import type {
5
+ DataPointOption,
6
+ FormFieldOption,
7
+ SingleCondition,
8
+ } from '../display-conditions/types';
9
+ import { type ESignDateSignedOffsetRule, MAX_E_SIGN_OFFSET_DAYS } from '../shared/const';
10
+ import { clampESignOffsetDays } from '../shared/e-sign-offset';
11
+ import { FormInfo } from '../shared/forms';
12
+
13
+ export interface ESignOffsetDaysFieldProps {
14
+ chipLabel: string;
15
+ days: number;
16
+ onChange: (days: number) => void;
17
+ }
18
+
19
+ export function ESignOffsetDaysField({
20
+ chipLabel,
21
+ days,
22
+ onChange,
23
+ }: Readonly<ESignOffsetDaysFieldProps>) {
24
+ return (
25
+ <Flex>
26
+ <Flex alignItems="center" style={{ padding: '8px 12px' }}>
27
+ <Chip label={chipLabel} size="medium" />
28
+ </Flex>
29
+ <NumberField
30
+ label="Number of days"
31
+ size="small"
32
+ value={days}
33
+ minValue={0}
34
+ maxValue={MAX_E_SIGN_OFFSET_DAYS}
35
+ maximumFractionDigits={0}
36
+ suffix="days"
37
+ hint={`days range up to ${MAX_E_SIGN_OFFSET_DAYS}`}
38
+ onChange={value => onChange(clampESignOffsetDays(value))}
39
+ />
40
+ </Flex>
41
+ );
42
+ }
43
+
44
+ export interface ESignOffsetRuleRowProps {
45
+ dataPointOptions: DataPointOption[];
46
+ formFieldOptions: FormFieldOption[];
47
+ forms: FormInfo[];
48
+ onChange: (rule: ESignDateSignedOffsetRule) => void;
49
+ onFormSelect: (formId: number) => void;
50
+ onRemove: () => void;
51
+ rule: ESignDateSignedOffsetRule;
52
+ }
53
+
54
+ export function ESignOffsetRuleRow({
55
+ dataPointOptions,
56
+ formFieldOptions,
57
+ forms,
58
+ onChange,
59
+ onFormSelect,
60
+ onRemove,
61
+ rule,
62
+ }: Readonly<ESignOffsetRuleRowProps>) {
63
+ const group = rule.condition.groups[0] ?? defaultGroup();
64
+ const condition = group.conditions[0] ?? defaultCondition();
65
+
66
+ const handleConditionChange = (nextCondition: SingleCondition) => {
67
+ onChange({
68
+ ...rule,
69
+ condition: {
70
+ behavior: 'show',
71
+ groups: [{ conditions: [nextCondition], id: group.id }],
72
+ },
73
+ });
74
+ };
75
+
76
+ const handleDaysChange = (days: number) => {
77
+ onChange({ ...rule, days });
78
+ };
79
+
80
+ return (
81
+ <Card background="strong" padding="small">
82
+ <Flex direction="column" gap="3">
83
+ <ConditionRow
84
+ canRemove
85
+ condition={condition}
86
+ dataPointOptions={dataPointOptions}
87
+ formFieldOptions={formFieldOptions}
88
+ forms={forms}
89
+ onChange={handleConditionChange}
90
+ onFormSelect={onFormSelect}
91
+ onRemove={onRemove}
92
+ />
93
+ <ESignOffsetDaysField
94
+ chipLabel="THEN"
95
+ days={rule.days}
96
+ onChange={handleDaysChange}
97
+ />
98
+ </Flex>
99
+ </Card>
100
+ );
101
+ }
@@ -0,0 +1,39 @@
1
+ import type { ESignDateSignedOffsetSettings } from '../shared/const';
2
+
3
+ export interface ESignOffsetRequest {
4
+ value: ESignDateSignedOffsetSettings;
5
+ done: (value: ESignDateSignedOffsetSettings) => void;
6
+ }
7
+
8
+ type Listener = (request: ESignOffsetRequest) => void;
9
+
10
+ const E_SIGN_OFFSET_EVENT = 'dte:e-sign-offset';
11
+ const eSignOffsetEvents = new EventTarget();
12
+ let activeListeners = 0;
13
+
14
+ export const onESignOffsetRequest = (listener: Listener) => {
15
+ const handler = (event: Event) => {
16
+ if (event instanceof CustomEvent && event.detail) {
17
+ listener(event.detail);
18
+ }
19
+ };
20
+
21
+ activeListeners += 1;
22
+ eSignOffsetEvents.addEventListener(E_SIGN_OFFSET_EVENT, handler);
23
+
24
+ return () => {
25
+ activeListeners = Math.max(0, activeListeners - 1);
26
+ eSignOffsetEvents.removeEventListener(E_SIGN_OFFSET_EVENT, handler);
27
+ };
28
+ };
29
+
30
+ export const emitESignOffsetRequest = (request: ESignOffsetRequest) => {
31
+ if (activeListeners === 0) {
32
+ request.done(request.value);
33
+ return;
34
+ }
35
+
36
+ eSignOffsetEvents.dispatchEvent(
37
+ new CustomEvent<ESignOffsetRequest>(E_SIGN_OFFSET_EVENT, { detail: request }),
38
+ );
39
+ };