@servicetitan/dte-unlayer 0.153.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.
- package/dist/display-conditions/ConditionRow.d.ts.map +1 -1
- package/dist/display-conditions/ConditionRow.js +111 -95
- package/dist/display-conditions/ConditionRow.js.map +1 -1
- package/dist/display-conditions/DisplayConditionModal.d.ts +3 -3
- package/dist/display-conditions/DisplayConditionModal.d.ts.map +1 -1
- package/dist/display-conditions/DisplayConditionModal.js +8 -95
- package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
- package/dist/display-conditions/useConditionForms.d.ts +22 -0
- package/dist/display-conditions/useConditionForms.d.ts.map +1 -0
- package/dist/display-conditions/useConditionForms.js +110 -0
- package/dist/display-conditions/useConditionForms.js.map +1 -0
- package/dist/e-sign-offset/ESignDateSignedOffsetModal.d.ts +11 -0
- package/dist/e-sign-offset/ESignDateSignedOffsetModal.d.ts.map +1 -0
- package/dist/e-sign-offset/ESignDateSignedOffsetModal.js +192 -0
- package/dist/e-sign-offset/ESignDateSignedOffsetModal.js.map +1 -0
- package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts +20 -0
- package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts.map +1 -0
- package/dist/e-sign-offset/ESignOffsetRuleRow.js +87 -0
- package/dist/e-sign-offset/ESignOffsetRuleRow.js.map +1 -0
- package/dist/e-sign-offset/eSignOffsetController.d.ts +10 -0
- package/dist/e-sign-offset/eSignOffsetController.d.ts.map +1 -0
- package/dist/e-sign-offset/eSignOffsetController.js +27 -0
- package/dist/e-sign-offset/eSignOffsetController.js.map +1 -0
- 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 +8 -0
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/merge-tags.d.ts +6 -10
- package/dist/merge-tags.d.ts.map +1 -1
- package/dist/merge-tags.js +35 -44
- package/dist/merge-tags.js.map +1 -1
- package/dist/shared/const.d.ts +30 -0
- package/dist/shared/const.d.ts.map +1 -1
- package/dist/shared/const.js +5 -0
- package/dist/shared/const.js.map +1 -1
- package/dist/shared/e-sign-offset.d.ts +21 -0
- package/dist/shared/e-sign-offset.d.ts.map +1 -0
- package/dist/shared/e-sign-offset.js +130 -0
- package/dist/shared/e-sign-offset.js.map +1 -0
- package/dist/shared/instance-id.d.ts +13 -0
- package/dist/shared/instance-id.d.ts.map +1 -0
- package/dist/shared/instance-id.js +22 -0
- package/dist/shared/instance-id.js.map +1 -0
- package/dist/store.d.ts +15 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +80 -5
- package/dist/store.js.map +1 -1
- package/dist/tools.d.ts +18 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +68 -1
- package/dist/tools.js.map +1 -1
- package/dist/unlayer-interface.d.ts +11 -1
- package/dist/unlayer-interface.d.ts.map +1 -1
- package/dist/unlayer-interface.js.map +1 -1
- package/dist/unlayer.d.ts.map +1 -1
- package/dist/unlayer.js +5 -3
- package/dist/unlayer.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/e-sign-offset.test.ts +281 -0
- package/src/__tests__/merge-tags.test.ts +98 -76
- package/src/__tests__/store.merge-tags.test.ts +186 -0
- package/src/display-conditions/ConditionRow.tsx +86 -81
- package/src/display-conditions/DisplayConditionModal.tsx +12 -116
- package/src/display-conditions/useConditionForms.ts +146 -0
- package/src/e-sign-offset/ESignDateSignedOffsetModal.tsx +197 -0
- package/src/e-sign-offset/ESignOffsetRuleRow.tsx +101 -0
- package/src/e-sign-offset/eSignOffsetController.ts +39 -0
- package/src/editor-core-source.ts +2 -2
- package/src/editor.tsx +9 -0
- package/src/index.ts +1 -2
- package/src/merge-tags.ts +41 -60
- package/src/shared/const.ts +37 -0
- package/src/shared/e-sign-offset.ts +173 -0
- package/src/shared/instance-id.ts +23 -0
- package/src/store.ts +106 -5
- package/src/tools.ts +87 -1
- package/src/unlayer-interface.tsx +11 -1
- package/src/unlayer.tsx +5 -3
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { Button, Combobox, Dialog, Flex, Text, ThemeProvider } from '@servicetitan/anvil2';
|
|
2
|
-
import { useCallback, useEffect, useMemo,
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
}, [
|
|
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
|
+
};
|