@servicetitan/dte-unlayer 0.157.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 (166) 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.d.ts.map +1 -1
  26. package/dist/display-conditions/ConditionRow.js +71 -296
  27. package/dist/display-conditions/ConditionRow.js.map +1 -1
  28. package/dist/display-conditions/DisplayConditionModal.js +40 -190
  29. package/dist/display-conditions/DisplayConditionModal.js.map +1 -1
  30. package/dist/display-conditions/LookupValueControl.d.ts.map +1 -1
  31. package/dist/display-conditions/LookupValueControl.js +42 -109
  32. package/dist/display-conditions/LookupValueControl.js.map +1 -1
  33. package/dist/display-conditions/SeparatorWithChip.js +1 -10
  34. package/dist/display-conditions/SeparatorWithChip.js.map +1 -1
  35. package/dist/display-conditions/constants.js +15 -20
  36. package/dist/display-conditions/constants.js.map +1 -1
  37. package/dist/display-conditions/displayConditionController.js +5 -8
  38. package/dist/display-conditions/displayConditionController.js.map +1 -1
  39. package/dist/display-conditions/lookupOptionsController.js +16 -16
  40. package/dist/display-conditions/lookupOptionsController.js.map +1 -1
  41. package/dist/display-conditions/nunjucks.js +98 -116
  42. package/dist/display-conditions/nunjucks.js.map +1 -1
  43. package/dist/display-conditions/schemaDataPoints.js +13 -11
  44. package/dist/display-conditions/schemaDataPoints.js.map +1 -1
  45. package/dist/display-conditions/types.js +23 -67
  46. package/dist/display-conditions/types.js.map +1 -1
  47. package/dist/display-conditions/useConditionForms.js +38 -56
  48. package/dist/display-conditions/useConditionForms.js.map +1 -1
  49. package/dist/display-conditions/useLookupOptions.js +24 -44
  50. package/dist/display-conditions/useLookupOptions.js.map +1 -1
  51. package/dist/e-sign-offset/ESignDateSignedOffsetModal.d.ts.map +1 -1
  52. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js +48 -156
  53. package/dist/e-sign-offset/ESignDateSignedOffsetModal.js.map +1 -1
  54. package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts +3 -1
  55. package/dist/e-sign-offset/ESignOffsetRuleRow.d.ts.map +1 -1
  56. package/dist/e-sign-offset/ESignOffsetRuleRow.js +12 -71
  57. package/dist/e-sign-offset/ESignOffsetRuleRow.js.map +1 -1
  58. package/dist/e-sign-offset/eSignOffsetController.js +5 -8
  59. package/dist/e-sign-offset/eSignOffsetController.js.map +1 -1
  60. package/dist/editor-core-source.d.ts +2 -2
  61. package/dist/editor-core-source.d.ts.map +1 -1
  62. package/dist/editor-core-source.js +3 -3
  63. package/dist/editor-core-source.js.map +1 -1
  64. package/dist/editor-core.js +2 -2
  65. package/dist/editor-core.js.map +1 -1
  66. package/dist/editor.d.ts.map +1 -1
  67. package/dist/editor.js +56 -118
  68. package/dist/editor.js.map +1 -1
  69. package/dist/image-upload-error.js +3 -3
  70. package/dist/image-upload-error.js.map +1 -1
  71. package/dist/index.d.ts +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +0 -1
  74. package/dist/index.js.map +1 -1
  75. package/dist/loadScript.js +7 -8
  76. package/dist/loadScript.js.map +1 -1
  77. package/dist/log.js +2 -3
  78. package/dist/log.js.map +1 -1
  79. package/dist/merge-tags.js +18 -17
  80. package/dist/merge-tags.js.map +1 -1
  81. package/dist/shared/configs.js +1 -2
  82. package/dist/shared/configs.js.map +1 -1
  83. package/dist/shared/const.d.ts +72 -0
  84. package/dist/shared/const.d.ts.map +1 -1
  85. package/dist/shared/const.js +13 -4
  86. package/dist/shared/const.js.map +1 -1
  87. package/dist/shared/date-calc.d.ts +32 -13
  88. package/dist/shared/date-calc.d.ts.map +1 -1
  89. package/dist/shared/date-calc.js +130 -76
  90. package/dist/shared/date-calc.js.map +1 -1
  91. package/dist/shared/e-sign-offset.js +46 -63
  92. package/dist/shared/e-sign-offset.js.map +1 -1
  93. package/dist/shared/edit-icon.js +0 -1
  94. package/dist/shared/edit-icon.js.map +1 -1
  95. package/dist/shared/fonts.js +16 -17
  96. package/dist/shared/fonts.js.map +1 -1
  97. package/dist/shared/forms.js +16 -38
  98. package/dist/shared/forms.js.map +1 -1
  99. package/dist/shared/instance-id.d.ts +3 -0
  100. package/dist/shared/instance-id.d.ts.map +1 -1
  101. package/dist/shared/instance-id.js +10 -7
  102. package/dist/shared/instance-id.js.map +1 -1
  103. package/dist/shared/nunjucks-helpers.js +2 -5
  104. package/dist/shared/nunjucks-helpers.js.map +1 -1
  105. package/dist/shared/schema.js +52 -96
  106. package/dist/shared/schema.js.map +1 -1
  107. package/dist/shared/tools.js +6 -13
  108. package/dist/shared/tools.js.map +1 -1
  109. package/dist/store.d.ts +15 -1
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +851 -547
  112. package/dist/store.js.map +1 -1
  113. package/dist/tools.js +133 -143
  114. package/dist/tools.js.map +1 -1
  115. package/dist/unlayer-interface.d.ts +25 -1
  116. package/dist/unlayer-interface.d.ts.map +1 -1
  117. package/dist/unlayer-interface.js +1 -2
  118. package/dist/unlayer-interface.js.map +1 -1
  119. package/dist/unlayer.js +86 -113
  120. package/dist/unlayer.js.map +1 -1
  121. package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +25 -0
  122. package/dist/vendor/dte-shared/business-calendar-validation.utils.js +80 -0
  123. package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +15 -0
  124. package/dist/vendor/dte-shared/components/business-calendar-section.js +14 -0
  125. package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +18 -0
  126. package/dist/vendor/dte-shared/components/schedule-drawer.js +119 -0
  127. package/dist/vendor/dte-shared/components/schedule-picker.d.ts +8 -0
  128. package/dist/vendor/dte-shared/components/schedule-picker.js +88 -0
  129. package/dist/vendor/dte-shared/constants.d.ts +31 -0
  130. package/dist/vendor/dte-shared/constants.js +34 -0
  131. package/dist/vendor/dte-shared/field-schedule-editor-context.d.ts +31 -0
  132. package/dist/vendor/dte-shared/field-schedule-editor-context.js +110 -0
  133. package/dist/vendor/dte-shared/index.d.ts +8 -0
  134. package/dist/vendor/dte-shared/index.js +7 -0
  135. package/dist/vendor/dte-shared/schedule-bindings.utils.d.ts +22 -0
  136. package/dist/vendor/dte-shared/schedule-bindings.utils.js +31 -0
  137. package/dist/vendor/dte-shared/styles/schedule.css +19 -0
  138. package/dist/vendor/dte-shared/types.d.ts +129 -0
  139. package/dist/vendor/dte-shared/types.js +1 -0
  140. package/package.json +7 -1
  141. package/src/__tests__/business-calendar-drawer.test.tsx +183 -0
  142. package/src/__tests__/business-calendar-requests.test.ts +166 -0
  143. package/src/__tests__/business-calendar-state.test.tsx +142 -0
  144. package/src/__tests__/condition-row.test.tsx +131 -0
  145. package/src/__tests__/date-calc.test.ts +145 -0
  146. package/src/__tests__/e-sign-offset-modal.test.tsx +124 -0
  147. package/src/__tests__/instance-id.test.ts +49 -0
  148. package/src/__tests__/store.business-calendar.test.ts +185 -0
  149. package/src/__tests__/unlayer.e-sign-types.test.ts +30 -0
  150. package/src/business-calendar/BusinessCalendarDrawer.tsx +76 -0
  151. package/src/business-calendar/business-calendar-requests.ts +80 -0
  152. package/src/business-calendar/business-calendar-state.ts +113 -0
  153. package/src/business-calendar/useBusinessCalendarRequests.ts +49 -0
  154. package/src/display-conditions/ConditionRow.tsx +102 -175
  155. package/src/display-conditions/LookupValueControl.tsx +1 -12
  156. package/src/e-sign-offset/ESignDateSignedOffsetModal.tsx +20 -12
  157. package/src/e-sign-offset/ESignOffsetRuleRow.tsx +7 -2
  158. package/src/editor-core-source.ts +2 -2
  159. package/src/editor.tsx +15 -0
  160. package/src/index.ts +10 -0
  161. package/src/shared/const.ts +61 -0
  162. package/src/shared/date-calc.ts +155 -78
  163. package/src/shared/instance-id.ts +6 -1
  164. package/src/store.ts +68 -5
  165. package/src/unlayer-interface.tsx +25 -1
  166. package/src/unlayer.tsx +5 -5
@@ -0,0 +1,131 @@
1
+ import { act, useState } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { TextDecoder, TextEncoder } from 'util';
4
+ import type { FormFieldOption, SingleCondition } from '../display-conditions/types';
5
+ import { buildFormFieldKey } from '../shared/forms';
6
+
7
+ // Anvil2 needs these in jsdom: text encoding at import time, the rest when it renders.
8
+ Object.assign(globalThis, { TextDecoder, TextEncoder });
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+ const NoopObserver = class {
11
+ observe() {}
12
+ unobserve() {}
13
+ disconnect() {}
14
+ takeRecords() {
15
+ return [];
16
+ }
17
+ };
18
+ globalThis.ResizeObserver ??= NoopObserver as unknown as typeof ResizeObserver;
19
+ globalThis.IntersectionObserver ??= NoopObserver as unknown as typeof IntersectionObserver;
20
+ window.matchMedia ??= ((query: string) => ({
21
+ matches: false,
22
+ media: query,
23
+ onchange: null,
24
+ addListener: () => undefined,
25
+ removeListener: () => undefined,
26
+ addEventListener: () => undefined,
27
+ removeEventListener: () => undefined,
28
+ dispatchEvent: () => false,
29
+ })) as typeof window.matchMedia;
30
+ Object.assign(HTMLElement.prototype, {
31
+ showPopover() {},
32
+ hidePopover() {},
33
+ togglePopover() {},
34
+ });
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const { ConditionRow } =
38
+ require('../display-conditions/ConditionRow') as typeof import('../display-conditions/ConditionRow');
39
+
40
+ const FORM_ID = 5;
41
+ const ADDRESS_KEY = buildFormFieldKey(FORM_ID, 'address');
42
+ const formFieldOptions: FormFieldOption[] = [
43
+ { formId: FORM_ID, fullKey: ADDRESS_KEY, title: 'Address', fieldType: 'string' },
44
+ {
45
+ formId: FORM_ID,
46
+ fullKey: buildFormFieldKey(FORM_ID, 'city'),
47
+ title: 'City',
48
+ fieldType: 'string',
49
+ },
50
+ ];
51
+
52
+ describe('ConditionRow (Unlayer) source changes', () => {
53
+ let container: HTMLDivElement;
54
+ let root: Root;
55
+ let latest: SingleCondition;
56
+ const onFormSelect = jest.fn();
57
+
58
+ /** A host that keeps the condition the row reports, as `ConditionGroup` does. */
59
+ const Host = ({ initial }: { initial: SingleCondition }) => {
60
+ const [condition, setCondition] = useState(initial);
61
+ latest = condition;
62
+ return (
63
+ <ConditionRow
64
+ canRemove={false}
65
+ condition={condition}
66
+ dataPointOptions={[{ fullKey: 'Job.Name', title: 'Job Name', fieldType: 'string' }]}
67
+ formFieldOptions={formFieldOptions}
68
+ forms={[{ id: FORM_ID, name: 'Job Completion Checklist' }]}
69
+ onChange={setCondition}
70
+ onFormSelect={onFormSelect}
71
+ onRemove={() => undefined}
72
+ />
73
+ );
74
+ };
75
+
76
+ const comboboxes = () =>
77
+ Array.from(container.querySelectorAll<HTMLElement>('[role="combobox"]'));
78
+ /** Opens a select and picks the option labelled `label` (else the first), once loaded. */
79
+ const choose = async (combobox: HTMLElement, label?: string) => {
80
+ act(() => combobox.click());
81
+ await act(() => new Promise(resolve => setTimeout(resolve, 50)));
82
+ const options = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'));
83
+ const option =
84
+ label === undefined ? options[0] : options.find(o => o.textContent?.trim() === label);
85
+ expect(option).toBeDefined();
86
+ act(() => option!.click());
87
+ await act(() => new Promise(resolve => setTimeout(resolve, 0)));
88
+ };
89
+
90
+ beforeEach(() => {
91
+ onFormSelect.mockClear();
92
+ container = document.createElement('div');
93
+ document.body.appendChild(container);
94
+ root = createRoot(container);
95
+ });
96
+
97
+ afterEach(() => {
98
+ act(() => root.unmount());
99
+ document.body.innerHTML = '';
100
+ });
101
+
102
+ test("resets the data point both ways, as BYOD does, without picking the form's first field", async () => {
103
+ act(() =>
104
+ root.render(
105
+ <Host
106
+ initial={{
107
+ id: 'c1',
108
+ dataPointKey: ADDRESS_KEY,
109
+ operator: 'contains',
110
+ value: 'Main St',
111
+ }}
112
+ />,
113
+ ),
114
+ );
115
+ expect(comboboxes()[0].textContent).toContain('Job Completion Checklist');
116
+ expect(comboboxes()[1].textContent).toContain('Address');
117
+
118
+ // The merge-tag source, listed first.
119
+ await choose(comboboxes()[0]);
120
+
121
+ expect(latest).toEqual({ id: 'c1', dataPointKey: '', operator: 'is_equal_to', value: '' });
122
+ expect(comboboxes()[1].textContent).toContain('Select data point...');
123
+
124
+ await choose(comboboxes()[0], 'Job Completion Checklist');
125
+
126
+ expect(latest).toEqual({ id: 'c1', dataPointKey: '', operator: 'is_equal_to', value: '' });
127
+ expect(comboboxes()[0].textContent).toContain('Job Completion Checklist');
128
+ expect(comboboxes()[1].textContent).toContain('Select form field...');
129
+ expect(onFormSelect).toHaveBeenCalledWith(FORM_ID);
130
+ });
131
+ });
@@ -0,0 +1,145 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import {
5
+ addDays,
6
+ type BusinessCalendar,
7
+ dateDiffDays,
8
+ isDateWithinRange,
9
+ } from '../shared/date-calc';
10
+
11
+ /*
12
+ * The business-day cases DTE runs through BusinessDayCalculator (.NET) and noderenderer, copied from
13
+ * DTE's `tests/fixtures/business-day-cases.json`. The hash is the DTE file's: update the copy and the
14
+ * hash together, never the copy alone.
15
+ */
16
+ const FIXTURE_PATH = resolve(__dirname, '../../../../tests/fixtures/business-day-cases.json');
17
+ const FIXTURE_SHA256 = 'da105547c63cdec48e79962b57794f709cbe1867a05b8cfe79d40f99671a0370';
18
+
19
+ interface FixtureCase {
20
+ name: string;
21
+ calendar: string;
22
+ start: string;
23
+ steps?: number[];
24
+ end?: string | null;
25
+ expected: string | number | null;
26
+ }
27
+
28
+ interface Fixture {
29
+ calendars: Record<string, BusinessCalendar>;
30
+ cases: FixtureCase[];
31
+ }
32
+
33
+ // Git may check the file out with CRLF line endings on Windows; DTE hashes it with LF.
34
+ const fixtureText = readFileSync(FIXTURE_PATH, 'utf-8').replace(/\r\n/g, '\n');
35
+ const fixture: Fixture = JSON.parse(fixtureText);
36
+
37
+ describe('business-day parity fixture', () => {
38
+ test('matches the DTE copy', () => {
39
+ expect(createHash('sha256').update(fixtureText).digest('hex')).toBe(FIXTURE_SHA256);
40
+ });
41
+
42
+ describe('runs through the date helpers as a bound field', () => {
43
+ test.each(fixture.cases.map(c => [c.name, c] as const))('%s', (_, testCase) => {
44
+ const options = { calendar: fixture.calendars[testCase.calendar] };
45
+
46
+ const result =
47
+ 'end' in testCase
48
+ ? dateDiffDays(testCase.start, testCase.end, options)
49
+ : (testCase.steps ?? []).reduce<string | null>(
50
+ (date, step) => addDays(date, step, options),
51
+ testCase.start,
52
+ );
53
+
54
+ expect(result).toBe(testCase.expected);
55
+ });
56
+ });
57
+ });
58
+
59
+ describe('date helpers with the default calendar', () => {
60
+ beforeAll(() => {
61
+ jest.useFakeTimers({ now: new Date('2026-06-01T00:00:00Z') });
62
+ });
63
+
64
+ afterAll(() => {
65
+ jest.useRealTimers();
66
+ });
67
+
68
+ describe('addDays', () => {
69
+ test('skips Saturday and Sunday when no holidays are passed', () => {
70
+ // Fri 2026-07-03 + 1 = Mon 2026-07-06
71
+ expect(addDays('2026-07-03', 1)).toBe('2026-07-06');
72
+ });
73
+
74
+ test('skips the holidays passed', () => {
75
+ expect(addDays('2026-07-02', 1, { holidays: ['2026-07-03'] })).toBe('2026-07-06');
76
+ });
77
+
78
+ test('accepts timestamp holidays', () => {
79
+ expect(addDays('2026-07-02', 1, { holidays: ['2026-07-03T00:00:00.000Z'] })).toBe(
80
+ '2026-07-06',
81
+ );
82
+ });
83
+
84
+ test.each([
85
+ ['a fractional offset', '2026-09-30', 2.5],
86
+ ['an invalid date', 'not-a-date', 1],
87
+ ['a missing date', undefined, 1],
88
+ ['an offset over 365', '2026-01-02', 366],
89
+ ['a start outside the current year ± 1', '2024-12-31', 1],
90
+ ['a result outside the current year ± 1', '2027-12-31', 1],
91
+ ])('is blank for %s', (_, date, days) => {
92
+ expect(addDays(date, days)).toBeNull();
93
+ });
94
+
95
+ test('accepts a numeric string offset', () => {
96
+ expect(addDays('2026-07-06', '2')).toBe('2026-07-08');
97
+ });
98
+ });
99
+
100
+ describe('dateDiffDays', () => {
101
+ test('counts from the first date to the second', () => {
102
+ // Mon 2026-07-06 → Fri 2026-07-10
103
+ expect(dateDiffDays('2026-07-06', '2026-07-10')).toBe(4);
104
+ expect(dateDiffDays('2026-07-10', '2026-07-06')).toBe(-4);
105
+ });
106
+
107
+ test.each([
108
+ ['an invalid date', 'not-a-date'],
109
+ ['a missing date', undefined],
110
+ ])('is blank for %s', (_, to) => {
111
+ expect(dateDiffDays('2026-07-06', to)).toBeNull();
112
+ });
113
+ });
114
+
115
+ describe('isDateWithinRange', () => {
116
+ test.each([
117
+ ['2025-01-01', true],
118
+ ['2027-12-31', true],
119
+ ['2024-12-31', false],
120
+ ['2028-01-01', false],
121
+ ])('%s is %s for the current year ± 1', (date, expected) => {
122
+ expect(isDateWithinRange(date)).toBe(expected);
123
+ });
124
+ });
125
+ });
126
+
127
+ describe('date helpers with a field calendar', () => {
128
+ const sundayOnly: BusinessCalendar = {
129
+ weekendDays: [0],
130
+ holidays: [],
131
+ firstYear: 2026,
132
+ lastYear: 2026,
133
+ };
134
+
135
+ test('uses only the field calendar, not the default holidays', () => {
136
+ // Fri 2026-07-10 + 1 = Sat 2026-07-11, a business day of a Sunday-only calendar.
137
+ expect(addDays('2026-07-10', 1, { calendar: sundayOnly, holidays: ['2026-07-11'] })).toBe(
138
+ '2026-07-11',
139
+ );
140
+ });
141
+
142
+ test('is blank outside the calendar years, whatever the current year', () => {
143
+ expect(addDays('2026-12-31', 1, { calendar: sundayOnly })).toBeNull();
144
+ });
145
+ });
@@ -0,0 +1,124 @@
1
+ import { act } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { TextDecoder, TextEncoder } from 'util';
4
+ import type { ESignDateSignedOffsetSettings } from '../shared/const';
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 @typescript-eslint/no-require-imports -- loaded after the stubs Anvil2 needs */
39
+ const { ESignDateSignedOffsetModal } =
40
+ require('../e-sign-offset/ESignDateSignedOffsetModal') as typeof import('../e-sign-offset/ESignDateSignedOffsetModal');
41
+ const { emitESignOffsetRequest } =
42
+ require('../e-sign-offset/eSignOffsetController') as typeof import('../e-sign-offset/eSignOffsetController');
43
+ /* eslint-enable @typescript-eslint/no-require-imports */
44
+
45
+ const configuredRule = {
46
+ days: 2,
47
+ condition: {
48
+ behavior: 'show' as const,
49
+ groups: [
50
+ {
51
+ id: 'g1',
52
+ conditions: [
53
+ {
54
+ id: 'c1',
55
+ dataPointKey: 'Job.Name',
56
+ operator: 'is_equal_to' as const,
57
+ value: 'Repair',
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ },
63
+ };
64
+
65
+ describe('ESignDateSignedOffsetModal (the Date Signed Offset Rules modal, as in BYOD)', () => {
66
+ let container: HTMLDivElement;
67
+ let root: Root;
68
+
69
+ const open = (value: ESignDateSignedOffsetSettings) => {
70
+ const done = jest.fn();
71
+ act(() => emitESignOffsetRequest({ value, done }));
72
+ return done;
73
+ };
74
+ const button = (text: string) =>
75
+ Array.from(document.body.querySelectorAll('button')).find(
76
+ b => b.textContent?.trim() === text,
77
+ );
78
+ const click = (text: string) => {
79
+ const target = button(text);
80
+ if (!target) {
81
+ throw new Error(`No "${text}" button`);
82
+ }
83
+ act(() => target.click());
84
+ };
85
+
86
+ beforeEach(() => {
87
+ container = document.createElement('div');
88
+ document.body.appendChild(container);
89
+ root = createRoot(container);
90
+ act(() => root.render(<ESignDateSignedOffsetModal />));
91
+ });
92
+
93
+ afterEach(() => {
94
+ act(() => root.unmount());
95
+ document.body.innerHTML = '';
96
+ });
97
+
98
+ test("labels the condition's source as BYOD does", () => {
99
+ open({ rules: [{ days: 0 }] });
100
+
101
+ expect(document.body.textContent).toContain('Source');
102
+ expect(document.body.textContent).toContain('Merge tags');
103
+ expect(document.body.textContent).not.toContain('Data Type');
104
+ });
105
+
106
+ test('offers Clear Rules only once a rule is configured', () => {
107
+ open({ rules: [{ days: 0 }] });
108
+ expect(button('Clear Rules')).toBeUndefined();
109
+
110
+ click('Cancel');
111
+ open({ rules: [configuredRule, { days: 5 }] });
112
+ expect(button('Clear Rules')).toBeDefined();
113
+ });
114
+
115
+ test('clearing leaves one empty rule that saves as no rules', () => {
116
+ const done = open({ rules: [configuredRule, { days: 5 }] });
117
+
118
+ click('Clear Rules');
119
+ expect(button('Clear Rules')).toBeUndefined();
120
+ click('Save Changes');
121
+
122
+ expect(done).toHaveBeenCalledWith({ rules: [{ days: 0 }] });
123
+ });
124
+ });
@@ -0,0 +1,49 @@
1
+ import { unlayerToolsDedupeInstanceIds } from '../tools';
2
+
3
+ import type { UnlayerDesignFormat, UnlayerDesignTool } from '../unlayer-interface';
4
+
5
+ const ORIGINAL_ID = '3fa85f64-5717-4562-b3fc-2c963f66afa6';
6
+
7
+ const content = (slug: string, htmlID: string, id?: string): UnlayerDesignTool => ({
8
+ type: 'custom',
9
+ slug,
10
+ values: { id, _meta: { htmlID } },
11
+ });
12
+
13
+ const design = (contents: UnlayerDesignTool[]): UnlayerDesignFormat => ({
14
+ body: { rows: [{ cells: [1], columns: [{ contents }] }] },
15
+ counters: {},
16
+ schemaVersion: 1,
17
+ });
18
+
19
+ describe('unlayerToolsDedupeInstanceIds', () => {
20
+ test.each(['calculated_field', 'e-sign', 'embedded-image'])(
21
+ 'gives a duplicated %s a new id and keeps the original',
22
+ slug => {
23
+ const original = content(slug, 'u_content_1', ORIGINAL_ID);
24
+ const copy = content(slug, 'u_content_2', ORIGINAL_ID);
25
+
26
+ expect(unlayerToolsDedupeInstanceIds(design([original, copy]), 'u_content_2')).toBe(
27
+ true,
28
+ );
29
+ expect(original.values.id).toBe(ORIGINAL_ID);
30
+ expect(copy.values.id).toEqual(expect.any(String));
31
+ expect(copy.values.id).not.toBe(ORIGINAL_ID);
32
+ },
33
+ );
34
+
35
+ test('keeps the ids of different tools that happen to be equal', () => {
36
+ const field = content('calculated_field', 'u_content_1', ORIGINAL_ID);
37
+ const signature = content('e-sign', 'u_content_2', ORIGINAL_ID);
38
+
39
+ expect(unlayerToolsDedupeInstanceIds(design([field, signature]))).toBe(false);
40
+ expect(signature.values.id).toBe(ORIGINAL_ID);
41
+ });
42
+
43
+ test('leaves tools without an instance id alone', () => {
44
+ const first = content('page-break', 'u_content_1', ORIGINAL_ID);
45
+ const second = content('page-break', 'u_content_2', ORIGINAL_ID);
46
+
47
+ expect(unlayerToolsDedupeInstanceIds(design([first, second]))).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,185 @@
1
+ import type { UnlayerEventBusinessCalendarState } from '../shared/const';
2
+ import { UnlayerStore } from '../store';
3
+
4
+ const editorMock = {
5
+ addEventListener: jest.fn(),
6
+ removeEventListener: jest.fn(),
7
+ registerCallback: jest.fn(),
8
+ unregisterCallback: jest.fn(),
9
+ setMergeTags: jest.fn(),
10
+ loadDesign: jest.fn(),
11
+ setBodyValues: jest.fn(),
12
+ };
13
+
14
+ jest.mock('../loadScript', () => ({ loadScript: jest.fn().mockResolvedValue(undefined) }));
15
+ jest.mock('../unlayer', () => ({
16
+ ...jest.requireActual('../unlayer'),
17
+ createUnlayerEditor: jest.fn(() => editorMock),
18
+ }));
19
+
20
+ const offState: UnlayerEventBusinessCalendarState = {
21
+ enabled: true,
22
+ bindings: {},
23
+ schedules: [],
24
+ canDeleteSchedule: true,
25
+ fieldCalendars: {},
26
+ };
27
+
28
+ const onState: UnlayerEventBusinessCalendarState = {
29
+ enabled: true,
30
+ bindings: { 'field-a': { scheduleId: 7 } },
31
+ schedules: [
32
+ {
33
+ id: 7,
34
+ name: 'Sundays only',
35
+ isDefault: false,
36
+ calendar: { weekendDays: [0], knownHolidays: [], customDates: [] },
37
+ },
38
+ ],
39
+ canDeleteSchedule: true,
40
+ fieldCalendars: {
41
+ 'field-a': { weekendDays: [0], holidays: [], firstYear: 2025, lastYear: 2027 },
42
+ },
43
+ holidays: ['2026-07-03'],
44
+ };
45
+
46
+ /** A message posted to the parent window: by the tools core in Unlayer's iframe, by default. */
47
+ const postFromIframe = (
48
+ type: string,
49
+ data?: unknown,
50
+ source: Window | null = document.querySelector('iframe')?.contentWindow ?? null,
51
+ ) =>
52
+ window.dispatchEvent(
53
+ new MessageEvent('message', { data: { '-dte-message-from': { type, data } }, source }),
54
+ );
55
+
56
+ describe('UnlayerStore business-day schedules', () => {
57
+ let store: UnlayerStore;
58
+ let container: HTMLDivElement;
59
+ let postMessage: jest.SpyInstance;
60
+
61
+ /** Types of the messages sent to the iframe, in order. */
62
+ const sentTypes = () =>
63
+ postMessage.mock.calls.map(([message]) => message['-dte-message-to'].type);
64
+ const sentData = (type: string) =>
65
+ postMessage.mock.calls
66
+ .map(([message]) => message['-dte-message-to'])
67
+ .filter(message => message.type === type)
68
+ .map(message => message.data);
69
+
70
+ beforeEach(async () => {
71
+ jest.useFakeTimers();
72
+ jest.clearAllMocks();
73
+ (window as any).unlayer = {};
74
+ container = document.createElement('div');
75
+ container.appendChild(document.createElement('iframe'));
76
+ document.body.appendChild(container);
77
+
78
+ store = new UnlayerStore({ tools: [] });
79
+ await store.init(container);
80
+ // The window message listener is added on the next tick (not the tools-ready watchdog).
81
+ jest.advanceTimersByTime(0);
82
+ postMessage = jest.spyOn(container.querySelector('iframe')!.contentWindow!, 'postMessage');
83
+ });
84
+
85
+ afterEach(() => {
86
+ store.destroy();
87
+ container.remove();
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ test('keeps the state until the tools core has loaded, then sends it right after --config', () => {
92
+ store.setBusinessCalendarState(onState);
93
+ expect(sentTypes()).toEqual([]);
94
+
95
+ postFromIframe('--core-loaded');
96
+
97
+ expect(sentTypes()).toEqual(['--config', '--business-calendar-state']);
98
+ expect(sentData('--business-calendar-state')).toEqual([onState]);
99
+ });
100
+
101
+ test('sends a changed state at once, and skips an unchanged one', () => {
102
+ postFromIframe('--core-loaded');
103
+ postMessage.mockClear();
104
+
105
+ store.setBusinessCalendarState(offState);
106
+ store.setBusinessCalendarState({ ...offState });
107
+ store.setBusinessCalendarState(onState);
108
+
109
+ expect(sentData('--business-calendar-state')).toEqual([offState, onState]);
110
+ });
111
+
112
+ test('sends the state again when the tools core loads again', () => {
113
+ store.setBusinessCalendarState(onState);
114
+ postFromIframe('--core-loaded');
115
+ postMessage.mockClear();
116
+
117
+ postFromIframe('--core-loaded');
118
+
119
+ expect(sentData('--business-calendar-state')).toEqual([onState]);
120
+ });
121
+
122
+ test('sends a plain copy, not the object it was given', () => {
123
+ postFromIframe('--core-loaded');
124
+ store.setBusinessCalendarState(onState);
125
+
126
+ const [sent] = sentData('--business-calendar-state');
127
+ expect(sent).toEqual(onState);
128
+ expect(sent).not.toBe(onState);
129
+ });
130
+
131
+ test('passes schedule requests to the host only when they name an action and a field', () => {
132
+ const onRequest = jest.fn();
133
+ store.setOnBusinessCalendarRequest(onRequest);
134
+
135
+ postFromIframe('--business-calendar-request', { action: 'toggle', enabled: true });
136
+ postFromIframe('--business-calendar-request', { fieldKey: 'field-a' });
137
+ postFromIframe('--business-calendar-request', {
138
+ action: 'toggle',
139
+ fieldKey: 'field-a',
140
+ enabled: true,
141
+ });
142
+ postFromIframe('--business-calendar-request', {
143
+ action: 'select',
144
+ fieldKey: 'esign-a',
145
+ scheduleId: 7,
146
+ });
147
+
148
+ expect(onRequest.mock.calls).toEqual([
149
+ [{ action: 'toggle', fieldKey: 'field-a', enabled: true }],
150
+ [{ action: 'select', fieldKey: 'esign-a', scheduleId: 7 }],
151
+ ]);
152
+ });
153
+
154
+ test("ignores schedule requests that don't come from the editor's iframe", () => {
155
+ const onRequest = jest.fn();
156
+ store.setOnBusinessCalendarRequest(onRequest);
157
+ const otherFrame = document.createElement('iframe');
158
+ document.body.appendChild(otherFrame);
159
+
160
+ const request = { action: 'delete', fieldKey: 'field-a', scheduleId: 7, requestId: 'r1' };
161
+ postFromIframe('--business-calendar-request', request, otherFrame.contentWindow);
162
+ postFromIframe('--business-calendar-request', request, window);
163
+ postFromIframe('--business-calendar-request', request, null);
164
+
165
+ expect(onRequest).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test('sends the outcome of a delete request to the iframe', () => {
169
+ store.sendBusinessCalendarResult({ requestId: 'r1', error: 'in use' });
170
+
171
+ expect(sentData('--business-calendar-result')).toEqual([
172
+ { requestId: 'r1', error: 'in use' },
173
+ ]);
174
+ });
175
+
176
+ test('registers the e-sign type labels with the tools', () => {
177
+ postFromIframe('--data-loaded');
178
+
179
+ const [register] = sentData('--register');
180
+ expect(register.eSignFieldTypes).toContain('Date Signed Offset');
181
+ expect(register.eSignFieldTypeLabels).toEqual({
182
+ dateSignedOffset: 'Calculated Signing Date',
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,30 @@
1
+ import { E_SIGN_DATE_SIGNED_OFFSET_TYPE } from '../shared/const';
2
+ import { createUnlayerEditor } from '../unlayer';
3
+
4
+ describe('createUnlayerEditor e-sign field types', () => {
5
+ const createEditor = jest.fn(() => ({ registerCallback: jest.fn() }));
6
+
7
+ beforeEach(() => {
8
+ createEditor.mockClear();
9
+ (window as any).unlayer = { createEditor };
10
+ });
11
+
12
+ const eSignTypeOptions = (): { label: string; value: string }[] => {
13
+ const container = document.createElement('div');
14
+ container.id = 'editor';
15
+ createUnlayerEditor(container, {
16
+ tools: [],
17
+ eSignFieldTypes: ['Signature', 'Date Signed', 'Date Signed Offset'],
18
+ });
19
+ const [[config]] = createEditor.mock.calls as unknown as [[any]];
20
+ return config.tools['custom#e-sign'].properties.eSignFieldType.editor.data.options;
21
+ };
22
+
23
+ test('shows "Calculated Signing Date" for the date signed offset type, keeping its value', () => {
24
+ expect(eSignTypeOptions()).toEqual([
25
+ { label: 'Signature', value: 'signature' },
26
+ { label: 'Date Signed', value: 'dateSigned' },
27
+ { label: 'Calculated Signing Date', value: E_SIGN_DATE_SIGNED_OFFSET_TYPE },
28
+ ]);
29
+ });
30
+ });