@servicetitan/dte-pdf-editor 1.96.2 → 1.97.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/README.md +11 -2
- package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +1 -0
- package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
- package/dist/components/field-config-panel/e-sign-offset-rule-row.js +1 -1
- package/dist/components/field-config-panel/e-sign-offset-rule-row.js.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.js +1 -0
- package/dist/components/field-config-panel/e-sign-options-modal.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.js +24 -42
- package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.d.ts +5 -0
- package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.js +5 -2
- package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-modal.js +4 -3
- package/dist/components/field-config-panel/formula-modal.js.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts +6 -8
- package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
- package/dist/constants/field.constants.js +1 -1
- package/dist/interface/types.d.ts +4 -8
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/styles/field-config-panel-overlay.css +24 -0
- package/dist/utils/formula/index.d.ts +0 -1
- package/dist/utils/formula/index.d.ts.map +1 -1
- package/dist/utils/formula/index.js +0 -1
- package/dist/utils/formula/index.js.map +1 -1
- package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +3 -0
- package/dist/vendor/dte-shared/business-calendar-validation.utils.js +4 -0
- package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +1 -1
- package/dist/vendor/dte-shared/components/business-calendar-section.js +3 -2
- package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +5 -3
- package/dist/vendor/dte-shared/components/schedule-drawer.js +19 -12
- package/dist/vendor/dte-shared/components/schedule-picker.js +10 -10
- package/dist/vendor/dte-shared/constants.d.ts +21 -0
- package/dist/vendor/dte-shared/constants.js +24 -0
- package/dist/vendor/dte-shared/field-schedule-editor-context.d.ts +31 -0
- package/dist/vendor/dte-shared/field-schedule-editor-context.js +110 -0
- package/dist/vendor/dte-shared/index.d.ts +2 -0
- package/dist/vendor/dte-shared/index.js +2 -0
- package/dist/vendor/dte-shared/schedule-bindings.utils.d.ts +22 -0
- package/dist/vendor/dte-shared/schedule-bindings.utils.js +31 -0
- package/dist/vendor/dte-shared/styles/schedule.css +2 -2
- package/dist/vendor/dte-shared/types.d.ts +62 -0
- package/package.json +2 -2
- package/src/components/field-config-panel/__tests__/field-config-panel-overlay.test.tsx +16 -0
- package/src/components/field-config-panel/__tests__/schedule-editor-placement.test.tsx +289 -0
- package/src/components/field-config-panel/advanced-settings.tsx +1 -0
- package/src/components/field-config-panel/e-sign-offset-rule-row.tsx +1 -1
- package/src/components/field-config-panel/e-sign-options-modal.tsx +1 -0
- package/src/components/field-config-panel/field-config-panel-overlay.tsx +64 -95
- package/src/components/field-config-panel/field-config-panel.tsx +10 -1
- package/src/components/field-config-panel/formula-modal.tsx +5 -8
- package/src/components/pdf-editor/pdf-editor.tsx +6 -8
- package/src/constants/field.constants.ts +1 -1
- package/src/interface/types.ts +4 -8
- package/src/styles/field-config-panel-overlay.css +24 -0
- package/src/utils/formula/index.ts +0 -1
- package/dist/utils/formula/schedule-bindings.utils.d.ts +0 -12
- package/dist/utils/formula/schedule-bindings.utils.d.ts.map +0 -1
- package/dist/utils/formula/schedule-bindings.utils.js +0 -17
- package/dist/utils/formula/schedule-bindings.utils.js.map +0 -1
- package/src/utils/formula/__tests__/schedule-bindings.utils.test.ts +0 -73
- package/src/utils/formula/schedule-bindings.utils.ts +0 -27
|
@@ -40,6 +40,62 @@ export interface SaveScheduleRequest extends BusinessCalendarRules {
|
|
|
40
40
|
/** Display name, unique per tenant ignoring case, max 255 characters, and not "Default". */
|
|
41
41
|
name: string;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A date calculated field whose custom business-day exclusion toggle is on, and the schedule it
|
|
45
|
+
* uses (DTE's bindings, full-replace via `PUT api/holidays/templates/{id}/bindings`). A field with
|
|
46
|
+
* no entry has the toggle off; what that means for its date arithmetic is up to the editor.
|
|
47
|
+
*/
|
|
48
|
+
export interface FieldScheduleBinding {
|
|
49
|
+
/** The field's key: `PdfField.id` in BYOD, the tool's instance id (`values.id`) in Unlayer. */
|
|
50
|
+
fieldKey: string;
|
|
51
|
+
/** One of the tenant's schedules, or undefined for the default schedule. */
|
|
52
|
+
scheduleId?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The business-day schedule props an editor (`PdfEditor`, `UnlayerEditor`) takes from its host,
|
|
56
|
+
* with the same names and meaning in every editor. The editor builds each field's
|
|
57
|
+
* `ScheduleEditorContext` from them (`useFieldScheduleEditorContext`).
|
|
58
|
+
*/
|
|
59
|
+
export interface BusinessCalendarHostProps {
|
|
60
|
+
/**
|
|
61
|
+
* Date calculated fields whose custom business-day exclusion toggle is on, and the schedule
|
|
62
|
+
* each uses. Also drives the toggle/picker of the field being edited.
|
|
63
|
+
*/
|
|
64
|
+
scheduleBindings?: FieldScheduleBinding[];
|
|
65
|
+
/** The tenant's known holidays, for the schedule editor's checklist. */
|
|
66
|
+
holidayCatalog?: HolidayCatalog;
|
|
67
|
+
holidayCatalogLoading?: boolean;
|
|
68
|
+
holidayCatalogError?: string;
|
|
69
|
+
/** The schedules the tenant can pick from the schedule picker. */
|
|
70
|
+
schedules?: BusinessDaySchedule[];
|
|
71
|
+
schedulesLoading?: boolean;
|
|
72
|
+
schedulesError?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Called with the complete desired `scheduleBindings` whenever the toggle, the schedule picker
|
|
75
|
+
* or a schedule delete changes them (full replacement, mirroring DTE's
|
|
76
|
+
* `PUT api/holidays/templates/{id}/bindings`). The editor does not persist them itself.
|
|
77
|
+
*/
|
|
78
|
+
onScheduleBindingsChange?(bindings: FieldScheduleBinding[]): void;
|
|
79
|
+
/**
|
|
80
|
+
* Creates a tenant schedule from the "Create New Schedule" drawer. Always creates a new
|
|
81
|
+
* schedule — it never overwrites a selected one. On success the created schedule is selected
|
|
82
|
+
* in the picker. A rejected promise's `message` is shown as the drawer's error (for example a
|
|
83
|
+
* duplicate-name conflict).
|
|
84
|
+
*/
|
|
85
|
+
onCreateSchedule?(request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
|
|
86
|
+
/**
|
|
87
|
+
* Replaces a tenant schedule's name and rules in place, from the picker's "Edit" action (DTE's
|
|
88
|
+
* `PUT api/holidays/schedules/{id}`). On success the updated schedule stays selected.
|
|
89
|
+
*/
|
|
90
|
+
onUpdateSchedule?(id: number, request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
|
|
91
|
+
/**
|
|
92
|
+
* Permanently deletes a tenant schedule, from the picker's "Delete" action (DTE's
|
|
93
|
+
* `DELETE api/holidays/schedules/{id}`). On success every field bound to it moves to the
|
|
94
|
+
* default schedule (toggle still on) through `onScheduleBindingsChange`, as DTE falls back to
|
|
95
|
+
* the default schedule for a deleted one.
|
|
96
|
+
*/
|
|
97
|
+
onDeleteSchedule?(id: number): Promise<void>;
|
|
98
|
+
}
|
|
43
99
|
/**
|
|
44
100
|
* Prop bundle for the business-calendar toggle/picker/drawer, built by a host package (currently
|
|
45
101
|
* `@servicetitan/dte-pdf-editor` and `@servicetitan/dte-unlayer`) from whatever it uses to track
|
|
@@ -57,6 +113,12 @@ export interface ScheduleEditorContext {
|
|
|
57
113
|
schedules?: BusinessDaySchedule[];
|
|
58
114
|
schedulesLoading?: boolean;
|
|
59
115
|
schedulesError?: string;
|
|
116
|
+
/**
|
|
117
|
+
* When set, the schedule form ("Create New Schedule" / "Edit Schedule") is rendered into this
|
|
118
|
+
* element through a portal instead of opening in a drawer. The host owns the element and decides
|
|
119
|
+
* how it is shown; leave it unset (or null, before the element exists) for the drawer.
|
|
120
|
+
*/
|
|
121
|
+
editorContainer?: HTMLElement | null;
|
|
60
122
|
onToggle(enabled: boolean): void;
|
|
61
123
|
onSelectSchedule(scheduleId: number | undefined): void;
|
|
62
124
|
onCreateSchedule?(request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@servicetitan/dte-pdf-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.97.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"typings": "./dist/index.d.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registry": "https://registry.npmjs.org"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "npm run build --prefix ../shared && tsc --build && startup prepare-package && node scripts/bundle-shared.mjs"
|
|
16
|
+
"build": "npm run build --prefix ../shared && tsc --build && startup prepare-package && node ../shared/scripts/bundle-shared.mjs"
|
|
17
17
|
},
|
|
18
18
|
"cli": {
|
|
19
19
|
"webpack": false
|
|
@@ -68,6 +68,22 @@ describe('FieldConfigPanelOverlay', () => {
|
|
|
68
68
|
container.remove();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
describe('the schedule form slot', () => {
|
|
72
|
+
test('gives the panel an empty element inside the overlay to portal the form into', async () => {
|
|
73
|
+
await render();
|
|
74
|
+
|
|
75
|
+
const { scheduleEditorContainer } = panel.mock.calls[panel.mock.calls.length - 1][0];
|
|
76
|
+
|
|
77
|
+
expect(scheduleEditorContainer).toBeInstanceOf(HTMLElement);
|
|
78
|
+
expect(scheduleEditorContainer.childNodes).toHaveLength(0);
|
|
79
|
+
expect(
|
|
80
|
+
container
|
|
81
|
+
.querySelector('.dte-field-config-panel-overlay')
|
|
82
|
+
?.contains(scheduleEditorContainer),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
71
87
|
describe('without onScheduleBindingsChange', () => {
|
|
72
88
|
test('gives the panel no schedule context', async () => {
|
|
73
89
|
await render();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where "Create new schedule" opens its form. A calculated field keeps the drawer; an e-sign
|
|
3
|
+
* `dateSignedOffset` field portals the same form into the element the overlay gives the panel
|
|
4
|
+
* (`editorContainer`). anvil2 renders the drawer as a `<dialog>`, so a form with no `<dialog>`
|
|
5
|
+
* around it is not in a drawer.
|
|
6
|
+
*/
|
|
7
|
+
jest.mock('../formula-generator', () => ({ FormulaGenerator: jest.fn(() => null) }));
|
|
8
|
+
|
|
9
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
10
|
+
|
|
11
|
+
// jsdom has no TextEncoder; anvil2 reads it when it is loaded, so this must run before it is imported.
|
|
12
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
13
|
+
|
|
14
|
+
import { act } from 'react';
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
16
|
+
import {
|
|
17
|
+
type CalculatedFieldFormat,
|
|
18
|
+
ESignFieldType,
|
|
19
|
+
FieldTypeEnum,
|
|
20
|
+
type PdfField,
|
|
21
|
+
} from '../../../interface/types';
|
|
22
|
+
import { AdvancedSettings } from '../advanced-settings';
|
|
23
|
+
import { ESignOptionsModal } from '../e-sign-options-modal';
|
|
24
|
+
import { FieldConfigPanel } from '../field-config-panel';
|
|
25
|
+
import { FormulaGenerator } from '../formula-generator';
|
|
26
|
+
|
|
27
|
+
// jsdom has no matchMedia, ResizeObserver, IntersectionObserver, popover or dialog API; anvil2 uses them.
|
|
28
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
29
|
+
writable: true,
|
|
30
|
+
value: (query: string) => ({
|
|
31
|
+
matches: false,
|
|
32
|
+
media: query,
|
|
33
|
+
onchange: null,
|
|
34
|
+
addListener: () => {},
|
|
35
|
+
removeListener: () => {},
|
|
36
|
+
addEventListener: () => {},
|
|
37
|
+
removeEventListener: () => {},
|
|
38
|
+
dispatchEvent: () => false,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
class ObserverMock {
|
|
42
|
+
observe() {}
|
|
43
|
+
unobserve() {}
|
|
44
|
+
disconnect() {}
|
|
45
|
+
}
|
|
46
|
+
global.ResizeObserver = ObserverMock as unknown as typeof ResizeObserver;
|
|
47
|
+
global.IntersectionObserver = ObserverMock as unknown as typeof IntersectionObserver;
|
|
48
|
+
HTMLElement.prototype.showPopover ??= () => {};
|
|
49
|
+
HTMLElement.prototype.hidePopover ??= () => {};
|
|
50
|
+
HTMLElement.prototype.togglePopover ??= () => false;
|
|
51
|
+
HTMLDialogElement.prototype.show ??= () => {};
|
|
52
|
+
HTMLDialogElement.prototype.showModal ??= () => {};
|
|
53
|
+
HTMLDialogElement.prototype.close ??= () => {};
|
|
54
|
+
|
|
55
|
+
const PICKER_LABEL = 'Select Schedule or create new';
|
|
56
|
+
const FORM_TITLE = 'Create New Schedule';
|
|
57
|
+
|
|
58
|
+
const defaultSchedule = {
|
|
59
|
+
name: 'Weekends and US Federal Holidays',
|
|
60
|
+
isDefault: true,
|
|
61
|
+
calendar: { weekendDays: [0, 6] as (0 | 6)[], knownHolidays: [], customDates: [] },
|
|
62
|
+
};
|
|
63
|
+
const created = {
|
|
64
|
+
id: 7,
|
|
65
|
+
name: 'Weekdays only',
|
|
66
|
+
isDefault: false,
|
|
67
|
+
calendar: { weekendDays: [0, 6] as (0 | 6)[], knownHolidays: [], customDates: [] },
|
|
68
|
+
};
|
|
69
|
+
const dateFormat: CalculatedFieldFormat = { resultType: 'date', dateFormat: 'MM/DD/YYYY' };
|
|
70
|
+
const noOp = () => {};
|
|
71
|
+
|
|
72
|
+
describe('where the schedule form opens', () => {
|
|
73
|
+
let container: HTMLDivElement;
|
|
74
|
+
let slot: HTMLDivElement;
|
|
75
|
+
let root: Root;
|
|
76
|
+
const onSelectSchedule = jest.fn();
|
|
77
|
+
const onCreateSchedule = jest.fn();
|
|
78
|
+
const businessCalendar = (editorContainer?: HTMLElement | null) => ({
|
|
79
|
+
enabled: true,
|
|
80
|
+
schedules: [defaultSchedule],
|
|
81
|
+
editorContainer,
|
|
82
|
+
onToggle: noOp,
|
|
83
|
+
onSelectSchedule,
|
|
84
|
+
onCreateSchedule,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const render = (element: JSX.Element) =>
|
|
88
|
+
act(async () => {
|
|
89
|
+
root.render(element);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const renderESign = (editorContainer: HTMLElement | null = slot) =>
|
|
93
|
+
render(
|
|
94
|
+
<ESignOptionsModal
|
|
95
|
+
initialState={{ rules: [{ days: 3 }] }}
|
|
96
|
+
onSave={noOp}
|
|
97
|
+
businessCalendar={businessCalendar(editorContainer)}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const renderCalculated = () =>
|
|
102
|
+
render(
|
|
103
|
+
<AdvancedSettings
|
|
104
|
+
format={dateFormat}
|
|
105
|
+
setFormat={noOp}
|
|
106
|
+
businessCalendar={businessCalendar()}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const renderPanel = (field: PdfField) =>
|
|
111
|
+
render(
|
|
112
|
+
<FieldConfigPanel
|
|
113
|
+
field={field}
|
|
114
|
+
recipients={[{ id: 1, name: 'customer', displayName: 'Customer' }]}
|
|
115
|
+
forms={[]}
|
|
116
|
+
hideConditionalLogic
|
|
117
|
+
businessCalendar={businessCalendar()}
|
|
118
|
+
scheduleEditorContainer={slot}
|
|
119
|
+
onDeleteField={noOp}
|
|
120
|
+
onFieldConfigChange={noOp}
|
|
121
|
+
onRequestFormFields={noOp}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const findDrawer = () => document.querySelector('dialog');
|
|
126
|
+
|
|
127
|
+
// The item is in the DOM while the dropdown is closed, so there is no need to open it first.
|
|
128
|
+
const clickCreateNewSchedule = () =>
|
|
129
|
+
act(async () => {
|
|
130
|
+
[...document.querySelectorAll('button')]
|
|
131
|
+
.find(button => button.textContent?.includes('Create new schedule'))
|
|
132
|
+
?.click();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const clickButton = (parent: HTMLElement, label: string) =>
|
|
136
|
+
act(async () => {
|
|
137
|
+
[...parent.querySelectorAll('button')]
|
|
138
|
+
.find(button => button.textContent?.trim() === label)
|
|
139
|
+
?.click();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const clickClose = () =>
|
|
143
|
+
act(async () => {
|
|
144
|
+
slot.querySelector<HTMLButtonElement>('button[aria-label="Close"]')?.click();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const typeScheduleName = (name: string) =>
|
|
148
|
+
act(async () => {
|
|
149
|
+
const input = slot.querySelector('input') as HTMLInputElement;
|
|
150
|
+
const setValue = Object.getOwnPropertyDescriptor(
|
|
151
|
+
HTMLInputElement.prototype,
|
|
152
|
+
'value',
|
|
153
|
+
)?.set;
|
|
154
|
+
setValue?.call(input, name);
|
|
155
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
beforeAll(() => {
|
|
159
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
onSelectSchedule.mockClear();
|
|
164
|
+
onCreateSchedule.mockReset().mockResolvedValue(created);
|
|
165
|
+
(FormulaGenerator as unknown as jest.Mock).mockClear();
|
|
166
|
+
container = document.createElement('div');
|
|
167
|
+
slot = document.createElement('div');
|
|
168
|
+
document.body.append(container, slot);
|
|
169
|
+
root = createRoot(container);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
afterEach(async () => {
|
|
173
|
+
await act(async () => root.unmount());
|
|
174
|
+
container.remove();
|
|
175
|
+
slot.remove();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('for a calculated field', () => {
|
|
179
|
+
test('shows the form in a drawer, next to the picker', async () => {
|
|
180
|
+
await renderCalculated();
|
|
181
|
+
expect(findDrawer()).toBeNull();
|
|
182
|
+
|
|
183
|
+
await clickCreateNewSchedule();
|
|
184
|
+
|
|
185
|
+
expect(findDrawer()?.textContent).toContain(FORM_TITLE);
|
|
186
|
+
expect(container.textContent).toContain(PICKER_LABEL);
|
|
187
|
+
expect(slot.textContent).toBe('');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('is not given the container by the config panel', async () => {
|
|
191
|
+
await renderPanel({
|
|
192
|
+
id: 'c1',
|
|
193
|
+
type: FieldTypeEnum.calculated,
|
|
194
|
+
path: 'c1',
|
|
195
|
+
} as unknown as PdfField);
|
|
196
|
+
|
|
197
|
+
const generatorProps = (FormulaGenerator as unknown as jest.Mock).mock.calls.at(
|
|
198
|
+
-1,
|
|
199
|
+
)?.[0];
|
|
200
|
+
expect(generatorProps.businessCalendar).toBeDefined();
|
|
201
|
+
expect(generatorProps.businessCalendar.editorContainer).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('for an e-sign date signed offset field', () => {
|
|
206
|
+
test('portals the form into the container and opens no drawer', async () => {
|
|
207
|
+
await renderESign();
|
|
208
|
+
expect(slot.textContent).toBe('');
|
|
209
|
+
|
|
210
|
+
await clickCreateNewSchedule();
|
|
211
|
+
|
|
212
|
+
expect(slot.textContent).toContain(FORM_TITLE);
|
|
213
|
+
expect(slot.textContent).toContain('Schedule Name');
|
|
214
|
+
expect(container.textContent).not.toContain(FORM_TITLE);
|
|
215
|
+
expect(findDrawer()).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('leaves the field settings mounted while the form is open', async () => {
|
|
219
|
+
await renderESign();
|
|
220
|
+
|
|
221
|
+
await clickCreateNewSchedule();
|
|
222
|
+
|
|
223
|
+
expect(container.textContent).toContain('Number of days');
|
|
224
|
+
expect(container.textContent).toContain(PICKER_LABEL);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('opens the drawer while the container does not exist yet', async () => {
|
|
228
|
+
await renderESign(null);
|
|
229
|
+
|
|
230
|
+
await clickCreateNewSchedule();
|
|
231
|
+
|
|
232
|
+
expect(findDrawer()?.textContent).toContain(FORM_TITLE);
|
|
233
|
+
expect(slot.textContent).toBe('');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('gets the container from the config panel', async () => {
|
|
237
|
+
await renderPanel({
|
|
238
|
+
id: 'e1',
|
|
239
|
+
type: FieldTypeEnum.eSign,
|
|
240
|
+
subType: ESignFieldType.dateSignedOffset,
|
|
241
|
+
recipient: 'customer',
|
|
242
|
+
path: 'esign.customer.dateSignedOffset',
|
|
243
|
+
// The switch of an e-sign field is stored in its data, not in the schedule context.
|
|
244
|
+
data: { rules: [{ days: 3 }], businessCalendarEnabled: true },
|
|
245
|
+
} as unknown as PdfField);
|
|
246
|
+
|
|
247
|
+
await clickCreateNewSchedule();
|
|
248
|
+
|
|
249
|
+
expect(slot.textContent).toContain(FORM_TITLE);
|
|
250
|
+
expect(findDrawer()).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('creates the schedule, selects it and empties the container', async () => {
|
|
254
|
+
await renderESign();
|
|
255
|
+
await clickCreateNewSchedule();
|
|
256
|
+
await typeScheduleName('Weekdays only');
|
|
257
|
+
|
|
258
|
+
await clickButton(slot, 'Create');
|
|
259
|
+
|
|
260
|
+
expect(onCreateSchedule).toHaveBeenCalledWith(
|
|
261
|
+
expect.objectContaining({ name: 'Weekdays only' }),
|
|
262
|
+
);
|
|
263
|
+
expect(onSelectSchedule).toHaveBeenCalledWith(created.id);
|
|
264
|
+
expect(slot.textContent).toBe('');
|
|
265
|
+
expect(slot.childNodes).toHaveLength(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('does not create anything without a name', async () => {
|
|
269
|
+
await renderESign();
|
|
270
|
+
await clickCreateNewSchedule();
|
|
271
|
+
|
|
272
|
+
await clickButton(slot, 'Create');
|
|
273
|
+
|
|
274
|
+
expect(onCreateSchedule).not.toHaveBeenCalled();
|
|
275
|
+
expect(slot.textContent).toContain(FORM_TITLE);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('close empties the container without creating or selecting a schedule', async () => {
|
|
279
|
+
await renderESign();
|
|
280
|
+
await clickCreateNewSchedule();
|
|
281
|
+
|
|
282
|
+
await clickClose();
|
|
283
|
+
|
|
284
|
+
expect(onCreateSchedule).not.toHaveBeenCalled();
|
|
285
|
+
expect(onSelectSchedule).not.toHaveBeenCalled();
|
|
286
|
+
expect(slot.childNodes).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Flex, SegmentedControl, Switch, Text } from '@servicetitan/anvil2';
|
|
2
|
+
// eslint-disable-next-line import/no-extraneous-dependencies -- bundled, see bundle-shared.mjs
|
|
2
3
|
import { BusinessCalendarSection, type ScheduleEditorContext } from '@servicetitan/dte-shared';
|
|
3
4
|
import { Dispatch, FC, SetStateAction } from 'react';
|
|
4
5
|
import { DEFAULT_DATE_FORMAT, ROUNDING_OPTIONS } from '../../constants';
|
|
@@ -35,7 +35,7 @@ export const ESignOffsetRuleRow: FC<ESignOffsetRuleRowProps> = ({
|
|
|
35
35
|
rule,
|
|
36
36
|
}) => {
|
|
37
37
|
const handleDaysChange = (days: number | null) => {
|
|
38
|
-
onChange({ ...rule, days: days ?? 0 });
|
|
38
|
+
onChange({ ...rule, days: Math.max(Math.min(365, days ?? 0), 0) });
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
const group = rule.condition.groups[0];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Button, Flex, NumberField } from '@servicetitan/anvil2';
|
|
2
2
|
import AddIcon from '@servicetitan/anvil2/assets/icons/material/round/add_circle.svg';
|
|
3
|
+
// eslint-disable-next-line import/no-extraneous-dependencies -- bundled, see bundle-shared.mjs
|
|
3
4
|
import { BusinessCalendarSection, type ScheduleEditorContext } from '@servicetitan/dte-shared';
|
|
4
5
|
import { FC, Fragment, useState } from 'react';
|
|
5
6
|
import {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Button, Flex, Text } from '@servicetitan/anvil2';
|
|
2
2
|
import IconClose from '@servicetitan/anvil2/assets/icons/material/round/close.svg';
|
|
3
|
-
|
|
4
|
-
import {
|
|
3
|
+
// eslint-disable-next-line import/no-extraneous-dependencies -- bundled, see bundle-shared.mjs
|
|
4
|
+
import { useFieldScheduleEditorContext } from '@servicetitan/dte-shared';
|
|
5
|
+
import { FC, useMemo, useState } from 'react';
|
|
5
6
|
import {
|
|
6
7
|
type BusinessCalendar,
|
|
7
8
|
type BusinessDaySchedule,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
type SaveScheduleRequest,
|
|
15
16
|
type SchemaObject,
|
|
16
17
|
} from '../../interface/types';
|
|
17
|
-
import { getFieldDateCalcOptions
|
|
18
|
+
import { getFieldDateCalcOptions } from '../../utils';
|
|
18
19
|
import { FieldConfigPanel } from './field-config-panel';
|
|
19
20
|
|
|
20
21
|
interface FieldConfigPanelOverlayProps {
|
|
@@ -77,112 +78,80 @@ export const FieldConfigPanelOverlay: FC<FieldConfigPanelOverlayProps> = ({
|
|
|
77
78
|
schedulesLoading,
|
|
78
79
|
selectedField,
|
|
79
80
|
}) => {
|
|
81
|
+
/*
|
|
82
|
+
* Where an e-sign date offset's schedule form is portaled to. A state, not a ref, so the panel
|
|
83
|
+
* re-renders with the element once it exists.
|
|
84
|
+
*/
|
|
85
|
+
const [scheduleEditorContainer, setScheduleEditorContainer] = useState<HTMLElement | null>(
|
|
86
|
+
null,
|
|
87
|
+
);
|
|
88
|
+
|
|
80
89
|
const dateCalcOptions = useMemo(
|
|
81
90
|
() => getFieldDateCalcOptions(selectedField.id, scheduleBindings, holidays, fieldCalendars),
|
|
82
91
|
[selectedField.id, scheduleBindings, holidays, fieldCalendars],
|
|
83
92
|
);
|
|
84
93
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const handleSelectSchedule = useCallback(
|
|
101
|
-
(scheduleId: number | undefined) => {
|
|
102
|
-
onScheduleBindingsChange?.(
|
|
103
|
-
setFieldSchedule(scheduleBindings ?? [], selectedFieldId, scheduleId),
|
|
104
|
-
);
|
|
105
|
-
},
|
|
106
|
-
[scheduleBindings, selectedFieldId, onScheduleBindingsChange],
|
|
107
|
-
);
|
|
108
|
-
|
|
94
|
+
const fieldBusinessCalendar = useFieldScheduleEditorContext(selectedField.id, {
|
|
95
|
+
holidayCatalog,
|
|
96
|
+
holidayCatalogError,
|
|
97
|
+
holidayCatalogLoading,
|
|
98
|
+
onCreateSchedule,
|
|
99
|
+
onDeleteSchedule,
|
|
100
|
+
onScheduleBindingsChange,
|
|
101
|
+
onUpdateSchedule,
|
|
102
|
+
scheduleBindings,
|
|
103
|
+
schedules,
|
|
104
|
+
schedulesError,
|
|
105
|
+
schedulesLoading,
|
|
106
|
+
});
|
|
109
107
|
/*
|
|
110
108
|
* The schedule UI (the Formula Builder's switch and the e-sign day offset's schedule) is offered
|
|
111
109
|
* only to a host that passes `onScheduleBindingsChange`. Without it there is no context, and
|
|
112
110
|
* everything that renders the schedule UI checks for one.
|
|
113
111
|
*/
|
|
114
|
-
const businessCalendar
|
|
115
|
-
() =>
|
|
116
|
-
onScheduleBindingsChange
|
|
117
|
-
? {
|
|
118
|
-
enabled: !!selectedBinding,
|
|
119
|
-
selectedScheduleId: selectedBinding?.scheduleId,
|
|
120
|
-
onToggle: handleToggle,
|
|
121
|
-
onSelectSchedule: handleSelectSchedule,
|
|
122
|
-
holidayCatalog,
|
|
123
|
-
holidayCatalogLoading,
|
|
124
|
-
holidayCatalogError,
|
|
125
|
-
schedules,
|
|
126
|
-
schedulesLoading,
|
|
127
|
-
schedulesError,
|
|
128
|
-
onCreateSchedule,
|
|
129
|
-
onUpdateSchedule,
|
|
130
|
-
onDeleteSchedule,
|
|
131
|
-
}
|
|
132
|
-
: undefined,
|
|
133
|
-
[
|
|
134
|
-
onScheduleBindingsChange,
|
|
135
|
-
selectedBinding,
|
|
136
|
-
handleToggle,
|
|
137
|
-
handleSelectSchedule,
|
|
138
|
-
holidayCatalog,
|
|
139
|
-
holidayCatalogLoading,
|
|
140
|
-
holidayCatalogError,
|
|
141
|
-
schedules,
|
|
142
|
-
schedulesLoading,
|
|
143
|
-
schedulesError,
|
|
144
|
-
onCreateSchedule,
|
|
145
|
-
onUpdateSchedule,
|
|
146
|
-
onDeleteSchedule,
|
|
147
|
-
],
|
|
148
|
-
);
|
|
112
|
+
const businessCalendar = onScheduleBindingsChange ? fieldBusinessCalendar : undefined;
|
|
149
113
|
|
|
150
114
|
return (
|
|
151
115
|
<Flex className="dte-field-config-panel-overlay" onClick={e => e.stopPropagation()}>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
116
|
+
{/* Before the inner panel, so a filled slot can hide it with a sibling selector. */}
|
|
117
|
+
<div ref={setScheduleEditorContainer} className="dte-field-config-panel-editor-slot" />
|
|
118
|
+
<Flex className="dte-field-config-panel-overlay-inner" direction="column">
|
|
119
|
+
<Flex
|
|
120
|
+
className="dte-field-config-panel-header"
|
|
121
|
+
alignItems="center"
|
|
122
|
+
justifyContent="space-between"
|
|
123
|
+
>
|
|
124
|
+
<Text variant="headline" el="h6" size="small">
|
|
125
|
+
Field Configuration
|
|
126
|
+
</Text>
|
|
127
|
+
<Button
|
|
128
|
+
appearance="ghost"
|
|
129
|
+
size="small"
|
|
130
|
+
onClick={onDeselectField}
|
|
131
|
+
aria-label="Close"
|
|
132
|
+
icon={IconClose}
|
|
133
|
+
/>
|
|
134
|
+
</Flex>
|
|
135
|
+
<div className="dte-field-config-panel-content">
|
|
136
|
+
<FieldConfigPanel
|
|
137
|
+
dataModel={dataModel}
|
|
138
|
+
documentFields={documentFields}
|
|
139
|
+
field={selectedField}
|
|
140
|
+
formFieldsByFormId={formFieldsByFormId}
|
|
141
|
+
forms={forms}
|
|
142
|
+
formulaLoadingFormId={formulaLoadingFormId}
|
|
143
|
+
recipients={recipients}
|
|
144
|
+
onFieldConfigChange={onFieldConfigChange}
|
|
145
|
+
onMatchingFieldsConfigChange={onMatchingFieldsConfigChange}
|
|
146
|
+
onDeleteField={onDeleteField}
|
|
147
|
+
hideConditionalLogic={hideConditionalLogic}
|
|
148
|
+
dateCalcOptions={dateCalcOptions}
|
|
149
|
+
businessCalendar={businessCalendar}
|
|
150
|
+
scheduleEditorContainer={scheduleEditorContainer}
|
|
151
|
+
onRequestFormFields={onRequestFormFields}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
167
154
|
</Flex>
|
|
168
|
-
<div className="dte-field-config-panel-content">
|
|
169
|
-
<FieldConfigPanel
|
|
170
|
-
dataModel={dataModel}
|
|
171
|
-
documentFields={documentFields}
|
|
172
|
-
field={selectedField}
|
|
173
|
-
formFieldsByFormId={formFieldsByFormId}
|
|
174
|
-
forms={forms}
|
|
175
|
-
formulaLoadingFormId={formulaLoadingFormId}
|
|
176
|
-
recipients={recipients}
|
|
177
|
-
onFieldConfigChange={onFieldConfigChange}
|
|
178
|
-
onMatchingFieldsConfigChange={onMatchingFieldsConfigChange}
|
|
179
|
-
onDeleteField={onDeleteField}
|
|
180
|
-
hideConditionalLogic={hideConditionalLogic}
|
|
181
|
-
dateCalcOptions={dateCalcOptions}
|
|
182
|
-
businessCalendar={businessCalendar}
|
|
183
|
-
onRequestFormFields={onRequestFormFields}
|
|
184
|
-
/>
|
|
185
|
-
</div>
|
|
186
155
|
</Flex>
|
|
187
156
|
);
|
|
188
157
|
};
|
|
@@ -41,6 +41,11 @@ interface FieldConfigPanelProps {
|
|
|
41
41
|
dateCalcOptions?: DateCalcOptions;
|
|
42
42
|
/** The business-calendar toggle/picker/drawer state and callbacks for this field. */
|
|
43
43
|
businessCalendar?: ScheduleEditorContext;
|
|
44
|
+
/**
|
|
45
|
+
* Where an e-sign `dateSignedOffset` field's schedule form is portaled to instead of a drawer.
|
|
46
|
+
* A calculated field's schedule form always uses the drawer.
|
|
47
|
+
*/
|
|
48
|
+
scheduleEditorContainer?: HTMLElement | null;
|
|
44
49
|
onDeleteField(): void;
|
|
45
50
|
onFieldConfigChange(updates: Partial<PdfField>): void;
|
|
46
51
|
onMatchingFieldsConfigChange?(
|
|
@@ -65,6 +70,7 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
|
|
|
65
70
|
onMatchingFieldsConfigChange,
|
|
66
71
|
onRequestFormFields,
|
|
67
72
|
recipients,
|
|
73
|
+
scheduleEditorContainer,
|
|
68
74
|
}) => {
|
|
69
75
|
const recipientOptions = useMemo(
|
|
70
76
|
() =>
|
|
@@ -100,11 +106,14 @@ export const FieldConfigPanel: FC<FieldConfigPanelProps> = ({
|
|
|
100
106
|
* Reuses the catalog/CRUD half of `businessCalendar` (shared tenant schedules, same as a
|
|
101
107
|
* calculated field's), but overrides the enabled/selected-schedule half to read and write
|
|
102
108
|
* `ESignDateSignedOffsetSettings` instead of a `scheduleBindings` entry, since the toggle here
|
|
103
|
-
* applies to every `dateSignedOffset` field in the document, same as `rules` does.
|
|
109
|
+
* applies to every `dateSignedOffset` field in the document, same as `rules` does. It is also
|
|
110
|
+
* the only context given `scheduleEditorContainer`, so only this field's schedule form leaves
|
|
111
|
+
* the drawer.
|
|
104
112
|
*/
|
|
105
113
|
const dateSignedOffsetData = field.data as ESignDateSignedOffsetSettings;
|
|
106
114
|
const eSignBusinessCalendar: ScheduleEditorContext | undefined = businessCalendar && {
|
|
107
115
|
...businessCalendar,
|
|
116
|
+
editorContainer: scheduleEditorContainer,
|
|
108
117
|
enabled: !!dateSignedOffsetData?.businessCalendarEnabled,
|
|
109
118
|
selectedScheduleId: dateSignedOffsetData?.scheduleId,
|
|
110
119
|
onToggle: businessCalendarEnabled =>
|