@servicetitan/dte-pdf-editor 1.95.0 → 1.96.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.
- package/README.md +84 -2
- package/dist/components/field-config-panel/advanced-settings.d.ts +2 -0
- package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +5 -4
- package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts +3 -0
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.js +6 -4
- package/dist/components/field-config-panel/e-sign-options-modal.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +14 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.js +41 -2
- package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.d.ts +6 -0
- package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.js +17 -3
- package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
- package/dist/components/field-config-panel/formula-generator.d.ts +4 -0
- package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-generator.js +2 -2
- package/dist/components/field-config-panel/formula-generator.js.map +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts +5 -0
- package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-modal.js +13 -6
- package/dist/components/field-config-panel/formula-modal.js.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.d.ts +2 -0
- package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.js +6 -4
- package/dist/components/field-config-panel/formula-workspace.js.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts +52 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.js +2 -2
- package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts +3 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.js +14 -4
- package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +18 -4
- package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view.js +2 -2
- package/dist/components/pdf-view/pdf-view.js.map +1 -1
- package/dist/constants/calculated.constants.d.ts +6 -0
- package/dist/constants/calculated.constants.d.ts.map +1 -1
- package/dist/constants/calculated.constants.js +6 -0
- package/dist/constants/calculated.constants.js.map +1 -1
- package/dist/hooks/usePinchZoom.d.ts.map +1 -1
- package/dist/hooks/usePinchZoom.js +1 -1
- package/dist/hooks/usePinchZoom.js.map +1 -1
- package/dist/interface/types.d.ts +37 -1
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/formula/business-days.utils.d.ts +32 -0
- package/dist/utils/formula/business-days.utils.d.ts.map +1 -0
- package/dist/utils/formula/business-days.utils.js +148 -0
- package/dist/utils/formula/business-days.utils.js.map +1 -0
- package/dist/utils/formula/caret.utils.js +1 -1
- package/dist/utils/formula/caret.utils.js.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.d.ts +25 -6
- package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.js +61 -101
- package/dist/utils/formula/evaluate-formula.utils.js.map +1 -1
- package/dist/utils/formula/index.d.ts +1 -0
- package/dist/utils/formula/index.d.ts.map +1 -1
- package/dist/utils/formula/index.js +1 -0
- package/dist/utils/formula/index.js.map +1 -1
- package/dist/utils/formula/schedule-bindings.utils.d.ts +12 -0
- package/dist/utils/formula/schedule-bindings.utils.d.ts.map +1 -0
- package/dist/utils/formula/schedule-bindings.utils.js +17 -0
- package/dist/utils/formula/schedule-bindings.utils.js.map +1 -0
- package/package.json +2 -1
- package/src/__tests__/field-types/date-fields.test.ts +29 -3
- package/src/__tests__/field-types/e-sign-fields.test.ts +2 -2
- package/src/__tests__/field-types/fillable-fields.test.ts +2 -2
- package/src/components/field-config-panel/advanced-settings.tsx +16 -3
- package/src/components/field-config-panel/e-sign-options-modal.tsx +13 -2
- package/src/components/field-config-panel/field-config-panel-overlay.tsx +97 -1
- package/src/components/field-config-panel/field-config-panel.tsx +28 -0
- package/src/components/field-config-panel/formula-generator.tsx +8 -1
- package/src/components/field-config-panel/formula-modal.tsx +19 -4
- package/src/components/field-config-panel/formula-workspace.tsx +31 -6
- package/src/components/pdf-editor/pdf-editor.tsx +82 -0
- package/src/components/pdf-view/pdf-view-calculated.tsx +28 -3
- package/src/components/pdf-view/pdf-view.tsx +23 -3
- package/src/constants/calculated.constants.ts +7 -0
- package/src/hooks/usePinchZoom.ts +1 -6
- package/src/interface/types.ts +47 -0
- package/src/utils/field/__tests__/field-background-color.utils.test.ts +8 -8
- package/src/utils/field/__tests__/get-field-bounds-style.utils.test.ts +16 -0
- package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +198 -16
- package/src/utils/formula/__tests__/schedule-bindings.utils.test.ts +73 -0
- package/src/utils/formula/business-days.utils.ts +188 -0
- package/src/utils/formula/caret.utils.ts +1 -1
- package/src/utils/formula/evaluate-formula.utils.ts +94 -114
- package/src/utils/formula/index.ts +1 -0
- package/src/utils/formula/schedule-bindings.utils.ts +27 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
type BusinessCalendar,
|
|
5
|
+
type DataModelValues,
|
|
6
|
+
type FieldScheduleBinding,
|
|
7
|
+
type StructuredFormula,
|
|
8
|
+
} from '../../../interface/types';
|
|
9
|
+
import {
|
|
10
|
+
evaluateFormula,
|
|
11
|
+
getFieldDateCalcOptions,
|
|
12
|
+
hasDivisionByZero,
|
|
13
|
+
valueToNumber,
|
|
14
|
+
} from '../evaluate-formula.utils';
|
|
3
15
|
|
|
4
16
|
const f = (tokens: StructuredFormula['tokens']): StructuredFormula => ({ tokens });
|
|
5
17
|
|
|
@@ -35,7 +47,7 @@ describe('evaluateFormula', () => {
|
|
|
35
47
|
formula: StructuredFormula | undefined | null,
|
|
36
48
|
data: DataModelValues | undefined,
|
|
37
49
|
holidays?: string[],
|
|
38
|
-
) => evaluateFormula(formula, data, holidays);
|
|
50
|
+
) => evaluateFormula(formula, data, { enabled: true, holidays });
|
|
39
51
|
|
|
40
52
|
describe('when formula is empty', () => {
|
|
41
53
|
test.each([
|
|
@@ -180,6 +192,18 @@ describe('evaluateFormula', () => {
|
|
|
180
192
|
});
|
|
181
193
|
|
|
182
194
|
describe('with date fields', () => {
|
|
195
|
+
// The default calendar covers the current year ± 1.
|
|
196
|
+
beforeAll(() => {
|
|
197
|
+
jest.useFakeTimers({ now: new Date('2024-06-01T00:00:00Z') });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterAll(() => {
|
|
201
|
+
jest.useRealTimers();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const dateKey = (result: number | null) =>
|
|
205
|
+
result === null ? null : new Date(result).toISOString().slice(0, 10);
|
|
206
|
+
|
|
183
207
|
test('adds business days to a date field (skipping weekend)', () => {
|
|
184
208
|
// Friday 2024-01-05 + 1 business day = Monday 2024-01-08
|
|
185
209
|
const formula = f([
|
|
@@ -187,22 +211,18 @@ describe('evaluateFormula', () => {
|
|
|
187
211
|
{ type: 'operator', value: '+' },
|
|
188
212
|
{ type: 'number', value: '1' },
|
|
189
213
|
]);
|
|
190
|
-
|
|
191
|
-
expect(result).not.toBeNull();
|
|
192
|
-
const date = new Date(result as number);
|
|
193
|
-
expect(date.getUTCFullYear()).toBe(2024);
|
|
194
|
-
expect(date.getUTCMonth()).toBe(0);
|
|
195
|
-
expect(date.getUTCDate()).toBe(8);
|
|
214
|
+
expect(dateKey(subject(formula, { d: '2024-01-05' }))).toBe('2024-01-08');
|
|
196
215
|
});
|
|
197
216
|
|
|
198
|
-
test('counts
|
|
199
|
-
//
|
|
217
|
+
test('counts D1 - D2 from D2 to D1, positive when D1 is later', () => {
|
|
218
|
+
// end - start traverses Mon 2024-01-01 → Fri 2024-01-05 = 4 business days
|
|
200
219
|
const formula = f([
|
|
201
|
-
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
202
|
-
{ type: 'operator', value: '-' },
|
|
203
220
|
{ type: 'field', path: 'end', fieldType: 'date' },
|
|
221
|
+
{ type: 'operator', value: '-' },
|
|
222
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
204
223
|
]);
|
|
205
224
|
expect(subject(formula, { start: '2024-01-01', end: '2024-01-05' })).toBe(4);
|
|
225
|
+
expect(subject(formula, { start: '2024-01-05', end: '2024-01-01' })).toBe(-4);
|
|
206
226
|
});
|
|
207
227
|
|
|
208
228
|
test('skips holidays when adding business days', () => {
|
|
@@ -212,9 +232,43 @@ describe('evaluateFormula', () => {
|
|
|
212
232
|
{ type: 'operator', value: '+' },
|
|
213
233
|
{ type: 'number', value: '1' },
|
|
214
234
|
]);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
expect(dateKey(subject(formula, { d: '2024-01-08' }, ['2024-01-09']))).toBe(
|
|
236
|
+
'2024-01-10',
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('applies chained offsets one step at a time', () => {
|
|
241
|
+
// D - 1 + 2 from Sat 2024-10-05: Fri 10-04, then Tue 10-08
|
|
242
|
+
const formula = f([
|
|
243
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
244
|
+
{ type: 'operator', value: '-' },
|
|
245
|
+
{ type: 'number', value: '1' },
|
|
246
|
+
{ type: 'operator', value: '+' },
|
|
247
|
+
{ type: 'number', value: '2' },
|
|
248
|
+
]);
|
|
249
|
+
expect(dateKey(subject(formula, { d: '2024-10-05' }))).toBe('2024-10-08');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test.each([
|
|
253
|
+
['a fractional offset', { d: '2024-09-30', n: '2.5' }],
|
|
254
|
+
['a start outside the current year ± 1', { d: '2022-06-01', n: '1' }],
|
|
255
|
+
['a result outside the current year ± 1', { d: '2025-12-31', n: '1' }],
|
|
256
|
+
])('returns null for %s', (_, data) => {
|
|
257
|
+
const formula = f([
|
|
258
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
259
|
+
{ type: 'operator', value: '+' },
|
|
260
|
+
{ type: 'field', path: 'n', fieldType: 'number' },
|
|
261
|
+
]);
|
|
262
|
+
expect(subject(formula, data)).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('returns null for a difference over 365 days', () => {
|
|
266
|
+
const formula = f([
|
|
267
|
+
{ type: 'field', path: 'end', fieldType: 'date' },
|
|
268
|
+
{ type: 'operator', value: '-' },
|
|
269
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
270
|
+
]);
|
|
271
|
+
expect(subject(formula, { start: '2024-01-02', end: '2025-01-05' })).toBeNull();
|
|
218
272
|
});
|
|
219
273
|
|
|
220
274
|
test('returns null when date field has invalid value', () => {
|
|
@@ -225,5 +279,133 @@ describe('evaluateFormula', () => {
|
|
|
225
279
|
]);
|
|
226
280
|
expect(subject(formula, { d: 'not-a-date' })).toBeNull();
|
|
227
281
|
});
|
|
282
|
+
|
|
283
|
+
test('uses only the field calendar when one is passed', () => {
|
|
284
|
+
// Fri 2026-07-10 + 1 = Sat 2026-07-11 with a Sunday-only calendar, outside today's years.
|
|
285
|
+
const formula = f([
|
|
286
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
287
|
+
{ type: 'operator', value: '+' },
|
|
288
|
+
{ type: 'number', value: '1' },
|
|
289
|
+
]);
|
|
290
|
+
const calendar: BusinessCalendar = {
|
|
291
|
+
weekendDays: [0],
|
|
292
|
+
holidays: [],
|
|
293
|
+
firstYear: 2026,
|
|
294
|
+
lastYear: 2026,
|
|
295
|
+
};
|
|
296
|
+
const result = evaluateFormula(formula, { d: '2026-07-10' }, { enabled: true, calendar });
|
|
297
|
+
expect(dateKey(result)).toBe('2026-07-11');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('uses plain calendar-day arithmetic (no skipping) when the toggle is disabled', () => {
|
|
301
|
+
// Friday 2024-01-05 + 1 day = Saturday 2024-01-06, unskipped, regardless of holidays passed.
|
|
302
|
+
const formula = f([
|
|
303
|
+
{ type: 'field', path: 'd', fieldType: 'date' },
|
|
304
|
+
{ type: 'operator', value: '+' },
|
|
305
|
+
{ type: 'number', value: '1' },
|
|
306
|
+
]);
|
|
307
|
+
const result = evaluateFormula(
|
|
308
|
+
formula,
|
|
309
|
+
{ d: '2024-01-05' },
|
|
310
|
+
{ enabled: false, holidays: ['2024-01-06'] },
|
|
311
|
+
);
|
|
312
|
+
expect(dateKey(result)).toBe('2024-01-06');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('uses plain calendar-day diff (no skipping) when the toggle is disabled', () => {
|
|
316
|
+
// Plain calendar days between Mon 2024-01-01 and Fri 2024-01-05 is 4, same as business days
|
|
317
|
+
// here, so use a span that differs: Fri 2024-01-05 -> Mon 2024-01-08 is 3 calendar days but
|
|
318
|
+
// only 1 business day.
|
|
319
|
+
const formula = f([
|
|
320
|
+
{ type: 'field', path: 'end', fieldType: 'date' },
|
|
321
|
+
{ type: 'operator', value: '-' },
|
|
322
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
323
|
+
]);
|
|
324
|
+
const result = evaluateFormula(
|
|
325
|
+
formula,
|
|
326
|
+
{ start: '2024-01-05', end: '2024-01-08' },
|
|
327
|
+
{ enabled: false },
|
|
328
|
+
);
|
|
329
|
+
expect(result).toBe(3);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('getFieldDateCalcOptions', () => {
|
|
335
|
+
const calendar: BusinessCalendar = {
|
|
336
|
+
weekendDays: [0],
|
|
337
|
+
holidays: [],
|
|
338
|
+
firstYear: 2026,
|
|
339
|
+
lastYear: 2027,
|
|
340
|
+
};
|
|
341
|
+
const scheduleBindings: FieldScheduleBinding[] = [{ fieldKey: 'a' }, { fieldKey: 'b' }];
|
|
342
|
+
|
|
343
|
+
test.each([
|
|
344
|
+
['the field calendar for a bound, listed field', 'a', { enabled: true, calendar }],
|
|
345
|
+
[
|
|
346
|
+
'the default holidays for a bound field with no calendar entry',
|
|
347
|
+
'b',
|
|
348
|
+
{ enabled: true, holidays: ['2026-07-03'] },
|
|
349
|
+
],
|
|
350
|
+
['disabled for an unbound field', 'c', { enabled: false }],
|
|
351
|
+
['disabled without a field id', undefined, { enabled: false }],
|
|
352
|
+
])('returns %s', (_, fieldId, expected) => {
|
|
353
|
+
expect(
|
|
354
|
+
getFieldDateCalcOptions(fieldId, scheduleBindings, ['2026-07-03'], { a: calendar }),
|
|
355
|
+
).toEqual(expected);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('business-day parity fixture', () => {
|
|
360
|
+
// DTE's shared business-day cases; packages/unlayer's date-calc test checks the copy's hash.
|
|
361
|
+
const fixture: {
|
|
362
|
+
calendars: Record<string, BusinessCalendar>;
|
|
363
|
+
cases: {
|
|
364
|
+
name: string;
|
|
365
|
+
calendar: string;
|
|
366
|
+
start: string;
|
|
367
|
+
steps?: number[];
|
|
368
|
+
end?: string | null;
|
|
369
|
+
expected: string | number | null;
|
|
370
|
+
}[];
|
|
371
|
+
} = JSON.parse(
|
|
372
|
+
readFileSync(
|
|
373
|
+
resolve(__dirname, '../../../../../../tests/fixtures/business-day-cases.json'),
|
|
374
|
+
'utf-8',
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
type FixtureCase = (typeof fixture.cases)[number];
|
|
379
|
+
|
|
380
|
+
// The case as a BYOD formula: `start ± step ± step …`, or `end - start`.
|
|
381
|
+
const toFormula = (testCase: FixtureCase): StructuredFormula => {
|
|
382
|
+
if ('end' in testCase) {
|
|
383
|
+
return f([
|
|
384
|
+
{ type: 'field', path: 'end', fieldType: 'date' },
|
|
385
|
+
{ type: 'operator', value: '-' },
|
|
386
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
387
|
+
]);
|
|
388
|
+
}
|
|
389
|
+
return f([
|
|
390
|
+
{ type: 'field', path: 'start', fieldType: 'date' },
|
|
391
|
+
...(testCase.steps ?? []).flatMap((step): StructuredFormula['tokens'] => [
|
|
392
|
+
{ type: 'operator', value: step < 0 ? '-' : '+' },
|
|
393
|
+
{ type: 'number', value: String(Math.abs(step)) },
|
|
394
|
+
]),
|
|
395
|
+
]);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
test.each(fixture.cases.map(c => [c.name, c] as const))('%s', (_, testCase) => {
|
|
399
|
+
const data = { start: testCase.start, end: testCase.end ?? '' };
|
|
400
|
+
const result = evaluateFormula(toFormula(testCase), data, {
|
|
401
|
+
enabled: true,
|
|
402
|
+
calendar: fixture.calendars[testCase.calendar],
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const actual =
|
|
406
|
+
result === null || 'end' in testCase
|
|
407
|
+
? result
|
|
408
|
+
: new Date(result).toISOString().slice(0, 10);
|
|
409
|
+
expect(actual).toBe(testCase.expected);
|
|
228
410
|
});
|
|
229
411
|
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type FieldScheduleBinding } from '../../../interface/types';
|
|
2
|
+
import { setFieldSchedule, setFieldScheduleEnabled } from '../schedule-bindings.utils';
|
|
3
|
+
|
|
4
|
+
describe('setFieldScheduleEnabled', () => {
|
|
5
|
+
test('adds a default-schedule binding when enabling a field with no existing binding', () => {
|
|
6
|
+
expect(setFieldScheduleEnabled([], 'field-a', true)).toEqual([{ fieldKey: 'field-a' }]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('removing a field with no binding is a no-op', () => {
|
|
10
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-b', scheduleId: 7 }];
|
|
11
|
+
expect(setFieldScheduleEnabled(bindings, 'field-a', false)).toEqual(bindings);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('disabling removes the field from the bindings', () => {
|
|
15
|
+
const bindings: FieldScheduleBinding[] = [
|
|
16
|
+
{ fieldKey: 'field-a', scheduleId: 3 },
|
|
17
|
+
{ fieldKey: 'field-b' },
|
|
18
|
+
];
|
|
19
|
+
expect(setFieldScheduleEnabled(bindings, 'field-a', false)).toEqual([
|
|
20
|
+
{ fieldKey: 'field-b' },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('re-enabling after disabling resets to the default schedule, not the previous one', () => {
|
|
25
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-a', scheduleId: 3 }];
|
|
26
|
+
const disabled = setFieldScheduleEnabled(bindings, 'field-a', false);
|
|
27
|
+
expect(setFieldScheduleEnabled(disabled, 'field-a', true)).toEqual([
|
|
28
|
+
{ fieldKey: 'field-a' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('leaves other fields untouched (field identity isolation)', () => {
|
|
33
|
+
const bindings: FieldScheduleBinding[] = [
|
|
34
|
+
{ fieldKey: 'field-a', scheduleId: 1 },
|
|
35
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
36
|
+
];
|
|
37
|
+
const result = setFieldScheduleEnabled(bindings, 'field-a', false);
|
|
38
|
+
expect(result).toEqual([{ fieldKey: 'field-b', scheduleId: 2 }]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('setFieldSchedule', () => {
|
|
43
|
+
test('sets the default schedule (scheduleId undefined) for a new field', () => {
|
|
44
|
+
expect(setFieldSchedule([], 'field-a', undefined)).toEqual([
|
|
45
|
+
{ fieldKey: 'field-a', scheduleId: undefined },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('sets a named schedule id for a new field', () => {
|
|
50
|
+
expect(setFieldSchedule([], 'field-a', 42)).toEqual([
|
|
51
|
+
{ fieldKey: 'field-a', scheduleId: 42 },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('replaces an existing binding for the same field', () => {
|
|
56
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-a', scheduleId: 1 }];
|
|
57
|
+
expect(setFieldSchedule(bindings, 'field-a', 2)).toEqual([
|
|
58
|
+
{ fieldKey: 'field-a', scheduleId: 2 },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('leaves other fields untouched (field identity isolation)', () => {
|
|
63
|
+
const bindings: FieldScheduleBinding[] = [
|
|
64
|
+
{ fieldKey: 'field-a', scheduleId: 1 },
|
|
65
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
66
|
+
];
|
|
67
|
+
const result = setFieldSchedule(bindings, 'field-a', 9);
|
|
68
|
+
expect(result).toEqual([
|
|
69
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
70
|
+
{ fieldKey: 'field-a', scheduleId: 9 },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { DEFAULT_CALENDAR_YEARS, MAX_DATE_CALC_DAYS } from '../../constants';
|
|
2
|
+
import { type BusinessCalendar } from '../../interface/types';
|
|
3
|
+
|
|
4
|
+
/** Saturday and Sunday: the default calendar's weekend. */
|
|
5
|
+
const DEFAULT_WEEKEND_DAYS = [0, 6];
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
interface PreparedCalendar {
|
|
10
|
+
weekendDays: Set<number>;
|
|
11
|
+
holidays: Set<string>;
|
|
12
|
+
firstYear: number;
|
|
13
|
+
lastYear: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toDateOnly(ms: number): Date {
|
|
17
|
+
const d = new Date(ms);
|
|
18
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toDateKey(d: Date): string {
|
|
23
|
+
const y = d.getUTCFullYear();
|
|
24
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
25
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
26
|
+
return `${y}-${m}-${day}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildHolidaySet(holidays: string[] | undefined): Set<string> {
|
|
30
|
+
const set = new Set<string>();
|
|
31
|
+
for (const h of holidays ?? []) {
|
|
32
|
+
const d = toDateOnly(new Date(h).getTime());
|
|
33
|
+
if (!isNaN(d.getTime())) {
|
|
34
|
+
set.add(toDateKey(d));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return set;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Calendars are passed once and reused by every field, so each is prepared once.
|
|
41
|
+
const preparedCalendars = new WeakMap<BusinessCalendar, PreparedCalendar>();
|
|
42
|
+
|
|
43
|
+
function prepareCalendar(calendar: BusinessCalendar): PreparedCalendar {
|
|
44
|
+
const cached = preparedCalendars.get(calendar);
|
|
45
|
+
if (cached) {
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
const prepared: PreparedCalendar = {
|
|
49
|
+
weekendDays: new Set(calendar.weekendDays ?? DEFAULT_WEEKEND_DAYS),
|
|
50
|
+
holidays: buildHolidaySet(calendar.holidays),
|
|
51
|
+
firstYear: calendar.firstYear,
|
|
52
|
+
lastYear: calendar.lastYear,
|
|
53
|
+
};
|
|
54
|
+
preparedCalendars.set(calendar, prepared);
|
|
55
|
+
return prepared;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The default calendar: Saturday and Sunday plus `holidays` (the host's, DTE's
|
|
60
|
+
* `GET api/holidays`), for the current year ± DEFAULT_CALENDAR_YEARS. Fields whose schedule toggle
|
|
61
|
+
* is off use it.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultBusinessCalendar(holidays?: string[]): BusinessCalendar {
|
|
64
|
+
const year = new Date().getUTCFullYear();
|
|
65
|
+
return {
|
|
66
|
+
weekendDays: DEFAULT_WEEKEND_DAYS,
|
|
67
|
+
holidays: holidays ?? [],
|
|
68
|
+
firstYear: year - DEFAULT_CALENDAR_YEARS,
|
|
69
|
+
lastYear: year + DEFAULT_CALENDAR_YEARS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isBusinessDay(d: Date, calendar: PreparedCalendar): boolean {
|
|
74
|
+
return !calendar.weekendDays.has(d.getUTCDay()) && !calendar.holidays.has(toDateKey(d));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isWithinYears(d: Date, calendar: PreparedCalendar): boolean {
|
|
78
|
+
const year = d.getUTCFullYear();
|
|
79
|
+
return year >= calendar.firstYear && year <= calendar.lastYear;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walks `days` business days from a date (epoch ms; negative walks back), the start not counted.
|
|
84
|
+
* Mirrors DTE's `BusinessDayCalculator.AddBusinessDays`: null for a fractional offset, more than
|
|
85
|
+
* MAX_DATE_CALC_DAYS, an invalid start, or a start or walk outside the calendar's years.
|
|
86
|
+
*/
|
|
87
|
+
export function addBusinessDays(
|
|
88
|
+
startMs: number,
|
|
89
|
+
days: number,
|
|
90
|
+
calendar: BusinessCalendar,
|
|
91
|
+
): number | null {
|
|
92
|
+
const prepared = prepareCalendar(calendar);
|
|
93
|
+
const start = toDateOnly(startMs);
|
|
94
|
+
if (
|
|
95
|
+
!Number.isInteger(days) ||
|
|
96
|
+
Math.abs(days) > MAX_DATE_CALC_DAYS ||
|
|
97
|
+
!isFinite(start.getTime()) ||
|
|
98
|
+
!isWithinYears(start, prepared)
|
|
99
|
+
) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const direction = days >= 0 ? 1 : -1;
|
|
104
|
+
let remaining = Math.abs(days);
|
|
105
|
+
const current = new Date(start.getTime());
|
|
106
|
+
|
|
107
|
+
while (remaining > 0) {
|
|
108
|
+
current.setUTCDate(current.getUTCDate() + direction);
|
|
109
|
+
if (!isWithinYears(current, prepared)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (isBusinessDay(current, prepared)) {
|
|
113
|
+
remaining--;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return current.getTime();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Counts the business days from `fromMs` to `toMs`, negative when `toMs` is earlier; the start is
|
|
122
|
+
* not counted, the end is. Mirrors DTE's `BusinessDayCalculator.CountBusinessDays`: null for an
|
|
123
|
+
* invalid date, a span over MAX_DATE_CALC_DAYS, or a date outside the calendar's years.
|
|
124
|
+
*/
|
|
125
|
+
export function countBusinessDays(
|
|
126
|
+
fromMs: number,
|
|
127
|
+
toMs: number,
|
|
128
|
+
calendar: BusinessCalendar,
|
|
129
|
+
): number | null {
|
|
130
|
+
const prepared = prepareCalendar(calendar);
|
|
131
|
+
const from = toDateOnly(fromMs);
|
|
132
|
+
const to = toDateOnly(toMs);
|
|
133
|
+
if (!isFinite(from.getTime()) || !isFinite(to.getTime())) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
Math.abs(to.getTime() - from.getTime()) / DAY_MS > MAX_DATE_CALC_DAYS ||
|
|
138
|
+
!isWithinYears(from, prepared) ||
|
|
139
|
+
!isWithinYears(to, prepared)
|
|
140
|
+
) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const direction = to.getTime() >= from.getTime() ? 1 : -1;
|
|
145
|
+
const cursor = new Date(from.getTime());
|
|
146
|
+
let count = 0;
|
|
147
|
+
|
|
148
|
+
while (toDateKey(cursor) !== toDateKey(to)) {
|
|
149
|
+
cursor.setUTCDate(cursor.getUTCDate() + direction);
|
|
150
|
+
if (isBusinessDay(cursor, prepared)) {
|
|
151
|
+
count++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return count * direction;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Adds `days` plain calendar days to a date (epoch ms; negative subtracts) — no weekend or holiday
|
|
160
|
+
* skipping. Used when a date calculated field's business-day exclusion toggle is off, matching the
|
|
161
|
+
* pre-DTE-4695 behavior. Null for a fractional offset, more than MAX_DATE_CALC_DAYS, or an invalid
|
|
162
|
+
* start.
|
|
163
|
+
*/
|
|
164
|
+
export function addCalendarDays(startMs: number, days: number): number | null {
|
|
165
|
+
const start = toDateOnly(startMs);
|
|
166
|
+
if (
|
|
167
|
+
!Number.isInteger(days) ||
|
|
168
|
+
Math.abs(days) > MAX_DATE_CALC_DAYS ||
|
|
169
|
+
!isFinite(start.getTime())
|
|
170
|
+
) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return start.getTime() + days * DAY_MS;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Counts the plain calendar days from `fromMs` to `toMs`, negative when `toMs` is earlier — no
|
|
178
|
+
* weekend or holiday skipping. Null for an invalid date or a span over MAX_DATE_CALC_DAYS.
|
|
179
|
+
*/
|
|
180
|
+
export function countCalendarDays(fromMs: number, toMs: number): number | null {
|
|
181
|
+
const from = toDateOnly(fromMs);
|
|
182
|
+
const to = toDateOnly(toMs);
|
|
183
|
+
if (!isFinite(from.getTime()) || !isFinite(to.getTime())) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const diffDays = Math.round((to.getTime() - from.getTime()) / DAY_MS);
|
|
187
|
+
return Math.abs(diffDays) > MAX_DATE_CALC_DAYS ? null : diffDays;
|
|
188
|
+
}
|
|
@@ -27,7 +27,7 @@ function getExpressionBeforeCaret(element: HTMLElement): string {
|
|
|
27
27
|
while (current && current.parentNode !== element) {
|
|
28
28
|
current = current.parentNode;
|
|
29
29
|
}
|
|
30
|
-
return current
|
|
30
|
+
return current?.parentNode === element ? (current as ChildNode) : null;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
if (endContainer === element) {
|