@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
package/src/editor.tsx CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  } from 'react';
10
+ import { useBusinessCalendarState } from './business-calendar/business-calendar-state';
11
+ import { BusinessCalendarDrawer } from './business-calendar/BusinessCalendarDrawer';
12
+ import { useBusinessCalendarRequests } from './business-calendar/useBusinessCalendarRequests';
10
13
  import { DisplayConditionModal } from './display-conditions/DisplayConditionModal';
11
14
  import { ESignDateSignedOffsetModal } from './e-sign-offset/ESignDateSignedOffsetModal';
12
15
  import { getErrorMessage } from './log';
@@ -133,6 +136,9 @@ export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props,
133
136
  return () => store.setOnCalcFieldSelect();
134
137
  }, [props.opts.onCalcFieldSelect, store]);
135
138
 
139
+ const businessCalendar = useBusinessCalendarRequests(store, props.opts);
140
+ useBusinessCalendarState(store, props.opts);
141
+
136
142
  const { minHeight = 800, style = {} } = props;
137
143
 
138
144
  return (
@@ -155,6 +161,15 @@ export const UnlayerEditor = forwardRef<UnlayerRef, UnlayerEditorProps>((props,
155
161
  onConditionFormSelect={props.opts.onConditionFormSelect}
156
162
  />
157
163
  )}
164
+ {businessCalendar.drawer && props.opts.onScheduleBindingsChange && (
165
+ <BusinessCalendarDrawer
166
+ key={businessCalendar.drawer.id}
167
+ request={businessCalendar.drawer}
168
+ host={props.opts}
169
+ portalContainer={props.opts.displayConditionModalPortalContainer}
170
+ onClose={businessCalendar.closeDrawer}
171
+ />
172
+ )}
158
173
  </div>
159
174
  );
160
175
  });
package/src/index.ts CHANGED
@@ -21,3 +21,13 @@ export { versions } from './unlayer';
21
21
  export * from './shared/date-calc';
22
22
  export { emptyArrayIfNull } from './shared/nunjucks-helpers';
23
23
  export * from './display-conditions/lookupOptionsController';
24
+ export type {
25
+ BusinessCalendarHostProps,
26
+ BusinessCalendarRules,
27
+ BusinessDaySchedule,
28
+ FieldScheduleBinding,
29
+ HolidayCatalog,
30
+ KnownHoliday,
31
+ SaveScheduleRequest,
32
+ WeekendDay,
33
+ } from '@servicetitan/dte-shared';
@@ -1,4 +1,6 @@
1
+ import type { BusinessDaySchedule, HolidayCatalog } from '@servicetitan/dte-shared';
1
2
  import type { DisplayConditionState } from '../display-conditions/types';
3
+ import type { BusinessCalendar } from './date-calc';
2
4
  import { SchemaObject } from './schema';
3
5
 
4
6
  export const constGenericsEditor = {
@@ -48,6 +50,62 @@ export interface UnlayerEventESignOffset {
48
50
  value: ESignDateSignedOffsetSettings;
49
51
  }
50
52
 
53
+ /**
54
+ * Names shown for e-sign field types whose title differs from the type list entry it is derived
55
+ * from (by type value). Only the label changes: the stored value stays, so saved designs and the
56
+ * `esign_<recipient>_<type>` placeholder are unaffected.
57
+ */
58
+ export const E_SIGN_FIELD_TYPE_LABELS: Readonly<Record<string, string | undefined>> = {
59
+ [E_SIGN_DATE_SIGNED_OFFSET_TYPE]: 'Calculated Signing Date',
60
+ };
61
+
62
+ /**
63
+ * Payload of `--business-calendar-request` (iframe to parent): what a date field's inline schedule
64
+ * settings (a calculated field's or an e-sign "Date Signed Offset" field's) ask the parent window
65
+ * to do. `fieldKey` is the tool's instance id (`values.id`), the key DTE binds the schedule to.
66
+ * - `toggle` / `select`: the exclusion toggle and the schedule picker.
67
+ * - `create` / `edit`: open the schedule drawer, for a new schedule or `scheduleId`.
68
+ * - `delete`: delete the selected schedule; the outcome comes back as `--business-calendar-result`.
69
+ */
70
+ export type UnlayerEventBusinessCalendarRequest =
71
+ | { action: 'toggle'; fieldKey: string; enabled: boolean }
72
+ | { action: 'select'; fieldKey: string; scheduleId?: number }
73
+ | { action: 'create'; fieldKey: string }
74
+ | { action: 'edit'; fieldKey: string; scheduleId: number }
75
+ | { action: 'delete'; fieldKey: string; scheduleId: number; requestId: string };
76
+
77
+ /** Payload of `--business-calendar-result` (parent to iframe): how a `delete` request ended. */
78
+ export interface UnlayerEventBusinessCalendarResult {
79
+ requestId: string;
80
+ /** Why it failed, shown under the schedule; absent on success. */
81
+ error?: string;
82
+ }
83
+
84
+ /**
85
+ * Payload of `--business-calendar-state` (parent to iframe), sent whenever the host's schedule
86
+ * props change: what the property editors show and the calendars the previews use.
87
+ */
88
+ export interface UnlayerEventBusinessCalendarState {
89
+ /** Whether the host edits schedules (it passes `onScheduleBindingsChange`). */
90
+ enabled: boolean;
91
+ /** The fields whose exclusion toggle is on, by field key, with their schedule (none: default). */
92
+ bindings: Record<string, { scheduleId?: number }>;
93
+ /** The schedules a field can pick, default first, as the host passes them. */
94
+ schedules: BusinessDaySchedule[];
95
+ schedulesLoading?: boolean;
96
+ schedulesError?: string;
97
+ /** The tenant's known holidays, for the schedule preview's names. */
98
+ holidayCatalog?: HolidayCatalog;
99
+ holidayCatalogLoading?: boolean;
100
+ holidayCatalogError?: string;
101
+ /** Whether the host can delete schedules (it passes `onDeleteSchedule`). */
102
+ canDeleteSchedule: boolean;
103
+ /** Calendars of the bound fields, by field key. */
104
+ fieldCalendars: Record<string, BusinessCalendar>;
105
+ /** The default calendar's holidays. */
106
+ holidays?: string[];
107
+ }
108
+
51
109
  export interface UnlayerEditorUnit {
52
110
  id: string;
53
111
  generic: string;
@@ -69,6 +127,7 @@ export interface UnlayerEventConfig {
69
127
  isSnapshotMode?: boolean;
70
128
  units?: UnlayerEditorUnit[];
71
129
  holidays?: string[];
130
+ fieldCalendars?: Record<string, BusinessCalendar>;
72
131
  conditionalRulesEnabled?: boolean;
73
132
  }
74
133
 
@@ -76,6 +135,8 @@ export interface UnlayerEventRegister {
76
135
  units?: UnlayerEditorUnit[];
77
136
  eSignRecipients?: UnlayerEditorESignRecipientInfo[];
78
137
  eSignFieldTypes?: string[];
138
+ /** {@link E_SIGN_FIELD_TYPE_LABELS}, for the e-sign tool's canvas label. */
139
+ eSignFieldTypeLabels?: Readonly<Record<string, string | undefined>>;
79
140
  customTools?: {
80
141
  key: string;
81
142
  title?: string;
@@ -1,23 +1,10 @@
1
1
  export const MAX_DATE_CALC_DAYS = 365;
2
2
 
3
3
  /**
4
- * Fallback US federal holidays used when the store config does not provide a
5
- * holidays list. Keep in sync with any similar inline arrays elsewhere (e.g.
6
- * components/scripts/comps/calculated-field.ts).
4
+ * The years DTE knows holidays for (`HolidayCheckService.HolidayYearsWindow`): outside them a
5
+ * default-calendar answer would skip weekends but miss holidays, so it is left blank, as in BYOD.
7
6
  */
8
- export const DEFAULT_HOLIDAYS: string[] = [
9
- '2026-01-01T00:00:00.000Z',
10
- '2026-01-19T00:00:00.000Z',
11
- '2026-02-16T00:00:00.000Z',
12
- '2026-05-25T00:00:00.000Z',
13
- '2026-07-04T00:00:00.000Z',
14
- '2026-09-07T00:00:00.000Z',
15
- '2026-11-26T00:00:00.000Z',
16
- '2026-11-27T00:00:00.000Z',
17
- '2026-12-25T00:00:00.000Z',
18
- ];
19
-
20
- export const DATE_RANGE_YEARS = 2;
7
+ export const DATE_RANGE_YEARS = 1;
21
8
 
22
9
  /**
23
10
  * Returns true if the date's year falls within [currentYear - DATE_RANGE_YEARS, currentYear + DATE_RANGE_YEARS].
@@ -36,10 +23,40 @@ export function isDateWithinRange(dateInput: unknown): boolean {
36
23
  return year >= currentYear - DATE_RANGE_YEARS && year <= currentYear + DATE_RANGE_YEARS;
37
24
  }
38
25
 
26
+ /**
27
+ * Days a date calculation skips, as DTE resolves them (`POST api/holidays/calendar`, and the
28
+ * `calendars` noderenderer receives). Arithmetic outside `firstYear`–`lastYear` is blank; without
29
+ * years no year limit applies.
30
+ */
31
+ export interface BusinessCalendar {
32
+ /** Excluded weekend days, as `Date.getUTCDay()` numbers them: 0 = Sunday, 6 = Saturday. */
33
+ weekendDays: number[];
34
+ /** Holiday dates, already moved to their observed days, as `YYYY-MM-DD`. */
35
+ holidays: string[];
36
+ firstYear?: number | null;
37
+ lastYear?: number | null;
38
+ }
39
+
40
+ /** Saturday and Sunday: the default calendar's weekend. */
41
+ export const DEFAULT_WEEKEND_DAYS = [0, 6];
42
+
43
+ /**
44
+ * Which calendar a calculation uses. With `calendar` (a field bound to a schedule) only that
45
+ * calendar applies. Without it, the default calendar applies: Saturday and Sunday plus `holidays`,
46
+ * with the start and the result limited to the current year ± DATE_RANGE_YEARS.
47
+ */
39
48
  export interface DateCalcOptions {
49
+ calendar?: BusinessCalendar;
40
50
  holidays?: string[];
41
51
  }
42
52
 
53
+ interface PreparedCalendar {
54
+ weekendDays: Set<number>;
55
+ holidays: Set<string>;
56
+ firstYear: number | null;
57
+ lastYear: number | null;
58
+ }
59
+
43
60
  function toDateOnly(input: string | Date): Date {
44
61
  const d = typeof input === 'string' ? new Date(input) : new Date(input.getTime());
45
62
  d.setUTCHours(0, 0, 0, 0);
@@ -53,96 +70,121 @@ function toDateKey(d: Date): string {
53
70
  return `${y}-${m}-${day}`;
54
71
  }
55
72
 
56
- function isWeekend(d: Date): boolean {
57
- const day = d.getUTCDay();
58
- return day === 0 || day === 6;
73
+ function parseDate(input: unknown): Date | null {
74
+ if (typeof input !== 'string' && !(input instanceof Date)) {
75
+ return null;
76
+ }
77
+ const d = toDateOnly(input);
78
+ return isFinite(d.getTime()) ? d : null;
59
79
  }
60
80
 
61
81
  function buildHolidaySet(holidays?: string[]): Set<string> {
62
- if (!holidays?.length) {
63
- return new Set();
64
- }
65
82
  const set = new Set<string>();
66
- for (const h of holidays) {
67
- const d = toDateOnly(h);
68
- if (!isNaN(d.getTime())) {
83
+ for (const h of holidays ?? []) {
84
+ const d = parseDate(h);
85
+ if (d) {
69
86
  set.add(toDateKey(d));
70
87
  }
71
88
  }
72
89
  return set;
73
90
  }
74
91
 
75
- function isNonBusinessDay(d: Date, holidaySet: Set<string>): boolean {
76
- if (isWeekend(d)) {
77
- return true;
92
+ // Calendars are passed once and reused by every calculation, so each is prepared once.
93
+ const preparedCalendars = new WeakMap<BusinessCalendar, PreparedCalendar>();
94
+
95
+ function prepareCalendar(calendar: BusinessCalendar): PreparedCalendar {
96
+ const cached = preparedCalendars.get(calendar);
97
+ if (cached) {
98
+ return cached;
78
99
  }
79
- if (holidaySet.size > 0 && holidaySet.has(toDateKey(d))) {
80
- return true;
100
+
101
+ const prepared: PreparedCalendar = {
102
+ weekendDays: new Set(calendar.weekendDays ?? DEFAULT_WEEKEND_DAYS),
103
+ holidays: buildHolidaySet(calendar.holidays),
104
+ firstYear: calendar.firstYear ?? null,
105
+ lastYear: calendar.lastYear ?? null,
106
+ };
107
+ preparedCalendars.set(calendar, prepared);
108
+ return prepared;
109
+ }
110
+
111
+ // One calendar per holidays array, so the default calendar is also prepared once.
112
+ const holidayCalendars = new WeakMap<string[], BusinessCalendar>();
113
+ const NO_HOLIDAYS_CALENDAR: BusinessCalendar = { weekendDays: DEFAULT_WEEKEND_DAYS, holidays: [] };
114
+
115
+ function defaultCalendar(holidays?: string[]): BusinessCalendar {
116
+ if (!Array.isArray(holidays)) {
117
+ return NO_HOLIDAYS_CALENDAR;
81
118
  }
82
- return false;
119
+ let calendar = holidayCalendars.get(holidays);
120
+ if (!calendar) {
121
+ calendar = { weekendDays: DEFAULT_WEEKEND_DAYS, holidays };
122
+ holidayCalendars.set(holidays, calendar);
123
+ }
124
+ return calendar;
125
+ }
126
+
127
+ function isBusinessDay(d: Date, calendar: PreparedCalendar): boolean {
128
+ return !calendar.weekendDays.has(d.getUTCDay()) && !calendar.holidays.has(toDateKey(d));
129
+ }
130
+
131
+ function isWithinYears(d: Date, calendar: PreparedCalendar): boolean {
132
+ const year = d.getUTCFullYear();
133
+ return (
134
+ (calendar.firstYear === null || year >= calendar.firstYear) &&
135
+ (calendar.lastYear === null || year <= calendar.lastYear)
136
+ );
83
137
  }
84
138
 
85
139
  /**
86
- * Add business days to a date, always skipping weekends and provided holidays.
87
- * Negative days subtracts.
140
+ * Walks `days` business days from `start` (negative walks back); the start is not counted.
141
+ * Mirrors `BusinessDayCalculator.AddBusinessDays` in DTE: null for more than MAX_DATE_CALC_DAYS,
142
+ * or when the start or the walk leaves the calendar's years.
88
143
  */
89
- export function addDays(dateInput: string | Date, days: number, options?: DateCalcOptions): Date {
90
- const result = toDateOnly(dateInput);
91
- if (!isFinite(result.getTime())) {
92
- return result;
93
- }
94
-
95
- const intDays = Math.trunc(days);
96
- if (intDays === 0) {
97
- return result;
98
- }
99
- if (Math.abs(intDays) > MAX_DATE_CALC_DAYS) {
100
- return new Date(NaN);
144
+ function addBusinessDays(start: Date, days: number, calendar: PreparedCalendar): Date | null {
145
+ if (Math.abs(days) > MAX_DATE_CALC_DAYS || !isWithinYears(start, calendar)) {
146
+ return null;
101
147
  }
102
148
 
103
- const holidaySet = buildHolidaySet(options?.holidays);
104
- const direction = intDays > 0 ? 1 : -1;
105
- let remaining = Math.abs(intDays);
149
+ const direction = days >= 0 ? 1 : -1;
150
+ let remaining = Math.abs(days);
151
+ const current = new Date(start.getTime());
106
152
 
107
153
  while (remaining > 0) {
108
- result.setUTCDate(result.getUTCDate() + direction);
109
- if (!isNonBusinessDay(result, holidaySet)) {
154
+ current.setUTCDate(current.getUTCDate() + direction);
155
+ if (!isWithinYears(current, calendar)) {
156
+ return null;
157
+ }
158
+ if (isBusinessDay(current, calendar)) {
110
159
  remaining--;
111
160
  }
112
161
  }
113
162
 
114
- return result;
163
+ return current;
115
164
  }
116
165
 
117
166
  /**
118
- * Count the number of business days between two dates, always skipping weekends and provided holidays.
119
- * Returns a positive number when `to` is after `from`, negative otherwise.
167
+ * Counts the business days from `from` to `to`, negative when `to` is earlier; the start is not
168
+ * counted, the end is. Mirrors `BusinessDayCalculator.CountBusinessDays` in DTE: null for a span
169
+ * over MAX_DATE_CALC_DAYS, or a date outside the calendar's years.
120
170
  */
121
- export function dateDiffDays(
122
- from: string | Date,
123
- to: string | Date,
124
- options?: DateCalcOptions,
125
- ): number {
126
- const start = toDateOnly(from);
127
- const end = toDateOnly(to);
128
-
129
- if (!isFinite(start.getTime()) || !isFinite(end.getTime())) {
130
- return 0;
131
- }
132
-
133
- const calendarDiff = Math.abs(end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000);
134
- if (calendarDiff > MAX_DATE_CALC_DAYS * 2) {
135
- return NaN;
171
+ function countBusinessDays(from: Date, to: Date, calendar: PreparedCalendar): number | null {
172
+ const span = Math.abs(to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000);
173
+ if (
174
+ span > MAX_DATE_CALC_DAYS ||
175
+ !isWithinYears(from, calendar) ||
176
+ !isWithinYears(to, calendar)
177
+ ) {
178
+ return null;
136
179
  }
137
180
 
138
- const holidaySet = buildHolidaySet(options?.holidays);
139
- const direction = end.getTime() >= start.getTime() ? 1 : -1;
140
- const cursor = new Date(start.getTime());
181
+ const direction = to.getTime() >= from.getTime() ? 1 : -1;
182
+ const cursor = new Date(from.getTime());
141
183
  let count = 0;
142
184
 
143
- while (toDateKey(cursor) !== toDateKey(end)) {
185
+ while (toDateKey(cursor) !== toDateKey(to)) {
144
186
  cursor.setUTCDate(cursor.getUTCDate() + direction);
145
- if (!isNonBusinessDay(cursor, holidaySet)) {
187
+ if (isBusinessDay(cursor, calendar)) {
146
188
  count++;
147
189
  }
148
190
  }
@@ -150,12 +192,47 @@ export function dateDiffDays(
150
192
  return count * direction;
151
193
  }
152
194
 
153
- /** Format a Date to ISO date string (YYYY-MM-DD). */
154
- export function formatDateResult(date: Date): string {
155
- if (!isFinite(date.getTime())) {
156
- return '';
195
+ /**
196
+ * Adds business days to a date, as DTE's noderenderer `dateAddDays` does. Returns the result as
197
+ * `YYYY-MM-DD`, or null (a blank field, as in BYOD) for an invalid date, a fractional offset, or
198
+ * a start or result outside the calendar's years.
199
+ */
200
+ export function addDays(
201
+ dateInput: unknown,
202
+ daysInput: unknown,
203
+ options?: DateCalcOptions,
204
+ ): string | null {
205
+ const isDefault = !options?.calendar;
206
+ const calendar = prepareCalendar(options?.calendar ?? defaultCalendar(options?.holidays));
207
+ const start = parseDate(dateInput);
208
+ const days = Number(daysInput);
209
+ if (!start || !Number.isInteger(days) || (isDefault && !isDateWithinRange(start))) {
210
+ return null;
211
+ }
212
+ const result = addBusinessDays(start, days, calendar);
213
+ if (!result || (isDefault && !isDateWithinRange(result))) {
214
+ return null;
215
+ }
216
+ return toDateKey(result);
217
+ }
218
+
219
+ /**
220
+ * Counts the business days from `from` to `to` (positive when `to` is later), as DTE's
221
+ * noderenderer `dateDiffDays` does. A formula's `D1 - D2` counts from D2 to D1. Null (a blank
222
+ * field) for an invalid or missing date, or a span over MAX_DATE_CALC_DAYS.
223
+ */
224
+ export function dateDiffDays(
225
+ fromInput: unknown,
226
+ toInput: unknown,
227
+ options?: DateCalcOptions,
228
+ ): number | null {
229
+ const calendar = prepareCalendar(options?.calendar ?? defaultCalendar(options?.holidays));
230
+ const from = parseDate(fromInput);
231
+ const to = parseDate(toInput);
232
+ if (!from || !to) {
233
+ return null;
157
234
  }
158
- return toDateKey(date);
235
+ return countBusinessDays(from, to, calendar);
159
236
  }
160
237
 
161
238
  export const DEFAULT_DATE_FORMAT = 'MM/DD/YYYY';
@@ -1,9 +1,14 @@
1
+ import { E_SIGN_TOOL_SLUG } from './const';
2
+
1
3
  /**
2
4
  * Custom tools whose `values.id` is a per-instance GUID that must stay unique
3
5
  * within a design. When such a tool is duplicated (content or row duplicate),
4
6
  * Unlayer copies the values as-is, so the host regenerates the id of the copy.
7
+ *
8
+ * `calculated_field` and `e-sign` use it as the field key DTE binds a business-day schedule
9
+ * to, so a copy starts unbound.
5
10
  */
6
- export const INSTANCE_ID_TOOL_SLUGS = ['embedded-image'];
11
+ export const INSTANCE_ID_TOOL_SLUGS = ['embedded-image', 'calculated_field', E_SIGN_TOOL_SLUG];
7
12
 
8
13
  /**
9
14
  * Generates an RFC 4122 v4 GUID (`3fa85f64-5717-4562-b3fc-2c963f66afa6`) with the
package/src/store.ts CHANGED
@@ -4,11 +4,15 @@ import { loadScript } from './loadScript';
4
4
  import { logUnlayerError } from './log';
5
5
  import { toUnlayerMergeTags, type UnlayerMergeTagsConfig } from './merge-tags';
6
6
  import { defaultImageValidation } from './shared/configs';
7
- import type {
8
- UnlayerEditorTwin,
9
- UnlayerEventConfig,
10
- UnlayerEventESignOffset,
11
- UnlayerEventRegister,
7
+ import {
8
+ E_SIGN_FIELD_TYPE_LABELS,
9
+ type UnlayerEditorTwin,
10
+ type UnlayerEventBusinessCalendarRequest,
11
+ type UnlayerEventBusinessCalendarResult,
12
+ type UnlayerEventBusinessCalendarState,
13
+ type UnlayerEventConfig,
14
+ type UnlayerEventESignOffset,
15
+ type UnlayerEventRegister,
12
16
  } from './shared/const';
13
17
  import { normalizeESignOffsetSettings } from './shared/e-sign-offset';
14
18
  import { unlayerSupportedFonts } from './shared/fonts';
@@ -212,6 +216,9 @@ export class UnlayerStore {
212
216
  private isEditorReady = false;
213
217
  private isMergeTagsApplied = false;
214
218
  private mergeTagsConfig?: UnlayerMergeTagsConfig;
219
+ /** Latest `--business-calendar-state` payload, and the last one sent to the iframe. */
220
+ private businessCalendarStateJson?: string;
221
+ private sentBusinessCalendarStateJson?: string;
215
222
 
216
223
  private onMessageCB?: (type: string, data: any) => void;
217
224
  private onChangeCB?: (info: UnlayerDesignChangeInfo) => void;
@@ -222,6 +229,7 @@ export class UnlayerStore {
222
229
  private onFormSelectCB?: (formId: number) => void;
223
230
  private onCalcFieldOpenCB?: (usedFormIds: number[]) => void;
224
231
  private onCalcFieldSelectCB?: (fieldKeys: string[]) => void;
232
+ private onBusinessCalendarRequestCB?: (request: UnlayerEventBusinessCalendarRequest) => void;
225
233
 
226
234
  constructor(readonly props: CreateUnlayerEditorProps) {
227
235
  const schemaData = this.props.schema ? schemaBuildMap(this.props.schema) : undefined;
@@ -232,6 +240,7 @@ export class UnlayerStore {
232
240
  .map(field => field.fullKey),
233
241
  );
234
242
 
243
+ // Each entry's type value is derived from it; the name shown can differ (E_SIGN_FIELD_TYPE_LABELS).
235
244
  this.props.eSignFieldTypes = [
236
245
  'Signature',
237
246
  'Initials',
@@ -379,6 +388,32 @@ export class UnlayerStore {
379
388
  this.onCalcFieldSelectCB = onCalcFieldSelect;
380
389
  };
381
390
 
391
+ setOnBusinessCalendarRequest = (
392
+ onBusinessCalendarRequest?: UnlayerStore['onBusinessCalendarRequestCB'],
393
+ ) => {
394
+ this.onBusinessCalendarRequestCB = onBusinessCalendarRequest;
395
+ };
396
+
397
+ /** Tells the iframe how a schedule `delete` request ended. */
398
+ sendBusinessCalendarResult = (result: UnlayerEventBusinessCalendarResult) => {
399
+ this.sendMessage('--business-calendar-result', JSON.parse(JSON.stringify(result)));
400
+ };
401
+
402
+ /**
403
+ * Keeps the schedule state for the iframe and sends it once the tools core has loaded (it
404
+ * goes out again right after `--config`). An unchanged state isn't sent again.
405
+ */
406
+ setBusinessCalendarState = (state: UnlayerEventBusinessCalendarState) => {
407
+ this.businessCalendarStateJson = JSON.stringify(state);
408
+
409
+ if (
410
+ this.hasCoreLoaded &&
411
+ this.businessCalendarStateJson !== this.sentBusinessCalendarStateJson
412
+ ) {
413
+ this.sendBusinessCalendarState();
414
+ }
415
+ };
416
+
382
417
  sendFormList = (forms: FormInfo[]) => {
383
418
  this.sendMessage('--form-list', { forms });
384
419
  };
@@ -509,17 +544,25 @@ export class UnlayerStore {
509
544
  genericConfigMode: this.props.genericConfigMode,
510
545
  isSnapshotMode: this.props.isSnapshotMode,
511
546
  holidays: this.props.holidays,
547
+ fieldCalendars: this.props.fieldCalendars,
512
548
  conditionalRulesEnabled:
513
549
  !!this.props.displayConditions || !!this.props.onConditionalsOpen,
514
550
  };
515
551
 
516
552
  this.sendMessage('--config', JSON.parse(JSON.stringify(configData)));
553
+ // Always: a reloaded tools core starts without it.
554
+ this.sendBusinessCalendarState();
517
555
  } else if (type === '--form-select') {
518
556
  this.onFormSelectCB?.(data?.formId);
519
557
  } else if (type === '--calc-field-open') {
520
558
  this.onCalcFieldOpenCB?.(data?.usedFormIds ?? []);
521
559
  } else if (type === '--esign-offset-open') {
522
560
  this.openESignOffsetRules(data);
561
+ } else if (type === '--business-calendar-request') {
562
+ // It can delete a tenant schedule and replace bindings: only from this editor's iframe.
563
+ if (e.source === this.iframe?.contentWindow) {
564
+ this.routeBusinessCalendarRequest(data);
565
+ }
523
566
  } else if (type === '--calc-field-select') {
524
567
  if (this.onCalcFieldSelectCB) {
525
568
  this.onCalcFieldSelectCB(data?.fieldKeys ?? []);
@@ -532,6 +575,7 @@ export class UnlayerStore {
532
575
  toolTwins: this.props.toolTwins,
533
576
  eSignRecipients: this.props.eSignRecipients,
534
577
  eSignFieldTypes: this.props.eSignFieldTypes,
578
+ eSignFieldTypeLabels: E_SIGN_FIELD_TYPE_LABELS,
535
579
  units: this.props.units?.map(unit => ({
536
580
  ...unit,
537
581
  values: {
@@ -580,6 +624,25 @@ export class UnlayerStore {
580
624
  });
581
625
  };
582
626
 
627
+ private routeBusinessCalendarRequest = (
628
+ data?: Partial<UnlayerEventBusinessCalendarRequest>,
629
+ ) => {
630
+ if (!data?.action || !data.fieldKey) {
631
+ return;
632
+ }
633
+
634
+ this.onBusinessCalendarRequestCB?.(data as UnlayerEventBusinessCalendarRequest);
635
+ };
636
+
637
+ private sendBusinessCalendarState = () => {
638
+ if (!this.businessCalendarStateJson) {
639
+ return;
640
+ }
641
+
642
+ this.sentBusinessCalendarStateJson = this.businessCalendarStateJson;
643
+ this.sendMessage('--business-calendar-state', JSON.parse(this.businessCalendarStateJson));
644
+ };
645
+
583
646
  private updateFormFieldSamples = (formId: number, fields: FormFieldInfo[]) => {
584
647
  fields.forEach((field, index) => {
585
648
  const normalizedFieldId = String(field.id ?? '').replaceAll('-', '');
@@ -1,8 +1,10 @@
1
+ import type { BusinessCalendarHostProps } from '@servicetitan/dte-shared';
1
2
  import {
2
3
  UnlayerEditorESignRecipientInfo,
3
4
  UnlayerEditorTwin,
4
5
  UnlayerEditorUnit,
5
6
  } from './shared/const';
7
+ import type { BusinessCalendar } from './shared/date-calc';
6
8
  import { FormFieldInfo, FormInfo } from './shared/forms';
7
9
  import { SchemaObject } from './shared/schema';
8
10
 
@@ -72,7 +74,16 @@ export interface UnlayerEditorCustomTool {
72
74
  groupUrl?: string;
73
75
  }
74
76
 
75
- export interface CreateUnlayerEditorProps {
77
+ /**
78
+ * Editor options. The business-day schedule props (`BusinessCalendarHostProps`: `scheduleBindings`,
79
+ * `schedules`, `holidayCatalog`, `onScheduleBindingsChange`, ...) mean the same as `PdfEditor`'s;
80
+ * the field key of a binding is the tool's `values.id` (calculated field or e-sign "Date Signed
81
+ * Offset" field). The schedule UI shows only when `onScheduleBindingsChange` is passed. Changes
82
+ * to them (and to `holidays` / `fieldCalendars`) reach the editor when the host re-renders
83
+ * `UnlayerEditor` with new values: new arrays and objects, as for `PdfEditor`, since one changed
84
+ * in place isn't noticed.
85
+ */
86
+ export interface CreateUnlayerEditorProps extends BusinessCalendarHostProps {
76
87
  tools: UnlayerEditorCustomTool[];
77
88
  newSnapshot?: UnlayerEditorTwin;
78
89
  toolTwins?: UnlayerEditorTwin[];
@@ -107,7 +118,20 @@ export interface CreateUnlayerEditorProps {
107
118
  maxHeight?: number;
108
119
  maxFileSize?: number;
109
120
  };
121
+ /**
122
+ * Holidays of the default business-day calendar (`YYYY-MM-DD`, observed days), skipped with
123
+ * Saturday and Sunday by date calculated fields whose toggle is off. Without it the previews
124
+ * skip weekends only. Sent to the editor again whenever it changes.
125
+ */
110
126
  holidays?: string[];
127
+ /**
128
+ * Calendars of the date calculated fields bound to a schedule, by field key (the tool's
129
+ * `values.id`), as `POST api/holidays/calendar` resolves them. Fields not listed use the
130
+ * default calendar (`holidays`); while `onScheduleBindingsChange` is passed, so does a field
131
+ * without a binding in `scheduleBindings`. Only the editor previews read it: rendered
132
+ * documents get their calendars from DTE. Sent to the editor again whenever it changes.
133
+ */
134
+ fieldCalendars?: Record<string, BusinessCalendar>;
111
135
  onFormSelect?: (formId: number) => void;
112
136
  onCalcFieldOpen?: (usedFormIds: number[]) => void;
113
137
  onCalcFieldSelect?: (fieldKeys: string[]) => void;
package/src/unlayer.tsx CHANGED
@@ -2,7 +2,7 @@ import { emitDisplayCondition } from './display-conditions/displayConditionContr
2
2
  import { editorCoreScript, editorCoreStyles, editorCoreTools } from './editor-core';
3
3
  import { logUnlayerError } from './log';
4
4
  import { toUnlayerMergeTags } from './merge-tags';
5
- import { constGenericsEditor } from './shared/const';
5
+ import { constGenericsEditor, E_SIGN_FIELD_TYPE_LABELS } from './shared/const';
6
6
  import { unlayerSupportedFonts } from './shared/fonts';
7
7
  import {
8
8
  unlayerCustomEmptyToolState,
@@ -226,10 +226,10 @@ export const createUnlayerEditor = (
226
226
 
227
227
  const acv = constGenericsEditor.adminConfigProperty;
228
228
 
229
- const eSignTypeMapping = (eSignFieldTypes ?? []).map(key => ({
230
- label: key,
231
- value: key.charAt(0).toLowerCase() + key.replace(/ /g, '').slice(1),
232
- }));
229
+ const eSignTypeMapping = (eSignFieldTypes ?? []).map(key => {
230
+ const value = key.charAt(0).toLowerCase() + key.replace(/ /g, '').slice(1);
231
+ return { label: E_SIGN_FIELD_TYPE_LABELS[value] ?? key, value };
232
+ });
233
233
 
234
234
  const unitsConfig = (units ?? []).reduce(
235
235
  (out, unit) => {