@servicetitan/dte-pdf-editor 1.96.0 → 1.96.2
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 +19 -7
- package/dist/components/field-config-panel/advanced-settings.d.ts +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.js +1 -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 +23 -15
- 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 +1 -1
- package/dist/components/field-config-panel/formula-generator.d.ts +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts +1 -1
- package/dist/components/field-config-panel/formula-workspace.d.ts +1 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts +6 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
- package/dist/interface/types.d.ts +1 -1
- package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +22 -0
- package/dist/vendor/dte-shared/business-calendar-validation.utils.js +76 -0
- package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +15 -0
- package/dist/vendor/dte-shared/components/business-calendar-section.js +13 -0
- package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +16 -0
- package/dist/vendor/dte-shared/components/schedule-drawer.js +112 -0
- package/dist/vendor/dte-shared/components/schedule-picker.d.ts +8 -0
- package/dist/vendor/dte-shared/components/schedule-picker.js +88 -0
- package/dist/vendor/dte-shared/constants.d.ts +10 -0
- package/dist/vendor/dte-shared/constants.js +10 -0
- package/dist/vendor/dte-shared/index.d.ts +6 -0
- package/dist/vendor/dte-shared/index.js +5 -0
- package/dist/vendor/dte-shared/styles/schedule.css +19 -0
- package/dist/vendor/dte-shared/types.d.ts +67 -0
- package/dist/vendor/dte-shared/types.js +1 -0
- package/package.json +5 -3
- package/src/components/field-config-panel/__tests__/field-config-panel-overlay.test.tsx +184 -0
- package/src/components/field-config-panel/__tests__/schedule-switch-visibility.test.tsx +206 -0
- package/src/components/field-config-panel/field-config-panel-overlay.tsx +25 -16
- package/src/components/pdf-editor/pdf-editor.tsx +6 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Alert, Button, Chip, Flex, SelectFieldSync, Text } from '@servicetitan/anvil2';
|
|
3
|
+
import DeleteIcon from '@servicetitan/anvil2/assets/icons/material/round/delete.svg';
|
|
4
|
+
import EditIcon from '@servicetitan/anvil2/assets/icons/material/round/edit.svg';
|
|
5
|
+
import IconExpandLess from '@servicetitan/anvil2/assets/icons/material/round/expand_less.svg';
|
|
6
|
+
import IconExpandMore from '@servicetitan/anvil2/assets/icons/material/round/expand_more.svg';
|
|
7
|
+
import { Fragment, useMemo, useState } from 'react';
|
|
8
|
+
import { formatCustomDateLabel } from '../business-calendar-validation.utils';
|
|
9
|
+
import { ScheduleDrawer } from './schedule-drawer';
|
|
10
|
+
/** `BusinessCalendarRules.weekendDays` day numbers, as `Date.getUTCDay()` numbers them. */
|
|
11
|
+
const WEEKEND_DAY_LABELS = { 0: 'Sundays', 6: 'Saturdays' };
|
|
12
|
+
const DEFAULT_OPTION_ID = 'default';
|
|
13
|
+
/** "Select Schedule or create new" picker plus a collapsible preview of the selected schedule's rules. */
|
|
14
|
+
export const SchedulePicker = ({ businessCalendar }) => {
|
|
15
|
+
var _a;
|
|
16
|
+
const { holidayCatalog, holidayCatalogError, holidayCatalogLoading, onDeleteSchedule, onSelectSchedule, schedules, schedulesError, schedulesLoading, selectedScheduleId, } = businessCalendar;
|
|
17
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
18
|
+
const [editingSchedule, setEditingSchedule] = useState();
|
|
19
|
+
const [previewOpen, setPreviewOpen] = useState(true);
|
|
20
|
+
const [deleting, setDeleting] = useState(false);
|
|
21
|
+
const [deleteError, setDeleteError] = useState();
|
|
22
|
+
const options = useMemo(() => (schedules !== null && schedules !== void 0 ? schedules : []).map(s => { var _a; return ({ id: (_a = s.id) !== null && _a !== void 0 ? _a : DEFAULT_OPTION_ID, label: s.name }); }), [schedules]);
|
|
23
|
+
const selectedOptionId = selectedScheduleId !== null && selectedScheduleId !== void 0 ? selectedScheduleId : DEFAULT_OPTION_ID;
|
|
24
|
+
const selectedOption = (_a = options.find(o => o.id === selectedOptionId)) !== null && _a !== void 0 ? _a : null;
|
|
25
|
+
const selectedSchedule = (schedules !== null && schedules !== void 0 ? schedules : []).find(s => { var _a; return ((_a = s.id) !== null && _a !== void 0 ? _a : DEFAULT_OPTION_ID) === selectedOptionId; });
|
|
26
|
+
const holidayByCode = useMemo(() => {
|
|
27
|
+
var _a;
|
|
28
|
+
const map = new Map();
|
|
29
|
+
for (const h of (_a = holidayCatalog === null || holidayCatalog === void 0 ? void 0 : holidayCatalog.holidays) !== null && _a !== void 0 ? _a : []) {
|
|
30
|
+
map.set(h.code, h);
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}, [holidayCatalog]);
|
|
34
|
+
const handleSelectedOptionChange = (option) => {
|
|
35
|
+
if (!option) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
onSelectSchedule(option.id === DEFAULT_OPTION_ID ? undefined : option.id);
|
|
39
|
+
};
|
|
40
|
+
const handleSaved = (schedule) => {
|
|
41
|
+
onSelectSchedule(schedule.id);
|
|
42
|
+
setDrawerOpen(false);
|
|
43
|
+
setEditingSchedule(undefined);
|
|
44
|
+
};
|
|
45
|
+
const handleOpenCreate = () => {
|
|
46
|
+
setEditingSchedule(undefined);
|
|
47
|
+
setDrawerOpen(true);
|
|
48
|
+
};
|
|
49
|
+
const handleOpenEdit = () => {
|
|
50
|
+
if (!selectedSchedule) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setEditingSchedule(selectedSchedule);
|
|
54
|
+
setDrawerOpen(true);
|
|
55
|
+
};
|
|
56
|
+
const handleCloseDrawer = () => {
|
|
57
|
+
setDrawerOpen(false);
|
|
58
|
+
setEditingSchedule(undefined);
|
|
59
|
+
};
|
|
60
|
+
const handleDelete = async () => {
|
|
61
|
+
if (!(selectedSchedule === null || selectedSchedule === void 0 ? void 0 : selectedSchedule.id) || !onDeleteSchedule) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setDeleting(true);
|
|
65
|
+
setDeleteError(undefined);
|
|
66
|
+
try {
|
|
67
|
+
await onDeleteSchedule(selectedSchedule.id);
|
|
68
|
+
onSelectSchedule(undefined);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
setDeleteError(e instanceof Error ? e.message : 'Failed to delete schedule');
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
setDeleting(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return (_jsxs(Fragment, { children: [_jsx(SelectFieldSync, { label: "Select Schedule or create new", options: options, value: selectedOption, disableClearButton: true, disabled: schedulesLoading, onSelectedOptionChange: handleSelectedOptionChange, onAddNewItem: handleOpenCreate, addItemLabel: "Create new schedule", filter: { keys: ['label'] } }), schedulesLoading && _jsx(Text, { variant: "body", children: "Loading schedules\u2026" }), schedulesError && _jsx(Alert, { status: "danger", title: schedulesError }), selectedSchedule && (_jsxs("div", { className: "dte-business-calendar-preview", children: [_jsxs(Flex, { alignItems: "center", justifyContent: "space-between", gap: "2", className: "dte-business-calendar-preview-header", children: [_jsxs(Flex, { alignItems: "center", gap: "2", onClick: () => setPreviewOpen(prev => !prev), children: [_jsx(Button, { appearance: "ghost", size: "small", icon: previewOpen ? IconExpandLess : IconExpandMore, "aria-label": previewOpen
|
|
78
|
+
? 'Collapse schedule preview'
|
|
79
|
+
: 'Expand schedule preview' }), _jsx(Text, { variant: "body", children: _jsx("strong", { children: selectedSchedule.name }) })] }), !selectedSchedule.isDefault && (_jsxs(Flex, { alignItems: "center", gap: "2", children: [_jsx(Button, { icon: EditIcon, size: "small", appearance: "ghost", onClick: handleOpenEdit, disabled: deleting, children: "Edit" }), _jsx(Button, { icon: DeleteIcon, size: "small", appearance: "ghost", "aria-label": "Delete schedule", onClick: handleDelete, loading: deleting, disabled: !onDeleteSchedule })] }))] }), deleteError && _jsx(Alert, { status: "danger", title: deleteError }), previewOpen && (_jsxs(Flex, { direction: "column", gap: "3", className: "dte-business-calendar-preview-content", children: [_jsxs(Flex, { direction: "column", gap: "2", children: [_jsx(Text, { variant: "eyebrow", size: "small", children: "Excluded Weekends" }), _jsx(Flex, { gap: "2", style: { flexWrap: 'wrap' }, children: selectedSchedule.calendar.weekendDays.map(day => {
|
|
80
|
+
var _a;
|
|
81
|
+
return (_jsx(Chip, { label: (_a = WEEKEND_DAY_LABELS[day]) !== null && _a !== void 0 ? _a : String(day), size: "medium" }, day));
|
|
82
|
+
}) })] }), _jsxs(Flex, { direction: "column", gap: "2", children: [_jsx(Text, { variant: "eyebrow", size: "small", children: "Excluded Holidays" }), holidayCatalogLoading && (_jsx(Text, { variant: "body", children: "Loading holidays\u2026" })), holidayCatalogError && (_jsx(Alert, { status: "danger", title: holidayCatalogError })), _jsx(Flex, { gap: "2", style: { flexWrap: 'wrap' }, children: selectedSchedule.calendar.knownHolidays.map(code => {
|
|
83
|
+
const holiday = holidayByCode.get(code);
|
|
84
|
+
return (_jsx(Chip, { label: holiday
|
|
85
|
+
? `${holiday.displayName} · ${holiday.rule}`
|
|
86
|
+
: code, size: "medium" }, code));
|
|
87
|
+
}) })] }), selectedSchedule.calendar.customDates.length > 0 && (_jsxs(Flex, { direction: "column", gap: "2", children: [_jsx(Text, { variant: "eyebrow", size: "small", children: "Custom Dates" }), _jsx(Flex, { gap: "2", style: { flexWrap: 'wrap' }, children: selectedSchedule.calendar.customDates.map(date => (_jsx(Chip, { label: formatCustomDateLabel(date), size: "medium" }, date))) })] }))] }))] })), drawerOpen && (_jsx(ScheduleDrawer, { businessCalendar: businessCalendar, editingSchedule: editingSchedule, onClose: handleCloseDrawer, onSaved: handleSaved }))] }));
|
|
88
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** DTE's `SaveDateScheduleRequest.Name` limit. */
|
|
2
|
+
export declare const MAX_SCHEDULE_NAME_LENGTH = 255;
|
|
3
|
+
/** The read-only default schedule's name; a tenant schedule may not reuse it (case-insensitive). */
|
|
4
|
+
export declare const RESERVED_SCHEDULE_NAME = "Default";
|
|
5
|
+
/** `MM-DD`, 01-12 / 01-31, as DTE's `CalendarSpecModel.customDates` (`02-29` is a valid leap day). */
|
|
6
|
+
export declare const CUSTOM_DATE_PATTERN: RegExp;
|
|
7
|
+
/** Days per month for an `MM-DD` custom date, Feb including the leap day. */
|
|
8
|
+
export declare const CUSTOM_DATE_MONTH_LENGTHS: number[];
|
|
9
|
+
/** The default schedule's weekend: Sunday and Saturday, DTE's `Date.getUTCDay()` numbering. */
|
|
10
|
+
export declare const DEFAULT_SCHEDULE_WEEKEND_DAYS: [0, 6];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** DTE's `SaveDateScheduleRequest.Name` limit. */
|
|
2
|
+
export const MAX_SCHEDULE_NAME_LENGTH = 255;
|
|
3
|
+
/** The read-only default schedule's name; a tenant schedule may not reuse it (case-insensitive). */
|
|
4
|
+
export const RESERVED_SCHEDULE_NAME = 'Default';
|
|
5
|
+
/** `MM-DD`, 01-12 / 01-31, as DTE's `CalendarSpecModel.customDates` (`02-29` is a valid leap day). */
|
|
6
|
+
export const CUSTOM_DATE_PATTERN = /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
7
|
+
/** Days per month for an `MM-DD` custom date, Feb including the leap day. */
|
|
8
|
+
export const CUSTOM_DATE_MONTH_LENGTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
9
|
+
/** The default schedule's weekend: Sunday and Saturday, DTE's `Date.getUTCDay()` numbering. */
|
|
10
|
+
export const DEFAULT_SCHEDULE_WEEKEND_DAYS = [0, 6];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { BusinessCalendarSection } from './components/business-calendar-section';
|
|
2
|
+
export { SchedulePicker } from './components/schedule-picker';
|
|
3
|
+
export { ScheduleDrawer } from './components/schedule-drawer';
|
|
4
|
+
export type * from './types';
|
|
5
|
+
export * from './constants';
|
|
6
|
+
export * from './business-calendar-validation.utils';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { BusinessCalendarSection } from './components/business-calendar-section';
|
|
2
|
+
export { SchedulePicker } from './components/schedule-picker';
|
|
3
|
+
export { ScheduleDrawer } from './components/schedule-drawer';
|
|
4
|
+
export * from './constants';
|
|
5
|
+
export * from './business-calendar-validation.utils';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.dte-business-calendar-section {
|
|
2
|
+
margin-top: var(--spacing-2);
|
|
3
|
+
padding-top: var(--spacing-2);
|
|
4
|
+
border-top: 1px solid var(--border-color);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.dte-business-calendar-preview {
|
|
8
|
+
padding: var(--spacing-2);
|
|
9
|
+
border: 1px solid var(--border-color);
|
|
10
|
+
border-radius: 8px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.dte-business-calendar-preview-header {
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dte-business-calendar-preview-content {
|
|
18
|
+
margin-top: var(--spacing-2);
|
|
19
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** A weekend day excluded by a business-day schedule, as `Date.getUTCDay()` numbers them. */
|
|
2
|
+
export type WeekendDay = 0 | 6;
|
|
3
|
+
/** A known holiday a tenant can exclude, from DTE's catalog (`GET api/holidays/catalog`). */
|
|
4
|
+
export interface KnownHoliday {
|
|
5
|
+
/** Stable code stored in a schedule's `knownHolidays`, e.g. `JULY4`. */
|
|
6
|
+
code: string;
|
|
7
|
+
/** Name to show, e.g. "Independence Day". */
|
|
8
|
+
displayName: string;
|
|
9
|
+
/** When it falls, e.g. "July 4" or "4th Thursday of November". */
|
|
10
|
+
rule: string;
|
|
11
|
+
}
|
|
12
|
+
/** The known holidays a tenant can exclude, for the schedule editor's checklist. */
|
|
13
|
+
export interface HolidayCatalog {
|
|
14
|
+
/** The jurisdiction the holidays belong to, e.g. "US" or a US state code. */
|
|
15
|
+
jurisdiction: string;
|
|
16
|
+
holidays: KnownHoliday[];
|
|
17
|
+
}
|
|
18
|
+
/** The rules of a business-day schedule: which days a date calculation skips. */
|
|
19
|
+
export interface BusinessCalendarRules {
|
|
20
|
+
weekendDays: WeekendDay[];
|
|
21
|
+
/** Codes of excluded holidays from the jurisdiction's catalog (`KnownHoliday.code`). */
|
|
22
|
+
knownHolidays: string[];
|
|
23
|
+
/** Extra excluded days as `MM-DD`, repeating every year, e.g. `12-24`. */
|
|
24
|
+
customDates: string[];
|
|
25
|
+
}
|
|
26
|
+
/** A schedule a tenant can pick for a business-day calculation (DTE's `GET api/holidays/schedules`). */
|
|
27
|
+
export interface BusinessDaySchedule {
|
|
28
|
+
/** Identifier of a tenant schedule; undefined for the read-only default schedule. */
|
|
29
|
+
id?: number;
|
|
30
|
+
name: string;
|
|
31
|
+
/** Whether this is the read-only default schedule of the tenant's jurisdiction. */
|
|
32
|
+
isDefault: boolean;
|
|
33
|
+
calendar: BusinessCalendarRules;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Request to create a tenant business-day schedule (DTE's `POST api/holidays/schedules`). Always
|
|
37
|
+
* creates a new schedule — editing a selected one does not overwrite it.
|
|
38
|
+
*/
|
|
39
|
+
export interface SaveScheduleRequest extends BusinessCalendarRules {
|
|
40
|
+
/** Display name, unique per tenant ignoring case, max 255 characters, and not "Default". */
|
|
41
|
+
name: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Prop bundle for the business-calendar toggle/picker/drawer, built by a host package (currently
|
|
45
|
+
* `@servicetitan/dte-pdf-editor` and `@servicetitan/dte-unlayer`) from whatever it uses to track
|
|
46
|
+
* which of its own fields have the toggle on and which schedule each uses — this package has no
|
|
47
|
+
* opinion on that part, only on the schedule CRUD and the UI built from it.
|
|
48
|
+
*/
|
|
49
|
+
export interface ScheduleEditorContext {
|
|
50
|
+
/** Whether the current field's custom business-day exclusion toggle is on. */
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
/** The current field's bound schedule id, or undefined for the default schedule. */
|
|
53
|
+
selectedScheduleId?: number;
|
|
54
|
+
holidayCatalog?: HolidayCatalog;
|
|
55
|
+
holidayCatalogLoading?: boolean;
|
|
56
|
+
holidayCatalogError?: string;
|
|
57
|
+
schedules?: BusinessDaySchedule[];
|
|
58
|
+
schedulesLoading?: boolean;
|
|
59
|
+
schedulesError?: string;
|
|
60
|
+
onToggle(enabled: boolean): void;
|
|
61
|
+
onSelectSchedule(scheduleId: number | undefined): void;
|
|
62
|
+
onCreateSchedule?(request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
|
|
63
|
+
/** Replaces a tenant schedule's name and rules in place (DTE's `PUT api/holidays/schedules/{id}`). */
|
|
64
|
+
onUpdateSchedule?(id: number, request: SaveScheduleRequest): Promise<BusinessDaySchedule>;
|
|
65
|
+
/** Permanently deletes a tenant schedule; fields bound to it fall back to the default schedule. */
|
|
66
|
+
onDeleteSchedule?(id: number): Promise<void>;
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@servicetitan/dte-pdf-editor",
|
|
3
|
-
"version": "1.96.
|
|
3
|
+
"version": "1.96.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"typings": "./dist/index.d.ts",
|
|
@@ -13,17 +13,19 @@
|
|
|
13
13
|
"registry": "https://registry.npmjs.org"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "tsc --build && startup prepare-package"
|
|
16
|
+
"build": "npm run build --prefix ../shared && tsc --build && startup prepare-package && node scripts/bundle-shared.mjs"
|
|
17
17
|
},
|
|
18
18
|
"cli": {
|
|
19
19
|
"webpack": false
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@servicetitan/dte-shared": "^0.0.1",
|
|
23
22
|
"pdfjs-dist": "^5.4.530",
|
|
24
23
|
"react-pdf": "^10.3.0",
|
|
25
24
|
"uuid": "^9.0.0"
|
|
26
25
|
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@servicetitan/dte-shared": "^0.0.1"
|
|
28
|
+
},
|
|
27
29
|
"peerDependencies": {
|
|
28
30
|
"@servicetitan/anvil2": "^3.7.1",
|
|
29
31
|
"react": "~18.3.1",
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The schedule UI is offered only to a host that passes `onScheduleBindingsChange`. The overlay
|
|
3
|
+
* decides that, and every place that renders the schedule UI checks the context it builds, so the
|
|
4
|
+
* panel below is replaced by a stand-in that records what the overlay gives it.
|
|
5
|
+
*/
|
|
6
|
+
jest.mock('../field-config-panel', () => ({ FieldConfigPanel: jest.fn(() => null) }));
|
|
7
|
+
// A Proxy is what `.svg` imports become under test; it is not a component.
|
|
8
|
+
jest.mock('@servicetitan/anvil2/assets/icons/material/round/close.svg', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: () => null,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
14
|
+
|
|
15
|
+
// jsdom has no TextEncoder; anvil2 reads it when it is loaded, so this must run before it is imported.
|
|
16
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
17
|
+
|
|
18
|
+
import { act, type ComponentProps } from 'react';
|
|
19
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
20
|
+
import { type FieldScheduleBinding, type PdfField } from '../../../interface/types';
|
|
21
|
+
import { FieldConfigPanel } from '../field-config-panel';
|
|
22
|
+
import { FieldConfigPanelOverlay } from '../field-config-panel-overlay';
|
|
23
|
+
|
|
24
|
+
const panel = FieldConfigPanel as unknown as jest.Mock;
|
|
25
|
+
const lastBusinessCalendar = () =>
|
|
26
|
+
panel.mock.calls[panel.mock.calls.length - 1][0].businessCalendar;
|
|
27
|
+
|
|
28
|
+
const defaultSchedule = {
|
|
29
|
+
name: 'Default',
|
|
30
|
+
isDefault: true,
|
|
31
|
+
calendar: { weekendDays: [0, 6] as (0 | 6)[], knownHolidays: [], customDates: [] },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('FieldConfigPanelOverlay', () => {
|
|
35
|
+
let container: HTMLDivElement;
|
|
36
|
+
let root: Root;
|
|
37
|
+
|
|
38
|
+
// Async, so the state anvil2 sets right after mounting is flushed inside `act`.
|
|
39
|
+
const render = (props: Partial<ComponentProps<typeof FieldConfigPanelOverlay>> = {}) =>
|
|
40
|
+
act(async () => {
|
|
41
|
+
root.render(
|
|
42
|
+
<FieldConfigPanelOverlay
|
|
43
|
+
selectedField={{ id: 'f1', type: 'calculated' } as PdfField}
|
|
44
|
+
recipients={[]}
|
|
45
|
+
forms={[]}
|
|
46
|
+
formFieldsByFormId={{}}
|
|
47
|
+
onFieldConfigChange={() => {}}
|
|
48
|
+
onDeleteField={() => {}}
|
|
49
|
+
onRequestFormFields={() => {}}
|
|
50
|
+
{...props}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
panel.mockClear();
|
|
61
|
+
container = document.createElement('div');
|
|
62
|
+
document.body.appendChild(container);
|
|
63
|
+
root = createRoot(container);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await act(async () => root.unmount());
|
|
68
|
+
container.remove();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('without onScheduleBindingsChange', () => {
|
|
72
|
+
test('gives the panel no schedule context', async () => {
|
|
73
|
+
await render();
|
|
74
|
+
|
|
75
|
+
expect(lastBusinessCalendar()).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('gives the panel no schedule context although the host passed schedules', async () => {
|
|
79
|
+
await render({
|
|
80
|
+
scheduleBindings: [{ fieldKey: 'f1' }],
|
|
81
|
+
schedules: [defaultSchedule],
|
|
82
|
+
holidayCatalog: { jurisdiction: 'US', holidays: [] },
|
|
83
|
+
onCreateSchedule: jest.fn(),
|
|
84
|
+
onUpdateSchedule: jest.fn(),
|
|
85
|
+
onDeleteSchedule: jest.fn(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(lastBusinessCalendar()).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('adds the schedule context when the host starts passing the handler', async () => {
|
|
92
|
+
await render();
|
|
93
|
+
expect(lastBusinessCalendar()).toBeUndefined();
|
|
94
|
+
|
|
95
|
+
await render({ onScheduleBindingsChange: jest.fn() });
|
|
96
|
+
|
|
97
|
+
expect(lastBusinessCalendar()).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('removes the schedule context when the host stops passing the handler', async () => {
|
|
101
|
+
await render({ onScheduleBindingsChange: jest.fn() });
|
|
102
|
+
expect(lastBusinessCalendar()).toBeDefined();
|
|
103
|
+
|
|
104
|
+
await render({ onScheduleBindingsChange: undefined });
|
|
105
|
+
|
|
106
|
+
expect(lastBusinessCalendar()).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('with onScheduleBindingsChange', () => {
|
|
111
|
+
let onScheduleBindingsChange: jest.Mock;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
onScheduleBindingsChange = jest.fn();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('gives the panel a schedule context with the switch off for a field without a binding', async () => {
|
|
118
|
+
await render({ onScheduleBindingsChange, scheduleBindings: [] });
|
|
119
|
+
|
|
120
|
+
expect(lastBusinessCalendar()).toMatchObject({ enabled: false });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('gives the panel the schedule of the field and what the host loaded', async () => {
|
|
124
|
+
const schedules = [defaultSchedule];
|
|
125
|
+
const holidayCatalog = { jurisdiction: 'US', holidays: [] };
|
|
126
|
+
const onCreateSchedule = jest.fn();
|
|
127
|
+
await render({
|
|
128
|
+
onScheduleBindingsChange,
|
|
129
|
+
scheduleBindings: [{ fieldKey: 'f1', scheduleId: 3 }],
|
|
130
|
+
schedules,
|
|
131
|
+
holidayCatalog,
|
|
132
|
+
onCreateSchedule,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(lastBusinessCalendar()).toMatchObject({
|
|
136
|
+
enabled: true,
|
|
137
|
+
selectedScheduleId: 3,
|
|
138
|
+
schedules,
|
|
139
|
+
holidayCatalog,
|
|
140
|
+
onCreateSchedule,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('follows the binding of the field that is selected next', async () => {
|
|
145
|
+
const scheduleBindings = [{ fieldKey: 'f2', scheduleId: 3 }];
|
|
146
|
+
await render({ onScheduleBindingsChange, scheduleBindings });
|
|
147
|
+
expect(lastBusinessCalendar()).toMatchObject({ enabled: false });
|
|
148
|
+
|
|
149
|
+
await render({
|
|
150
|
+
onScheduleBindingsChange,
|
|
151
|
+
scheduleBindings,
|
|
152
|
+
selectedField: { id: 'f2', type: 'calculated' } as PdfField,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(lastBusinessCalendar()).toMatchObject({ enabled: true, selectedScheduleId: 3 });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('turns the switch on by sending the whole list with the field added', async () => {
|
|
159
|
+
const others: FieldScheduleBinding[] = [{ fieldKey: 'other', scheduleId: 9 }];
|
|
160
|
+
await render({ onScheduleBindingsChange, scheduleBindings: others });
|
|
161
|
+
|
|
162
|
+
lastBusinessCalendar().onToggle(true);
|
|
163
|
+
|
|
164
|
+
expect(onScheduleBindingsChange).toHaveBeenCalledWith([
|
|
165
|
+
{ fieldKey: 'other', scheduleId: 9 },
|
|
166
|
+
{ fieldKey: 'f1' },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('picks a schedule by sending the whole list with the field changed', async () => {
|
|
171
|
+
await render({
|
|
172
|
+
onScheduleBindingsChange,
|
|
173
|
+
scheduleBindings: [{ fieldKey: 'f1' }, { fieldKey: 'other', scheduleId: 9 }],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
lastBusinessCalendar().onSelectSchedule(4);
|
|
177
|
+
|
|
178
|
+
expect(onScheduleBindingsChange).toHaveBeenCalledWith([
|
|
179
|
+
{ fieldKey: 'other', scheduleId: 9 },
|
|
180
|
+
{ fieldKey: 'f1', scheduleId: 4 },
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The overlay gives the panel no schedule context when the host cannot save bindings. This checks
|
|
3
|
+
* the other half: the places that draw the schedule switch show it only with a context.
|
|
4
|
+
*/
|
|
5
|
+
import { TextDecoder, TextEncoder } from 'util';
|
|
6
|
+
|
|
7
|
+
// jsdom has no TextEncoder; anvil2 reads it when it is loaded, so this must run before it is imported.
|
|
8
|
+
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
|
9
|
+
|
|
10
|
+
import { act, useState } from 'react';
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
12
|
+
import {
|
|
13
|
+
type CalculatedFieldFormat,
|
|
14
|
+
ESignFieldType,
|
|
15
|
+
FieldTypeEnum,
|
|
16
|
+
type PdfField,
|
|
17
|
+
} from '../../../interface/types';
|
|
18
|
+
import { AdvancedSettings } from '../advanced-settings';
|
|
19
|
+
import { ESignOptionsModal } from '../e-sign-options-modal';
|
|
20
|
+
import { FieldConfigPanel } from '../field-config-panel';
|
|
21
|
+
|
|
22
|
+
// jsdom has no matchMedia, ResizeObserver, IntersectionObserver or popover API; anvil2 uses them.
|
|
23
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
24
|
+
writable: true,
|
|
25
|
+
value: (query: string) => ({
|
|
26
|
+
matches: false,
|
|
27
|
+
media: query,
|
|
28
|
+
onchange: null,
|
|
29
|
+
addListener: () => {},
|
|
30
|
+
removeListener: () => {},
|
|
31
|
+
addEventListener: () => {},
|
|
32
|
+
removeEventListener: () => {},
|
|
33
|
+
dispatchEvent: () => false,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
class ObserverMock {
|
|
37
|
+
observe() {}
|
|
38
|
+
unobserve() {}
|
|
39
|
+
disconnect() {}
|
|
40
|
+
}
|
|
41
|
+
global.ResizeObserver = ObserverMock as unknown as typeof ResizeObserver;
|
|
42
|
+
global.IntersectionObserver = ObserverMock as unknown as typeof IntersectionObserver;
|
|
43
|
+
HTMLElement.prototype.showPopover ??= () => {};
|
|
44
|
+
HTMLElement.prototype.hidePopover ??= () => {};
|
|
45
|
+
HTMLElement.prototype.togglePopover ??= () => false;
|
|
46
|
+
|
|
47
|
+
const SWITCH_LABEL = 'Exclude weekends & holidays from the calculation';
|
|
48
|
+
const noOp = () => {};
|
|
49
|
+
const businessCalendar = { enabled: false, onToggle: noOp, onSelectSchedule: noOp };
|
|
50
|
+
const dateFormat: CalculatedFieldFormat = { resultType: 'date', dateFormat: 'MM/DD/YYYY' };
|
|
51
|
+
const numberFormat: CalculatedFieldFormat = {
|
|
52
|
+
resultType: 'number',
|
|
53
|
+
thousandsSeparator: false,
|
|
54
|
+
decimals: 2,
|
|
55
|
+
roundingMode: 'round',
|
|
56
|
+
decimalSeparatorEnabled: false,
|
|
57
|
+
decimalSeparator: '.',
|
|
58
|
+
prefixText: '',
|
|
59
|
+
postfixText: '',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const AdvancedSettingsSubject = ({
|
|
63
|
+
format,
|
|
64
|
+
withSchedules,
|
|
65
|
+
}: {
|
|
66
|
+
format: CalculatedFieldFormat;
|
|
67
|
+
withSchedules: boolean;
|
|
68
|
+
}) => {
|
|
69
|
+
const [current, setCurrent] = useState(format);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<AdvancedSettings
|
|
73
|
+
format={current}
|
|
74
|
+
setFormat={setCurrent}
|
|
75
|
+
businessCalendar={withSchedules ? businessCalendar : undefined}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe('the schedule switch', () => {
|
|
81
|
+
let container: HTMLDivElement;
|
|
82
|
+
let root: Root;
|
|
83
|
+
|
|
84
|
+
const render = (element: JSX.Element) =>
|
|
85
|
+
act(async () => {
|
|
86
|
+
root.render(element);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeAll(() => {
|
|
90
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
container = document.createElement('div');
|
|
95
|
+
document.body.appendChild(container);
|
|
96
|
+
root = createRoot(container);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(async () => {
|
|
100
|
+
await act(async () => root.unmount());
|
|
101
|
+
container.remove();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('in the advanced settings of a calculated field', () => {
|
|
105
|
+
test('is shown for a date field with a schedule context', async () => {
|
|
106
|
+
await render(<AdvancedSettingsSubject format={dateFormat} withSchedules />);
|
|
107
|
+
|
|
108
|
+
expect(container.textContent).toContain('Date format');
|
|
109
|
+
expect(container.textContent).toContain(SWITCH_LABEL);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('is not shown for a date field without a schedule context', async () => {
|
|
113
|
+
await render(<AdvancedSettingsSubject format={dateFormat} withSchedules={false} />);
|
|
114
|
+
|
|
115
|
+
expect(container.textContent).toContain('Date format');
|
|
116
|
+
expect(container.textContent).not.toContain(SWITCH_LABEL);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('is not shown for a number field', async () => {
|
|
120
|
+
await render(<AdvancedSettingsSubject format={numberFormat} withSchedules />);
|
|
121
|
+
|
|
122
|
+
expect(container.textContent).not.toContain(SWITCH_LABEL);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('in the options of an e-sign date signed offset', () => {
|
|
127
|
+
const renderOptions = (withSchedules: boolean) =>
|
|
128
|
+
render(
|
|
129
|
+
<ESignOptionsModal
|
|
130
|
+
initialState={{ rules: [{ days: 3 }] }}
|
|
131
|
+
onSave={() => {}}
|
|
132
|
+
businessCalendar={withSchedules ? businessCalendar : undefined}
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
test('is shown with a schedule context', async () => {
|
|
137
|
+
await renderOptions(true);
|
|
138
|
+
|
|
139
|
+
expect(container.textContent).toContain(SWITCH_LABEL);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('is not shown without a schedule context', async () => {
|
|
143
|
+
await renderOptions(false);
|
|
144
|
+
|
|
145
|
+
expect(container.textContent).not.toContain(SWITCH_LABEL);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('in the config panel of an e-sign date signed offset field', () => {
|
|
150
|
+
const field = {
|
|
151
|
+
id: 'e1',
|
|
152
|
+
type: FieldTypeEnum.eSign,
|
|
153
|
+
subType: ESignFieldType.dateSignedOffset,
|
|
154
|
+
recipient: 'customer',
|
|
155
|
+
path: 'esign.customer.dateSignedOffset',
|
|
156
|
+
data: { rules: [{ days: 3 }] },
|
|
157
|
+
} as unknown as PdfField;
|
|
158
|
+
const onFieldConfigChange = jest.fn();
|
|
159
|
+
|
|
160
|
+
const renderPanel = (context?: typeof businessCalendar) =>
|
|
161
|
+
render(
|
|
162
|
+
<FieldConfigPanel
|
|
163
|
+
field={field}
|
|
164
|
+
recipients={[{ id: 1, name: 'customer', displayName: 'Customer' }]}
|
|
165
|
+
forms={[]}
|
|
166
|
+
hideConditionalLogic
|
|
167
|
+
businessCalendar={context}
|
|
168
|
+
onDeleteField={noOp}
|
|
169
|
+
onFieldConfigChange={onFieldConfigChange}
|
|
170
|
+
onRequestFormFields={noOp}
|
|
171
|
+
/>,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const findSwitch = () =>
|
|
175
|
+
[...container.querySelectorAll('input')].find(input =>
|
|
176
|
+
input.closest('label')?.textContent?.includes(SWITCH_LABEL),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
beforeEach(() => onFieldConfigChange.mockClear());
|
|
180
|
+
|
|
181
|
+
test('is not shown without a schedule context', async () => {
|
|
182
|
+
await renderPanel();
|
|
183
|
+
|
|
184
|
+
expect(container.textContent).toContain('Number of days');
|
|
185
|
+
expect(container.textContent).not.toContain(SWITCH_LABEL);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('is shown with a schedule context', async () => {
|
|
189
|
+
await renderPanel(businessCalendar);
|
|
190
|
+
|
|
191
|
+
expect(findSwitch()).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('stores the choice in the field data and does not call the context', async () => {
|
|
195
|
+
const onToggle = jest.fn();
|
|
196
|
+
await renderPanel({ ...businessCalendar, onToggle });
|
|
197
|
+
|
|
198
|
+
await act(async () => findSwitch()?.click());
|
|
199
|
+
|
|
200
|
+
expect(onFieldConfigChange).toHaveBeenCalledWith({
|
|
201
|
+
data: { rules: [{ days: 3 }], businessCalendarEnabled: true },
|
|
202
|
+
});
|
|
203
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|