@servicetitan/dte-pdf-editor 1.96.1 → 1.96.3

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 (69) hide show
  1. package/README.md +26 -8
  2. package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
  3. package/dist/components/field-config-panel/advanced-settings.js +1 -0
  4. package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
  5. package/dist/components/field-config-panel/e-sign-offset-rule-row.js +1 -1
  6. package/dist/components/field-config-panel/e-sign-offset-rule-row.js.map +1 -1
  7. package/dist/components/field-config-panel/e-sign-options-modal.d.ts.map +1 -1
  8. package/dist/components/field-config-panel/e-sign-options-modal.js +1 -0
  9. package/dist/components/field-config-panel/e-sign-options-modal.js.map +1 -1
  10. package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
  11. package/dist/components/field-config-panel/field-config-panel-overlay.js +23 -33
  12. package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
  13. package/dist/components/field-config-panel/field-config-panel.d.ts +5 -0
  14. package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
  15. package/dist/components/field-config-panel/field-config-panel.js +5 -2
  16. package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
  17. package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
  18. package/dist/components/field-config-panel/formula-modal.js +4 -3
  19. package/dist/components/field-config-panel/formula-modal.js.map +1 -1
  20. package/dist/components/pdf-editor/pdf-editor.d.ts +12 -9
  21. package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
  22. package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
  23. package/dist/constants/field.constants.js +1 -1
  24. package/dist/interface/types.d.ts +4 -8
  25. package/dist/interface/types.d.ts.map +1 -1
  26. package/dist/interface/types.js.map +1 -1
  27. package/dist/styles/field-config-panel-overlay.css +24 -0
  28. package/dist/utils/formula/index.d.ts +0 -1
  29. package/dist/utils/formula/index.d.ts.map +1 -1
  30. package/dist/utils/formula/index.js +0 -1
  31. package/dist/utils/formula/index.js.map +1 -1
  32. package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +3 -0
  33. package/dist/vendor/dte-shared/business-calendar-validation.utils.js +4 -0
  34. package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +1 -1
  35. package/dist/vendor/dte-shared/components/business-calendar-section.js +3 -2
  36. package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +5 -3
  37. package/dist/vendor/dte-shared/components/schedule-drawer.js +19 -12
  38. package/dist/vendor/dte-shared/components/schedule-picker.js +10 -10
  39. package/dist/vendor/dte-shared/constants.d.ts +21 -0
  40. package/dist/vendor/dte-shared/constants.js +24 -0
  41. package/dist/vendor/dte-shared/field-schedule-editor-context.d.ts +31 -0
  42. package/dist/vendor/dte-shared/field-schedule-editor-context.js +110 -0
  43. package/dist/vendor/dte-shared/index.d.ts +2 -0
  44. package/dist/vendor/dte-shared/index.js +2 -0
  45. package/dist/vendor/dte-shared/schedule-bindings.utils.d.ts +22 -0
  46. package/dist/vendor/dte-shared/schedule-bindings.utils.js +31 -0
  47. package/dist/vendor/dte-shared/styles/schedule.css +2 -2
  48. package/dist/vendor/dte-shared/types.d.ts +62 -0
  49. package/package.json +2 -2
  50. package/src/components/field-config-panel/__tests__/field-config-panel-overlay.test.tsx +200 -0
  51. package/src/components/field-config-panel/__tests__/schedule-editor-placement.test.tsx +289 -0
  52. package/src/components/field-config-panel/__tests__/schedule-switch-visibility.test.tsx +206 -0
  53. package/src/components/field-config-panel/advanced-settings.tsx +1 -0
  54. package/src/components/field-config-panel/e-sign-offset-rule-row.tsx +1 -1
  55. package/src/components/field-config-panel/e-sign-options-modal.tsx +1 -0
  56. package/src/components/field-config-panel/field-config-panel-overlay.tsx +69 -91
  57. package/src/components/field-config-panel/field-config-panel.tsx +10 -1
  58. package/src/components/field-config-panel/formula-modal.tsx +5 -8
  59. package/src/components/pdf-editor/pdf-editor.tsx +12 -9
  60. package/src/constants/field.constants.ts +1 -1
  61. package/src/interface/types.ts +4 -8
  62. package/src/styles/field-config-panel-overlay.css +24 -0
  63. package/src/utils/formula/index.ts +0 -1
  64. package/dist/utils/formula/schedule-bindings.utils.d.ts +0 -12
  65. package/dist/utils/formula/schedule-bindings.utils.d.ts.map +0 -1
  66. package/dist/utils/formula/schedule-bindings.utils.js +0 -17
  67. package/dist/utils/formula/schedule-bindings.utils.js.map +0 -1
  68. package/src/utils/formula/__tests__/schedule-bindings.utils.test.ts +0 -73
  69. 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.96.1",
3
+ "version": "1.96.3",
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
@@ -0,0 +1,200 @@
1
+ /*
2
+ * The schedule UI is offered only to a host that passes `onScheduleBindingsChange`. The overlay
3
+ * decides that, and every place that renders the schedule UI checks the context it builds, so the
4
+ * panel below is replaced by a stand-in that records what the overlay gives it.
5
+ */
6
+ jest.mock('../field-config-panel', () => ({ FieldConfigPanel: jest.fn(() => null) }));
7
+ // A Proxy is what `.svg` imports become under test; it is not a component.
8
+ jest.mock('@servicetitan/anvil2/assets/icons/material/round/close.svg', () => ({
9
+ __esModule: true,
10
+ default: () => null,
11
+ }));
12
+
13
+ import { TextDecoder, TextEncoder } from 'util';
14
+
15
+ // jsdom has no TextEncoder; anvil2 reads it when it is loaded, so this must run before it is imported.
16
+ Object.assign(globalThis, { TextEncoder, TextDecoder });
17
+
18
+ import { act, type ComponentProps } from 'react';
19
+ import { createRoot, type Root } from 'react-dom/client';
20
+ import { type FieldScheduleBinding, type PdfField } from '../../../interface/types';
21
+ import { FieldConfigPanel } from '../field-config-panel';
22
+ import { FieldConfigPanelOverlay } from '../field-config-panel-overlay';
23
+
24
+ const panel = FieldConfigPanel as unknown as jest.Mock;
25
+ const lastBusinessCalendar = () =>
26
+ panel.mock.calls[panel.mock.calls.length - 1][0].businessCalendar;
27
+
28
+ const defaultSchedule = {
29
+ name: 'Default',
30
+ isDefault: true,
31
+ calendar: { weekendDays: [0, 6] as (0 | 6)[], knownHolidays: [], customDates: [] },
32
+ };
33
+
34
+ describe('FieldConfigPanelOverlay', () => {
35
+ let container: HTMLDivElement;
36
+ let root: Root;
37
+
38
+ // Async, so the state anvil2 sets right after mounting is flushed inside `act`.
39
+ const render = (props: Partial<ComponentProps<typeof FieldConfigPanelOverlay>> = {}) =>
40
+ act(async () => {
41
+ root.render(
42
+ <FieldConfigPanelOverlay
43
+ selectedField={{ id: 'f1', type: 'calculated' } as PdfField}
44
+ recipients={[]}
45
+ forms={[]}
46
+ formFieldsByFormId={{}}
47
+ onFieldConfigChange={() => {}}
48
+ onDeleteField={() => {}}
49
+ onRequestFormFields={() => {}}
50
+ {...props}
51
+ />,
52
+ );
53
+ });
54
+
55
+ beforeAll(() => {
56
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
57
+ });
58
+
59
+ beforeEach(() => {
60
+ panel.mockClear();
61
+ container = document.createElement('div');
62
+ document.body.appendChild(container);
63
+ root = createRoot(container);
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await act(async () => root.unmount());
68
+ container.remove();
69
+ });
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
+
87
+ describe('without onScheduleBindingsChange', () => {
88
+ test('gives the panel no schedule context', async () => {
89
+ await render();
90
+
91
+ expect(lastBusinessCalendar()).toBeUndefined();
92
+ });
93
+
94
+ test('gives the panel no schedule context although the host passed schedules', async () => {
95
+ await render({
96
+ scheduleBindings: [{ fieldKey: 'f1' }],
97
+ schedules: [defaultSchedule],
98
+ holidayCatalog: { jurisdiction: 'US', holidays: [] },
99
+ onCreateSchedule: jest.fn(),
100
+ onUpdateSchedule: jest.fn(),
101
+ onDeleteSchedule: jest.fn(),
102
+ });
103
+
104
+ expect(lastBusinessCalendar()).toBeUndefined();
105
+ });
106
+
107
+ test('adds the schedule context when the host starts passing the handler', async () => {
108
+ await render();
109
+ expect(lastBusinessCalendar()).toBeUndefined();
110
+
111
+ await render({ onScheduleBindingsChange: jest.fn() });
112
+
113
+ expect(lastBusinessCalendar()).toBeDefined();
114
+ });
115
+
116
+ test('removes the schedule context when the host stops passing the handler', async () => {
117
+ await render({ onScheduleBindingsChange: jest.fn() });
118
+ expect(lastBusinessCalendar()).toBeDefined();
119
+
120
+ await render({ onScheduleBindingsChange: undefined });
121
+
122
+ expect(lastBusinessCalendar()).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe('with onScheduleBindingsChange', () => {
127
+ let onScheduleBindingsChange: jest.Mock;
128
+
129
+ beforeEach(() => {
130
+ onScheduleBindingsChange = jest.fn();
131
+ });
132
+
133
+ test('gives the panel a schedule context with the switch off for a field without a binding', async () => {
134
+ await render({ onScheduleBindingsChange, scheduleBindings: [] });
135
+
136
+ expect(lastBusinessCalendar()).toMatchObject({ enabled: false });
137
+ });
138
+
139
+ test('gives the panel the schedule of the field and what the host loaded', async () => {
140
+ const schedules = [defaultSchedule];
141
+ const holidayCatalog = { jurisdiction: 'US', holidays: [] };
142
+ const onCreateSchedule = jest.fn();
143
+ await render({
144
+ onScheduleBindingsChange,
145
+ scheduleBindings: [{ fieldKey: 'f1', scheduleId: 3 }],
146
+ schedules,
147
+ holidayCatalog,
148
+ onCreateSchedule,
149
+ });
150
+
151
+ expect(lastBusinessCalendar()).toMatchObject({
152
+ enabled: true,
153
+ selectedScheduleId: 3,
154
+ schedules,
155
+ holidayCatalog,
156
+ onCreateSchedule,
157
+ });
158
+ });
159
+
160
+ test('follows the binding of the field that is selected next', async () => {
161
+ const scheduleBindings = [{ fieldKey: 'f2', scheduleId: 3 }];
162
+ await render({ onScheduleBindingsChange, scheduleBindings });
163
+ expect(lastBusinessCalendar()).toMatchObject({ enabled: false });
164
+
165
+ await render({
166
+ onScheduleBindingsChange,
167
+ scheduleBindings,
168
+ selectedField: { id: 'f2', type: 'calculated' } as PdfField,
169
+ });
170
+
171
+ expect(lastBusinessCalendar()).toMatchObject({ enabled: true, selectedScheduleId: 3 });
172
+ });
173
+
174
+ test('turns the switch on by sending the whole list with the field added', async () => {
175
+ const others: FieldScheduleBinding[] = [{ fieldKey: 'other', scheduleId: 9 }];
176
+ await render({ onScheduleBindingsChange, scheduleBindings: others });
177
+
178
+ lastBusinessCalendar().onToggle(true);
179
+
180
+ expect(onScheduleBindingsChange).toHaveBeenCalledWith([
181
+ { fieldKey: 'other', scheduleId: 9 },
182
+ { fieldKey: 'f1' },
183
+ ]);
184
+ });
185
+
186
+ test('picks a schedule by sending the whole list with the field changed', async () => {
187
+ await render({
188
+ onScheduleBindingsChange,
189
+ scheduleBindings: [{ fieldKey: 'f1' }, { fieldKey: 'other', scheduleId: 9 }],
190
+ });
191
+
192
+ lastBusinessCalendar().onSelectSchedule(4);
193
+
194
+ expect(onScheduleBindingsChange).toHaveBeenCalledWith([
195
+ { fieldKey: 'other', scheduleId: 9 },
196
+ { fieldKey: 'f1', scheduleId: 4 },
197
+ ]);
198
+ });
199
+ });
200
+ });
@@ -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
+ });