@servicetitan/dte-pdf-editor 1.96.2 → 1.96.4

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 (68) hide show
  1. package/README.md +11 -2
  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 +24 -42
  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 +6 -8
  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 +16 -0
  51. package/src/components/field-config-panel/__tests__/schedule-editor-placement.test.tsx +289 -0
  52. package/src/components/field-config-panel/advanced-settings.tsx +1 -0
  53. package/src/components/field-config-panel/e-sign-offset-rule-row.tsx +1 -1
  54. package/src/components/field-config-panel/e-sign-options-modal.tsx +1 -0
  55. package/src/components/field-config-panel/field-config-panel-overlay.tsx +64 -95
  56. package/src/components/field-config-panel/field-config-panel.tsx +10 -1
  57. package/src/components/field-config-panel/formula-modal.tsx +5 -8
  58. package/src/components/pdf-editor/pdf-editor.tsx +6 -8
  59. package/src/constants/field.constants.ts +1 -1
  60. package/src/interface/types.ts +4 -8
  61. package/src/styles/field-config-panel-overlay.css +24 -0
  62. package/src/utils/formula/index.ts +0 -1
  63. package/dist/utils/formula/schedule-bindings.utils.d.ts +0 -12
  64. package/dist/utils/formula/schedule-bindings.utils.d.ts.map +0 -1
  65. package/dist/utils/formula/schedule-bindings.utils.js +0 -17
  66. package/dist/utils/formula/schedule-bindings.utils.js.map +0 -1
  67. package/src/utils/formula/__tests__/schedule-bindings.utils.test.ts +0 -73
  68. 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.2",
3
+ "version": "1.96.4",
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
- import { type ScheduleEditorContext } from '@servicetitan/dte-shared';
4
- import { FC, useCallback, useMemo } from 'react';
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, setFieldSchedule, setFieldScheduleEnabled } from '../../utils';
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 selectedFieldId = selectedField.id;
86
- const selectedBinding = useMemo(
87
- () => scheduleBindings?.find(b => b.fieldKey === selectedFieldId),
88
- [scheduleBindings, selectedFieldId],
89
- );
90
-
91
- const handleToggle = useCallback(
92
- (enabled: boolean) => {
93
- onScheduleBindingsChange?.(
94
- setFieldScheduleEnabled(scheduleBindings ?? [], selectedFieldId, enabled),
95
- );
96
- },
97
- [scheduleBindings, selectedFieldId, onScheduleBindingsChange],
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: ScheduleEditorContext | undefined = useMemo(
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
- <Flex
153
- className="dte-field-config-panel-header"
154
- alignItems="center"
155
- justifyContent="space-between"
156
- >
157
- <Text variant="headline" el="h6" size="small">
158
- Field Configuration
159
- </Text>
160
- <Button
161
- appearance="ghost"
162
- size="small"
163
- onClick={onDeselectField}
164
- aria-label="Close"
165
- icon={IconClose}
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 =>