@servicetitan/dte-unlayer 0.158.0 → 0.159.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/api-core.js +22 -29
  2. package/dist/api-core.js.map +1 -1
  3. package/dist/api-custom-tools.js +23 -29
  4. package/dist/api-custom-tools.js.map +1 -1
  5. package/dist/business-calendar/BusinessCalendarDrawer.d.ts +19 -0
  6. package/dist/business-calendar/BusinessCalendarDrawer.d.ts.map +1 -0
  7. package/dist/business-calendar/BusinessCalendarDrawer.js +41 -0
  8. package/dist/business-calendar/BusinessCalendarDrawer.js.map +1 -0
  9. package/dist/business-calendar/business-calendar-requests.d.ts +25 -0
  10. package/dist/business-calendar/business-calendar-requests.d.ts.map +1 -0
  11. package/dist/business-calendar/business-calendar-requests.js +46 -0
  12. package/dist/business-calendar/business-calendar-requests.js.map +1 -0
  13. package/dist/business-calendar/business-calendar-state.d.ts +22 -0
  14. package/dist/business-calendar/business-calendar-state.d.ts.map +1 -0
  15. package/dist/business-calendar/business-calendar-state.js +74 -0
  16. package/dist/business-calendar/business-calendar-state.js.map +1 -0
  17. package/dist/business-calendar/useBusinessCalendarRequests.d.ts +15 -0
  18. package/dist/business-calendar/useBusinessCalendarRequests.d.ts.map +1 -0
  19. package/dist/business-calendar/useBusinessCalendarRequests.js +30 -0
  20. package/dist/business-calendar/useBusinessCalendarRequests.js.map +1 -0
  21. package/dist/display-conditions/ConditionGroup.js +15 -89
  22. package/dist/display-conditions/ConditionGroup.js.map +1 -1
  23. package/dist/display-conditions/ConditionGroupsSection.js +2 -70
  24. package/dist/display-conditions/ConditionGroupsSection.js.map +1 -1
  25. package/dist/display-conditions/ConditionRow.js +63 -205
  26. package/dist/display-conditions/ConditionRow.js.map +1 -1
  27. package/dist/display-conditions/DisplayConditionModal.js +40 -190
  28. package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
  29. package/dist/display-conditions/LookupValueControl.js +42 -105
  30. package/dist/display-conditions/LookupValueControl.js.map +1 -1
  31. package/dist/display-conditions/SeparatorWithChip.js +1 -10
  32. package/dist/display-conditions/SeparatorWithChip.js.map +1 -1
  33. package/dist/display-conditions/constants.js +15 -20
  34. package/dist/display-conditions/constants.js.map +1 -1
  35. package/dist/display-conditions/displayConditionController.js +5 -8
  36. package/dist/display-conditions/displayConditionController.js.map +1 -1
  37. package/dist/display-conditions/lookupOptionsController.js +16 -16
  38. package/dist/display-conditions/lookupOptionsController.js.map +1 -1
  39. package/dist/display-conditions/nunjucks.js +98 -116
  40. package/dist/display-conditions/nunjucks.js.map +1 -1
  41. package/dist/display-conditions/schemaDataPoints.js +13 -11
  42. package/dist/display-conditions/schemaDataPoints.js.map +1 -1
  43. package/dist/display-conditions/types.js +23 -67
  44. package/dist/display-conditions/types.js.map +1 -1
  45. package/dist/display-conditions/useConditionForms.js +38 -56
  46. package/dist/display-conditions/useConditionForms.js.map +1 -1
  47. package/dist/display-conditions/useLookupOptions.js +24 -44
  48. package/dist/display-conditions/useLookupOptions.js.map +1 -1
  49. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js +37 -150
  50. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js.map +1 -1
  51. package/dist/e-sign-offset/ESignOffsetRuleRow.js +12 -73
  52. package/dist/e-sign-offset/ESignOffsetRuleRow.js.map +1 -1
  53. package/dist/e-sign-offset/eSignOffsetController.js +5 -8
  54. package/dist/e-sign-offset/eSignOffsetController.js.map +1 -1
  55. package/dist/editor-core-source.d.ts +2 -2
  56. package/dist/editor-core-source.d.ts.map +1 -1
  57. package/dist/editor-core-source.js +3 -3
  58. package/dist/editor-core-source.js.map +1 -1
  59. package/dist/editor-core.js +2 -2
  60. package/dist/editor-core.js.map +1 -1
  61. package/dist/editor.d.ts.map +1 -1
  62. package/dist/editor.js +56 -118
  63. package/dist/editor.js.map +1 -1
  64. package/dist/image-upload-error.js +3 -3
  65. package/dist/image-upload-error.js.map +1 -1
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +0 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/loadScript.js +7 -8
  71. package/dist/loadScript.js.map +1 -1
  72. package/dist/log.js +2 -3
  73. package/dist/log.js.map +1 -1
  74. package/dist/merge-tags.js +18 -17
  75. package/dist/merge-tags.js.map +1 -1
  76. package/dist/shared/configs.js +1 -2
  77. package/dist/shared/configs.js.map +1 -1
  78. package/dist/shared/const.d.ts +70 -0
  79. package/dist/shared/const.d.ts.map +1 -1
  80. package/dist/shared/const.js +13 -4
  81. package/dist/shared/const.js.map +1 -1
  82. package/dist/shared/date-calc.js +47 -38
  83. package/dist/shared/date-calc.js.map +1 -1
  84. package/dist/shared/e-sign-offset.js +46 -63
  85. package/dist/shared/e-sign-offset.js.map +1 -1
  86. package/dist/shared/edit-icon.js +0 -1
  87. package/dist/shared/edit-icon.js.map +1 -1
  88. package/dist/shared/fonts.js +16 -17
  89. package/dist/shared/fonts.js.map +1 -1
  90. package/dist/shared/forms.js +16 -38
  91. package/dist/shared/forms.js.map +1 -1
  92. package/dist/shared/instance-id.js +6 -9
  93. package/dist/shared/instance-id.js.map +1 -1
  94. package/dist/shared/nunjucks-helpers.js +2 -5
  95. package/dist/shared/nunjucks-helpers.js.map +1 -1
  96. package/dist/shared/schema.js +52 -96
  97. package/dist/shared/schema.js.map +1 -1
  98. package/dist/shared/tools.js +6 -13
  99. package/dist/shared/tools.js.map +1 -1
  100. package/dist/store.d.ts +15 -1
  101. package/dist/store.d.ts.map +1 -1
  102. package/dist/store.js +851 -548
  103. package/dist/store.js.map +1 -1
  104. package/dist/tools.js +133 -143
  105. package/dist/tools.js.map +1 -1
  106. package/dist/unlayer-interface.d.ts +15 -4
  107. package/dist/unlayer-interface.d.ts.map +1 -1
  108. package/dist/unlayer-interface.js +1 -2
  109. package/dist/unlayer-interface.js.map +1 -1
  110. package/dist/unlayer.js +86 -113
  111. package/dist/unlayer.js.map +1 -1
  112. package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +25 -0
  113. package/dist/vendor/dte-shared/business-calendar-validation.utils.js +80 -0
  114. package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +15 -0
  115. package/dist/vendor/dte-shared/components/business-calendar-section.js +14 -0
  116. package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +18 -0
  117. package/dist/vendor/dte-shared/components/schedule-drawer.js +119 -0
  118. package/dist/vendor/dte-shared/components/schedule-picker.d.ts +8 -0
  119. package/dist/vendor/dte-shared/components/schedule-picker.js +88 -0
  120. package/dist/vendor/dte-shared/constants.d.ts +31 -0
  121. package/dist/vendor/dte-shared/constants.js +34 -0
  122. package/dist/vendor/dte-shared/field-schedule-editor-context.d.ts +31 -0
  123. package/dist/vendor/dte-shared/field-schedule-editor-context.js +110 -0
  124. package/dist/vendor/dte-shared/index.d.ts +8 -0
  125. package/dist/vendor/dte-shared/index.js +7 -0
  126. package/dist/vendor/dte-shared/schedule-bindings.utils.d.ts +22 -0
  127. package/dist/vendor/dte-shared/schedule-bindings.utils.js +31 -0
  128. package/dist/vendor/dte-shared/styles/schedule.css +19 -0
  129. package/dist/vendor/dte-shared/types.d.ts +129 -0
  130. package/dist/vendor/dte-shared/types.js +1 -0
  131. package/package.json +7 -1
  132. package/src/__tests__/business-calendar-drawer.test.tsx +183 -0
  133. package/src/__tests__/business-calendar-requests.test.ts +166 -0
  134. package/src/__tests__/business-calendar-state.test.tsx +142 -0
  135. package/src/__tests__/store.business-calendar.test.ts +185 -0
  136. package/src/__tests__/unlayer.e-sign-types.test.ts +30 -0
  137. package/src/business-calendar/BusinessCalendarDrawer.tsx +76 -0
  138. package/src/business-calendar/business-calendar-requests.ts +80 -0
  139. package/src/business-calendar/business-calendar-state.ts +113 -0
  140. package/src/business-calendar/useBusinessCalendarRequests.ts +49 -0
  141. package/src/editor-core-source.ts +2 -2
  142. package/src/editor.tsx +15 -0
  143. package/src/index.ts +10 -0
  144. package/src/shared/const.ts +59 -0
  145. package/src/store.ts +67 -5
  146. package/src/unlayer-interface.tsx +15 -4
  147. package/src/unlayer.tsx +5 -5
@@ -0,0 +1,19 @@
1
+ .dte-business-calendar-section {
2
+ margin-top: var(--spacing-2);
3
+ padding-top: var(--spacing-2);
4
+ border-top: 1px solid var(--border-color);
5
+ }
6
+
7
+ .dte-business-calendar-preview {
8
+ padding: var(--spacing-1);
9
+ border: 1px solid var(--border-color);
10
+ border-radius: 8px;
11
+ }
12
+
13
+ .dte-business-calendar-preview-header {
14
+ cursor: pointer;
15
+ }
16
+
17
+ .dte-business-calendar-preview-content {
18
+ margin-top: var(--spacing-1);
19
+ }
@@ -0,0 +1,129 @@
1
+ /** A weekend day excluded by a business-day schedule, as `Date.getUTCDay()` numbers them. */
2
+ export type WeekendDay = 0 | 6;
3
+ /** A known holiday a tenant can exclude, from DTE's catalog (`GET api/holidays/catalog`). */
4
+ export interface KnownHoliday {
5
+ /** Stable code stored in a schedule's `knownHolidays`, e.g. `JULY4`. */
6
+ code: string;
7
+ /** Name to show, e.g. "Independence Day". */
8
+ displayName: string;
9
+ /** When it falls, e.g. "July 4" or "4th Thursday of November". */
10
+ rule: string;
11
+ }
12
+ /** The known holidays a tenant can exclude, for the schedule editor's checklist. */
13
+ export interface HolidayCatalog {
14
+ /** The jurisdiction the holidays belong to, e.g. "US" or a US state code. */
15
+ jurisdiction: string;
16
+ holidays: KnownHoliday[];
17
+ }
18
+ /** The rules of a business-day schedule: which days a date calculation skips. */
19
+ export interface BusinessCalendarRules {
20
+ weekendDays: WeekendDay[];
21
+ /** Codes of excluded holidays from the jurisdiction's catalog (`KnownHoliday.code`). */
22
+ knownHolidays: string[];
23
+ /** Extra excluded days as `MM-DD`, repeating every year, e.g. `12-24`. */
24
+ customDates: string[];
25
+ }
26
+ /** A schedule a tenant can pick for a business-day calculation (DTE's `GET api/holidays/schedules`). */
27
+ export interface BusinessDaySchedule {
28
+ /** Identifier of a tenant schedule; undefined for the read-only default schedule. */
29
+ id?: number;
30
+ name: string;
31
+ /** Whether this is the read-only default schedule of the tenant's jurisdiction. */
32
+ isDefault: boolean;
33
+ calendar: BusinessCalendarRules;
34
+ }
35
+ /**
36
+ * Request to create a tenant business-day schedule (DTE's `POST api/holidays/schedules`). Always
37
+ * creates a new schedule — editing a selected one does not overwrite it.
38
+ */
39
+ export interface SaveScheduleRequest extends BusinessCalendarRules {
40
+ /** Display name, unique per tenant ignoring case, max 255 characters, and not "Default". */
41
+ name: string;
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
+ }
99
+ /**
100
+ * Prop bundle for the business-calendar toggle/picker/drawer, built by a host package (currently
101
+ * `@servicetitan/dte-pdf-editor` and `@servicetitan/dte-unlayer`) from whatever it uses to track
102
+ * which of its own fields have the toggle on and which schedule each uses — this package has no
103
+ * opinion on that part, only on the schedule CRUD and the UI built from it.
104
+ */
105
+ export interface ScheduleEditorContext {
106
+ /** Whether the current field's custom business-day exclusion toggle is on. */
107
+ enabled: boolean;
108
+ /** The current field's bound schedule id, or undefined for the default schedule. */
109
+ selectedScheduleId?: number;
110
+ holidayCatalog?: HolidayCatalog;
111
+ holidayCatalogLoading?: boolean;
112
+ holidayCatalogError?: string;
113
+ schedules?: BusinessDaySchedule[];
114
+ schedulesLoading?: boolean;
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;
122
+ onToggle(enabled: boolean): void;
123
+ onSelectSchedule(scheduleId: number | undefined): void;
124
+ onCreateSchedule?(request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
125
+ /** Replaces a tenant schedule's name and rules in place (DTE's `PUT api/holidays/schedules/{id}`). */
126
+ onUpdateSchedule?(id: number, request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
127
+ /** Permanently deletes a tenant schedule; fields bound to it fall back to the default schedule. */
128
+ onDeleteSchedule?(id: number): Promise<void>;
129
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/dte-unlayer",
3
- "version": "0.158.0",
3
+ "version": "0.159.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "typings": "./dist/index.d.ts",
@@ -12,9 +12,15 @@
12
12
  "access": "public",
13
13
  "registry": "https://registry.npmjs.org"
14
14
  },
15
+ "scripts": {
16
+ "build": "npm run build --prefix ../shared && tsc --build && startup prepare-package && node ../shared/scripts/bundle-shared.mjs"
17
+ },
15
18
  "cli": {
16
19
  "webpack": false
17
20
  },
21
+ "devDependencies": {
22
+ "@servicetitan/dte-shared": "^0.0.1"
23
+ },
18
24
  "peerDependencies": {
19
25
  "@servicetitan/anvil2": "^3.7.1"
20
26
  }
@@ -0,0 +1,183 @@
1
+ import type { BusinessCalendarHostProps, BusinessDaySchedule } from '@servicetitan/dte-shared';
2
+ import { act } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { TextDecoder, TextEncoder } from 'util';
5
+
6
+ // Anvil2 needs these in jsdom: text encoding at import time, the rest when it renders.
7
+ Object.assign(globalThis, { TextDecoder, TextEncoder });
8
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+ globalThis.ResizeObserver ??= class {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ } as unknown as typeof ResizeObserver;
14
+ window.matchMedia ??= ((query: string) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addListener: () => undefined,
19
+ removeListener: () => undefined,
20
+ addEventListener: () => undefined,
21
+ removeEventListener: () => undefined,
22
+ dispatchEvent: () => false,
23
+ })) as typeof window.matchMedia;
24
+ Object.assign(HTMLElement.prototype, {
25
+ showPopover() {},
26
+ hidePopover() {},
27
+ togglePopover() {},
28
+ });
29
+ Object.assign(HTMLDialogElement.prototype, {
30
+ showModal(this: HTMLDialogElement) {
31
+ this.setAttribute('open', '');
32
+ },
33
+ close(this: HTMLDialogElement) {
34
+ this.removeAttribute('open');
35
+ },
36
+ });
37
+
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const { BusinessCalendarDrawer } =
40
+ require('../business-calendar/BusinessCalendarDrawer') as typeof import('../business-calendar/BusinessCalendarDrawer');
41
+
42
+ const defaultSchedule: BusinessDaySchedule = {
43
+ name: 'Weekends and US Federal Holidays',
44
+ isDefault: true,
45
+ calendar: { weekendDays: [0, 6], knownHolidays: ['JULY4'], customDates: [] },
46
+ };
47
+ const sundaysOnly: BusinessDaySchedule = {
48
+ id: 7,
49
+ name: 'Sundays only',
50
+ isDefault: false,
51
+ calendar: { weekendDays: [0], knownHolidays: [], customDates: [] },
52
+ };
53
+
54
+ describe('BusinessCalendarDrawer (the drawer Unlayer opens from a field)', () => {
55
+ let container: HTMLDivElement;
56
+ let root: Root;
57
+
58
+ const host = (
59
+ overrides: Partial<BusinessCalendarHostProps> = {},
60
+ ): BusinessCalendarHostProps => ({
61
+ holidayCatalog: {
62
+ jurisdiction: 'US',
63
+ holidays: [{ code: 'JULY4', displayName: 'Independence Day', rule: 'July 4' }],
64
+ },
65
+ schedules: [defaultSchedule, sundaysOnly],
66
+ scheduleBindings: [{ fieldKey: 'field-a' }],
67
+ onScheduleBindingsChange: jest.fn(),
68
+ onCreateSchedule: jest.fn(async request => ({
69
+ id: 8,
70
+ name: request.name,
71
+ isDefault: false,
72
+ calendar: request,
73
+ })),
74
+ onUpdateSchedule: jest.fn(async (id, request) => ({
75
+ id,
76
+ name: request.name,
77
+ isDefault: false,
78
+ calendar: request,
79
+ })),
80
+ ...overrides,
81
+ });
82
+
83
+ const typeInto = (input: HTMLInputElement, value: string) =>
84
+ act(() => {
85
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
86
+ input,
87
+ value,
88
+ );
89
+ input.dispatchEvent(new Event('input', { bubbles: true }));
90
+ });
91
+ const button = (text: string) =>
92
+ Array.from(document.body.querySelectorAll('button')).find(
93
+ b => b.textContent?.trim() === text,
94
+ )!;
95
+
96
+ beforeEach(() => {
97
+ container = document.createElement('div');
98
+ document.body.appendChild(container);
99
+ root = createRoot(container);
100
+ });
101
+
102
+ afterEach(() => {
103
+ act(() => root.unmount());
104
+ document.body.innerHTML = '';
105
+ });
106
+
107
+ test('HD-01 creates a schedule from the default rules and selects it for the field', async () => {
108
+ const props = host();
109
+ const onClose = jest.fn();
110
+ act(() =>
111
+ root.render(
112
+ <BusinessCalendarDrawer
113
+ request={{ fieldKey: 'field-a' }}
114
+ host={props}
115
+ onClose={onClose}
116
+ />,
117
+ ),
118
+ );
119
+
120
+ expect(document.body.textContent).toContain('Create New Schedule');
121
+ typeInto(document.body.querySelector<HTMLInputElement>('input')!, 'Sundays and July 4');
122
+ act(() => button('Saturday').click());
123
+ await act(async () => {
124
+ button('Create').click();
125
+ });
126
+
127
+ expect(props.onCreateSchedule).toHaveBeenCalledWith({
128
+ name: 'Sundays and July 4',
129
+ weekendDays: [0],
130
+ knownHolidays: ['JULY4'],
131
+ customDates: [],
132
+ });
133
+ expect(props.onScheduleBindingsChange).toHaveBeenLastCalledWith([
134
+ { fieldKey: 'field-a', scheduleId: 8 },
135
+ ]);
136
+ expect(onClose).toHaveBeenCalled();
137
+ });
138
+
139
+ test('HD-02 edits the requested schedule in place and keeps it selected', async () => {
140
+ const props = host({ scheduleBindings: [{ fieldKey: 'field-a', scheduleId: 7 }] });
141
+ act(() =>
142
+ root.render(
143
+ <BusinessCalendarDrawer
144
+ request={{ fieldKey: 'field-a', scheduleId: 7 }}
145
+ host={props}
146
+ onClose={jest.fn()}
147
+ />,
148
+ ),
149
+ );
150
+
151
+ expect(document.body.textContent).toContain('Edit Schedule');
152
+ expect(document.body.querySelector<HTMLInputElement>('input')!.value).toBe('Sundays only');
153
+ await act(async () => {
154
+ button('Save').click();
155
+ });
156
+
157
+ expect(props.onUpdateSchedule).toHaveBeenCalledWith(7, {
158
+ name: 'Sundays only',
159
+ weekendDays: [0],
160
+ knownHolidays: [],
161
+ customDates: [],
162
+ });
163
+ // Still the field's schedule, so its binding stays as it is: nothing to resend.
164
+ expect(props.onScheduleBindingsChange).not.toHaveBeenCalled();
165
+ });
166
+
167
+ test('HD-03 closes without showing anything when the schedule to edit was deleted meanwhile', () => {
168
+ const onClose = jest.fn();
169
+ act(() =>
170
+ root.render(
171
+ <BusinessCalendarDrawer
172
+ request={{ fieldKey: 'field-a', scheduleId: 99 }}
173
+ host={host()}
174
+ onClose={onClose}
175
+ />,
176
+ ),
177
+ );
178
+
179
+ expect(document.body.textContent).not.toContain('Create New Schedule');
180
+ expect(document.body.textContent).not.toContain('Edit Schedule');
181
+ expect(onClose).toHaveBeenCalledTimes(1);
182
+ });
183
+ });
@@ -0,0 +1,166 @@
1
+ import type { BusinessCalendarHostProps, FieldScheduleBinding } from '@servicetitan/dte-shared';
2
+ import { TextDecoder, TextEncoder } from 'util';
3
+ import type { BusinessCalendarRequestHandlerDeps } from '../business-calendar/business-calendar-requests';
4
+
5
+ // `@servicetitan/dte-shared` loads Anvil2, which needs these at import time (jsdom has neither).
6
+ Object.assign(globalThis, { TextDecoder, TextEncoder });
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const { createBusinessCalendarRequestHandler } =
10
+ require('../business-calendar/business-calendar-requests') as typeof import('../business-calendar/business-calendar-requests');
11
+
12
+ const SUNDAYS_ONLY = {
13
+ id: 7,
14
+ name: 'Sundays only',
15
+ isDefault: false,
16
+ calendar: { weekendDays: [0 as const], knownHolidays: [], customDates: [] },
17
+ };
18
+
19
+ /**
20
+ * A handler over bindings kept the way a host keeps them, plus what it asked the editor to do.
21
+ * The host lists schedule 7 unless `host` says otherwise.
22
+ */
23
+ const setup = (initial: FieldScheduleBinding[], host: BusinessCalendarHostProps = {}) => {
24
+ let current = initial;
25
+ const deps: BusinessCalendarRequestHandlerDeps = {
26
+ getHost: () => ({ schedules: [SUNDAYS_ONLY], ...host, scheduleBindings: current }),
27
+ bindings: {
28
+ get: () => current,
29
+ set: next => {
30
+ current = next;
31
+ },
32
+ },
33
+ openDrawer: jest.fn(),
34
+ sendResult: jest.fn(),
35
+ };
36
+ return { deps, handle: createBusinessCalendarRequestHandler(deps), current: () => current };
37
+ };
38
+
39
+ /** Lets the delete's promise callbacks run. */
40
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0));
41
+
42
+ describe('createBusinessCalendarRequestHandler', () => {
43
+ test('toggling on and off changes only that field, as in BYOD', () => {
44
+ const { current, handle } = setup([{ fieldKey: 'other', scheduleId: 3 }]);
45
+
46
+ handle({ action: 'toggle', fieldKey: 'field-a', enabled: true });
47
+ expect(current()).toEqual([{ fieldKey: 'other', scheduleId: 3 }, { fieldKey: 'field-a' }]);
48
+
49
+ handle({ action: 'toggle', fieldKey: 'field-a', enabled: false });
50
+ expect(current()).toEqual([{ fieldKey: 'other', scheduleId: 3 }]);
51
+ });
52
+
53
+ test("selecting a schedule sets the field's binding", () => {
54
+ const { current, handle } = setup([{ fieldKey: 'field-a' }]);
55
+
56
+ handle({ action: 'select', fieldKey: 'field-a', scheduleId: 7 });
57
+
58
+ expect(current()).toEqual([{ fieldKey: 'field-a', scheduleId: 7 }]);
59
+ });
60
+
61
+ test('opens the drawer for a new schedule or the one to edit', () => {
62
+ const { deps, handle } = setup([]);
63
+
64
+ handle({ action: 'create', fieldKey: 'field-a' });
65
+ handle({ action: 'edit', fieldKey: 'field-a', scheduleId: 7 });
66
+
67
+ expect(deps.openDrawer).toHaveBeenNthCalledWith(1, { fieldKey: 'field-a' });
68
+ expect(deps.openDrawer).toHaveBeenNthCalledWith(2, { fieldKey: 'field-a', scheduleId: 7 });
69
+ });
70
+
71
+ test('a delete moves every field on the schedule to the default one and reports success', async () => {
72
+ const onDeleteSchedule = jest.fn().mockResolvedValue(undefined);
73
+ const { current, deps, handle } = setup(
74
+ [
75
+ { fieldKey: 'field-a', scheduleId: 7 },
76
+ { fieldKey: 'field-b', scheduleId: 7 },
77
+ ],
78
+ { onDeleteSchedule },
79
+ );
80
+
81
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 7, requestId: 'r1' });
82
+ await flush();
83
+
84
+ expect(onDeleteSchedule).toHaveBeenCalledWith(7);
85
+ expect(current()).toEqual([{ fieldKey: 'field-a' }, { fieldKey: 'field-b' }]);
86
+ expect(deps.sendResult).toHaveBeenCalledWith({ requestId: 'r1' });
87
+ });
88
+
89
+ test("a field's toggle turned off while its schedule is deleted stays off", async () => {
90
+ let resolveDelete!: () => void;
91
+ const onDeleteSchedule = jest.fn(
92
+ () => new Promise<void>(resolve => (resolveDelete = resolve)),
93
+ );
94
+ const { current, deps, handle } = setup([{ fieldKey: 'field-a', scheduleId: 7 }], {
95
+ onDeleteSchedule,
96
+ });
97
+ const set = jest.spyOn(deps.bindings, 'set');
98
+
99
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 7, requestId: 'r1' });
100
+ handle({ action: 'toggle', fieldKey: 'field-a', enabled: false });
101
+ resolveDelete();
102
+ await flush();
103
+
104
+ expect(current()).toEqual([]);
105
+ // The toggle's change only: the delete had no field left on the schedule to move.
106
+ expect(set).toHaveBeenCalledTimes(1);
107
+ expect(deps.sendResult).toHaveBeenCalledWith({ requestId: 'r1' });
108
+ });
109
+
110
+ test("a failed delete keeps the bindings and reports the host's message", async () => {
111
+ const onDeleteSchedule = jest.fn().mockRejectedValue(new Error('in use'));
112
+ const { current, deps, handle } = setup([{ fieldKey: 'field-a', scheduleId: 7 }], {
113
+ onDeleteSchedule,
114
+ });
115
+
116
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 7, requestId: 'r1' });
117
+ await flush();
118
+
119
+ expect(current()).toEqual([{ fieldKey: 'field-a', scheduleId: 7 }]);
120
+ expect(deps.sendResult).toHaveBeenCalledWith({ requestId: 'r1', error: 'in use' });
121
+ });
122
+
123
+ test("ignores picking a schedule the host doesn't list, but not the default one", () => {
124
+ const { current, handle } = setup([{ fieldKey: 'field-a', scheduleId: 7 }]);
125
+
126
+ handle({ action: 'select', fieldKey: 'field-a', scheduleId: 99 });
127
+ expect(current()).toEqual([{ fieldKey: 'field-a', scheduleId: 7 }]);
128
+
129
+ handle({ action: 'select', fieldKey: 'field-a' });
130
+ expect(current()).toEqual([{ fieldKey: 'field-a' }]);
131
+ });
132
+
133
+ test("refuses to delete a schedule the host doesn't list as the tenant's own", async () => {
134
+ const onDeleteSchedule = jest.fn().mockResolvedValue(undefined);
135
+ const { current, deps, handle } = setup([{ fieldKey: 'field-a', scheduleId: 7 }], {
136
+ onDeleteSchedule,
137
+ schedules: [{ ...SUNDAYS_ONLY, id: 1, isDefault: true }, SUNDAYS_ONLY],
138
+ });
139
+
140
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 99, requestId: 'r1' });
141
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 1, requestId: 'r2' });
142
+ await flush();
143
+
144
+ expect(onDeleteSchedule).not.toHaveBeenCalled();
145
+ expect(current()).toEqual([{ fieldKey: 'field-a', scheduleId: 7 }]);
146
+ expect(deps.sendResult).toHaveBeenCalledWith({
147
+ requestId: 'r1',
148
+ error: 'Failed to delete schedule',
149
+ });
150
+ expect(deps.sendResult).toHaveBeenCalledWith({
151
+ requestId: 'r2',
152
+ error: 'Failed to delete schedule',
153
+ });
154
+ });
155
+
156
+ test('a delete without a host delete reports a failure', () => {
157
+ const { deps, handle } = setup([{ fieldKey: 'field-a', scheduleId: 7 }]);
158
+
159
+ handle({ action: 'delete', fieldKey: 'field-a', scheduleId: 7, requestId: 'r1' });
160
+
161
+ expect(deps.sendResult).toHaveBeenCalledWith({
162
+ requestId: 'r1',
163
+ error: 'Failed to delete schedule',
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,142 @@
1
+ import type { BusinessDaySchedule } from '@servicetitan/dte-shared';
2
+ import { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import {
5
+ buildBusinessCalendarState,
6
+ useBusinessCalendarState,
7
+ } from '../business-calendar/business-calendar-state';
8
+ import type { BusinessCalendar } from '../shared/date-calc';
9
+ import type { UnlayerStore } from '../store';
10
+ import type { CreateUnlayerEditorProps } from '../unlayer-interface';
11
+
12
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const schedules: BusinessDaySchedule[] = [
15
+ {
16
+ name: 'Weekends and US Federal Holidays',
17
+ isDefault: true,
18
+ calendar: { weekendDays: [0, 6], knownHolidays: ['JULY4'], customDates: [] },
19
+ },
20
+ {
21
+ id: 7,
22
+ name: 'Sundays only',
23
+ isDefault: false,
24
+ calendar: { weekendDays: [0], knownHolidays: [], customDates: [] },
25
+ },
26
+ ];
27
+
28
+ const sundaysOnly: BusinessCalendar = {
29
+ weekendDays: [0],
30
+ holidays: [],
31
+ firstYear: 2025,
32
+ lastYear: 2027,
33
+ };
34
+
35
+ describe('buildBusinessCalendarState', () => {
36
+ test('when disabled, passes every calendar through', () => {
37
+ expect(
38
+ buildBusinessCalendarState({
39
+ enabled: false,
40
+ canDeleteSchedule: false,
41
+ fieldCalendars: { 'field-a': sundaysOnly },
42
+ holidays: ['2026-07-03'],
43
+ }),
44
+ ).toEqual({
45
+ enabled: false,
46
+ bindings: {},
47
+ schedules: [],
48
+ canDeleteSchedule: false,
49
+ fieldCalendars: { 'field-a': sundaysOnly },
50
+ holidays: ['2026-07-03'],
51
+ });
52
+ });
53
+
54
+ test('passes the schedules, the holiday catalog and their states through for the inline picker', () => {
55
+ const catalog = {
56
+ jurisdiction: 'US',
57
+ holidays: [{ code: 'JULY4', displayName: 'Independence Day', rule: 'July 4' }],
58
+ };
59
+ const state = buildBusinessCalendarState({
60
+ enabled: true,
61
+ canDeleteSchedule: true,
62
+ schedules,
63
+ schedulesLoading: true,
64
+ schedulesError: 'schedules failed',
65
+ holidayCatalog: catalog,
66
+ holidayCatalogLoading: true,
67
+ holidayCatalogError: 'catalog failed',
68
+ });
69
+
70
+ expect(state).toMatchObject({
71
+ schedules,
72
+ schedulesLoading: true,
73
+ schedulesError: 'schedules failed',
74
+ holidayCatalog: catalog,
75
+ holidayCatalogLoading: true,
76
+ holidayCatalogError: 'catalog failed',
77
+ canDeleteSchedule: true,
78
+ });
79
+ });
80
+
81
+ test('lists each bound field with its schedule, none for the default one', () => {
82
+ const state = buildBusinessCalendarState({
83
+ enabled: true,
84
+ canDeleteSchedule: false,
85
+ schedules,
86
+ scheduleBindings: [{ fieldKey: 'field-a', scheduleId: 7 }, { fieldKey: 'field-b' }],
87
+ });
88
+
89
+ expect(state.enabled).toBe(true);
90
+ expect(state.bindings).toEqual({
91
+ 'field-a': { scheduleId: 7 },
92
+ 'field-b': { scheduleId: undefined },
93
+ });
94
+ });
95
+
96
+ test('keeps only the calendars of bound fields while schedules are edited', () => {
97
+ const state = buildBusinessCalendarState({
98
+ enabled: true,
99
+ canDeleteSchedule: false,
100
+ schedules,
101
+ scheduleBindings: [{ fieldKey: 'field-a', scheduleId: 7 }],
102
+ // field-b's toggle was just turned off; the host hasn't dropped its calendar yet.
103
+ fieldCalendars: { 'field-a': sundaysOnly, 'field-b': sundaysOnly },
104
+ holidays: ['2026-07-03'],
105
+ });
106
+
107
+ expect(state.fieldCalendars).toEqual({ 'field-a': sundaysOnly });
108
+ expect(state.holidays).toEqual(['2026-07-03']);
109
+ });
110
+ });
111
+
112
+ describe('useBusinessCalendarState', () => {
113
+ test('rebuilds the state only when a schedule prop changes, not on every render', () => {
114
+ const store = { setBusinessCalendarState: jest.fn() };
115
+ const Editor = ({ opts }: { opts: CreateUnlayerEditorProps }) => {
116
+ useBusinessCalendarState(store as unknown as UnlayerStore, opts);
117
+ return null;
118
+ };
119
+ const container = document.createElement('div');
120
+ const root = createRoot(container);
121
+ // A host that rebuilds its options, callbacks included, on every render.
122
+ const opts = (next: Partial<CreateUnlayerEditorProps> = {}): CreateUnlayerEditorProps => ({
123
+ tools: [],
124
+ schedules,
125
+ onScheduleBindingsChange: () => undefined,
126
+ ...next,
127
+ });
128
+
129
+ act(() => root.render(<Editor opts={opts()} />));
130
+ act(() => root.render(<Editor opts={opts()} />));
131
+ expect(store.setBusinessCalendarState).toHaveBeenCalledTimes(1);
132
+
133
+ act(() => root.render(<Editor opts={opts({ schedules: [...schedules] })} />));
134
+ act(() => root.render(<Editor opts={opts({ onDeleteSchedule: async () => undefined })} />));
135
+ expect(store.setBusinessCalendarState).toHaveBeenCalledTimes(3);
136
+ expect(store.setBusinessCalendarState).toHaveBeenLastCalledWith(
137
+ expect.objectContaining({ enabled: true, canDeleteSchedule: true }),
138
+ );
139
+
140
+ act(() => root.unmount());
141
+ });
142
+ });