@antelopejs/dms 0.7.14 → 0.7.16
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/CHANGELOG.md +42 -0
- package/dist/db/models/userNotifications.model.js +3 -27
- package/dist/db/models/userNotifications.model.js.map +1 -1
- package/dist/implementations/dms/frontend-modules.d.ts +4 -2
- package/dist/implementations/dms/frontend-modules.js +4 -0
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
- package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
- package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +1 -0
- package/dist/implementations/dms/manifest.js +3 -1
- package/dist/implementations/dms/manifest.js.map +1 -1
- package/dist/test/integration/notifications.test.js +6 -3
- package/dist/test/integration/notifications.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js +16 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +19 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +52 -28
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +48 -4
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +6 -2
- package/frontend-vue/layers/dms-layout/app/composables/layout/usePageHeaderActions.ts +32 -1
- package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
- package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +49 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +34 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +47 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +32 -6
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +75 -6
- package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
- package/frontend-vue/tests/control-error-path.test.ts +11 -0
- package/frontend-vue/tests/email-layout.test.ts +124 -0
- package/frontend-vue/tests/header-action-page-query.test.ts +96 -1
- package/frontend-vue/tests/input-number-range.test.ts +1 -1
- package/frontend-vue/tests/input-tags.test.ts +1 -1
- package/frontend-vue/tests/period-day-rollover.test.ts +80 -0
- package/frontend-vue/tests/period-max-days.test.ts +128 -0
- package/frontend-vue/tests/period-selector-url.test.ts +44 -8
- package/frontend-vue/tests/row-side-panel.test.ts +2 -2
- package/frontend-vue/tests/table-url-filters.test.ts +158 -0
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
- package/frontend-vue/tests/table-view-url-tab.test.ts +115 -0
- package/package.json +2 -2
- package/skills/dms-pages/REFERENCE.md +6 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { usePeriod } from "../layers/dms-core/app/composables/period/usePeriod";
|
|
4
|
+
import {
|
|
5
|
+
isCalendarPreset,
|
|
6
|
+
msUntilNextMidnight,
|
|
7
|
+
} from "../layers/dms-core/app/composables/period/presetResolvers";
|
|
8
|
+
|
|
9
|
+
const MS_PER_MINUTE = 60000;
|
|
10
|
+
const MS_PER_DAY = 24 * 60 * MS_PER_MINUTE;
|
|
11
|
+
const LATE_EVENING = new Date(2026, 9, 12, 23, 58);
|
|
12
|
+
const NEXT_DAY = new Date(2026, 9, 13);
|
|
13
|
+
|
|
14
|
+
describe("calendar presets at midnight", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
vi.setSystemTime(LATE_EVENING);
|
|
18
|
+
vi.stubGlobal("window", globalThis);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
vi.unstubAllGlobals();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("classifies the day-bound presets as calendar presets", () => {
|
|
27
|
+
expect(isCalendarPreset("today")).toBe(true);
|
|
28
|
+
expect(isCalendarPreset("last-7-days")).toBe(true);
|
|
29
|
+
expect(isCalendarPreset("this-month")).toBe(true);
|
|
30
|
+
expect(isCalendarPreset("last-hour")).toBe(false);
|
|
31
|
+
expect(isCalendarPreset("custom")).toBe(false);
|
|
32
|
+
expect(isCalendarPreset("all")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("measures the time left until the next local midnight", () => {
|
|
36
|
+
expect(msUntilNextMidnight(LATE_EVENING)).toBe(2 * MS_PER_MINUTE);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("moves `today` onto the new day once midnight passes", () => {
|
|
40
|
+
const period = usePeriod({ defaultPreset: "today" });
|
|
41
|
+
const initialKey = period.state.value.key;
|
|
42
|
+
vi.advanceTimersByTime(MS_PER_MINUTE);
|
|
43
|
+
expect(period.state.value.key).toBe(initialKey);
|
|
44
|
+
vi.advanceTimersByTime(MS_PER_MINUTE);
|
|
45
|
+
expect(period.state.value.range?.from).toEqual(NEXT_DAY);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps moving on the following midnights", () => {
|
|
49
|
+
const period = usePeriod({ defaultPreset: "last-7-days" });
|
|
50
|
+
vi.advanceTimersByTime(2 * MS_PER_MINUTE);
|
|
51
|
+
expect(period.state.value.range?.to.getDate()).toBe(13);
|
|
52
|
+
vi.advanceTimersByTime(MS_PER_DAY);
|
|
53
|
+
expect(period.state.value.range?.to.getDate()).toBe(14);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("waits for the hold to end before moving", async () => {
|
|
57
|
+
const isHeld = ref(true);
|
|
58
|
+
const period = usePeriod({
|
|
59
|
+
defaultPreset: "today",
|
|
60
|
+
holdClock: () => isHeld.value,
|
|
61
|
+
});
|
|
62
|
+
const initialKey = period.state.value.key;
|
|
63
|
+
await vi.advanceTimersByTimeAsync(3 * MS_PER_MINUTE);
|
|
64
|
+
expect(period.state.value.key).toBe(initialKey);
|
|
65
|
+
isHeld.value = false;
|
|
66
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
67
|
+
expect(period.state.value.range?.from).toEqual(NEXT_DAY);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("leaves a custom range alone", async () => {
|
|
71
|
+
const period = usePeriod({ defaultPreset: "today" });
|
|
72
|
+
period.setCustomRange({
|
|
73
|
+
from: new Date(2026, 9, 1),
|
|
74
|
+
to: new Date(2026, 9, 2),
|
|
75
|
+
});
|
|
76
|
+
const initialKey = period.state.value.key;
|
|
77
|
+
await vi.advanceTimersByTimeAsync(3 * MS_PER_MINUTE);
|
|
78
|
+
expect(period.state.value.key).toBe(initialKey);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* `maxDays` caps the custom range a PeriodSelector lets the user pick: the
|
|
4
|
+
* calendar disables the days past it once a start day is picked, and a range
|
|
5
|
+
* from the date fields or a long preset is cut to it.
|
|
6
|
+
*/
|
|
7
|
+
import { CalendarDate } from "@internationalized/date";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
import PeriodRangeCalendar from "../layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue";
|
|
13
|
+
import {
|
|
14
|
+
calendarRangeDays,
|
|
15
|
+
isBeyondMaxDays,
|
|
16
|
+
limitCalendarRange,
|
|
17
|
+
} from "../layers/dms-ui/app/build/composables/period/periodDisplay";
|
|
18
|
+
|
|
19
|
+
const OCTOBER_FIRST = new CalendarDate(2026, 10, 1);
|
|
20
|
+
const OCTOBER_LAST = new CalendarDate(2026, 10, 31);
|
|
21
|
+
|
|
22
|
+
describe("limitCalendarRange", () => {
|
|
23
|
+
const october = { start: OCTOBER_FIRST, end: OCTOBER_LAST };
|
|
24
|
+
|
|
25
|
+
it("keeps a range within the limit", () => {
|
|
26
|
+
expect(limitCalendarRange(october, 31, "start")).toBe(october);
|
|
27
|
+
expect(limitCalendarRange(october, undefined, "start")).toBe(october);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("moves the end when the start is kept", () => {
|
|
31
|
+
const limited = limitCalendarRange(october, 7, "start");
|
|
32
|
+
expect(limited.start.toString()).toBe("2026-10-01");
|
|
33
|
+
expect(limited.end.toString()).toBe("2026-10-07");
|
|
34
|
+
expect(calendarRangeDays(limited)).toBe(7);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("moves the start when the end is kept", () => {
|
|
38
|
+
const limited = limitCalendarRange(october, 7, "end");
|
|
39
|
+
expect(limited.start.toString()).toBe("2026-10-25");
|
|
40
|
+
expect(limited.end.toString()).toBe("2026-10-31");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isBeyondMaxDays", () => {
|
|
45
|
+
it("allows exactly maxDays days on either side of the picked day", () => {
|
|
46
|
+
const picked = new CalendarDate(2026, 10, 10);
|
|
47
|
+
expect(isBeyondMaxDays(picked.add({ days: 6 }), picked, 7)).toBe(false);
|
|
48
|
+
expect(isBeyondMaxDays(picked.add({ days: 7 }), picked, 7)).toBe(true);
|
|
49
|
+
expect(isBeyondMaxDays(picked.subtract({ days: 6 }), picked, 7)).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
expect(isBeyondMaxDays(picked.subtract({ days: 7 }), picked, 7)).toBe(true);
|
|
53
|
+
expect(isBeyondMaxDays(picked.add({ days: 400 }), picked, undefined)).toBe(
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("the range calendar with maxDays", () => {
|
|
60
|
+
let app: App | undefined;
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
app?.unmount();
|
|
64
|
+
app = undefined;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function mountCalendar(maxDays?: number): HTMLElement {
|
|
68
|
+
const host = document.createElement("div");
|
|
69
|
+
document.body.appendChild(host);
|
|
70
|
+
app = createApp(
|
|
71
|
+
defineComponent({
|
|
72
|
+
setup: () => () =>
|
|
73
|
+
h(PeriodRangeCalendar, {
|
|
74
|
+
modelValue: {
|
|
75
|
+
start: new CalendarDate(2026, 9, 1),
|
|
76
|
+
end: new CalendarDate(2026, 9, 2),
|
|
77
|
+
},
|
|
78
|
+
locale: "en-US",
|
|
79
|
+
numberOfMonths: 1,
|
|
80
|
+
maxDays,
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
app.config.globalProperties.$t = (_key: string, fallback: string) =>
|
|
85
|
+
fallback;
|
|
86
|
+
app.component(
|
|
87
|
+
"UButton",
|
|
88
|
+
defineComponent({ setup: () => () => h("button") }),
|
|
89
|
+
);
|
|
90
|
+
app.mount(host);
|
|
91
|
+
return host;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function day(host: HTMLElement, value: string): HTMLElement {
|
|
95
|
+
const cell = host.querySelector<HTMLElement>(`[data-value="${value}"]`);
|
|
96
|
+
if (!cell) throw new Error(`No day ${value}`);
|
|
97
|
+
return cell;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it("disables the days past the limit once a start day is picked", async () => {
|
|
101
|
+
const host = mountCalendar(7);
|
|
102
|
+
expect(day(host, "2026-09-20").hasAttribute("data-disabled")).toBe(false);
|
|
103
|
+
day(host, "2026-09-10").click();
|
|
104
|
+
await nextTick();
|
|
105
|
+
expect(day(host, "2026-09-16").hasAttribute("data-disabled")).toBe(false);
|
|
106
|
+
expect(day(host, "2026-09-17").hasAttribute("data-disabled")).toBe(true);
|
|
107
|
+
expect(day(host, "2026-09-03").hasAttribute("data-disabled")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves every day enabled without a limit", async () => {
|
|
111
|
+
const host = mountCalendar();
|
|
112
|
+
day(host, "2026-09-10").click();
|
|
113
|
+
await nextTick();
|
|
114
|
+
expect(day(host, "2026-09-30").hasAttribute("data-disabled")).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("hands maxDays to the calendar and cuts the date fields to it", () => {
|
|
119
|
+
const source = readFileSync(
|
|
120
|
+
resolve(
|
|
121
|
+
__dirname,
|
|
122
|
+
"../layers/dms-ui/app/components/period/PeriodSelector.vue",
|
|
123
|
+
),
|
|
124
|
+
"utf8",
|
|
125
|
+
);
|
|
126
|
+
expect(source).toContain(':max-days="maxDays"');
|
|
127
|
+
expect(source).toContain("limitCalendarRange(range, props.maxDays, anchor)");
|
|
128
|
+
});
|
|
@@ -25,12 +25,13 @@ const preferences = new Map<string, unknown>();
|
|
|
25
25
|
vi.stubGlobal("useDmsRoute", () => route);
|
|
26
26
|
// Inertia's client-side replace moves its page URL, which the DMS route
|
|
27
27
|
// follows: the route then holds the query the browser shows.
|
|
28
|
+
function followInertiaPage({ url }: { url: string }): void {
|
|
29
|
+
route.query = Object.fromEntries(
|
|
30
|
+
new URL(url, "http://dms.local").searchParams,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
28
33
|
const { inertiaReplace } = vi.hoisted(() => ({
|
|
29
|
-
inertiaReplace: vi.fn(
|
|
30
|
-
route.query = Object.fromEntries(
|
|
31
|
-
new URL(url, "http://dms.local").searchParams,
|
|
32
|
-
);
|
|
33
|
-
}),
|
|
34
|
+
inertiaReplace: vi.fn(),
|
|
34
35
|
}));
|
|
35
36
|
vi.mock("@inertiajs/vue3", () => ({ router: { replace: inertiaReplace } }));
|
|
36
37
|
vi.stubGlobal("usePreferences", () => ({
|
|
@@ -116,13 +117,24 @@ function mount(...ids: string[]): void {
|
|
|
116
117
|
|
|
117
118
|
const search = () => new URLSearchParams(window.location.search);
|
|
118
119
|
|
|
120
|
+
/** A navigation from outside: the browser's URL moves, then the route. */
|
|
121
|
+
function navigate(query: Record<string, string>): void {
|
|
122
|
+
window.history.replaceState(
|
|
123
|
+
null,
|
|
124
|
+
"",
|
|
125
|
+
`${PAGE_PATH}?${new URLSearchParams(query)}`,
|
|
126
|
+
);
|
|
127
|
+
route.query = query;
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
const remember = (query: Record<string, string>) =>
|
|
120
131
|
preferences.set(PERIOD_PREFERENCE_KEY, { [`${PAGE_ID}#page`]: query });
|
|
121
132
|
|
|
122
133
|
beforeEach(() => {
|
|
123
134
|
route.query = {};
|
|
124
135
|
preferences.clear();
|
|
125
|
-
inertiaReplace.
|
|
136
|
+
inertiaReplace.mockReset();
|
|
137
|
+
inertiaReplace.mockImplementation(followInertiaPage);
|
|
126
138
|
window.history.replaceState(null, "", PAGE_PATH);
|
|
127
139
|
});
|
|
128
140
|
|
|
@@ -272,10 +284,34 @@ describe("a change of period", () => {
|
|
|
272
284
|
});
|
|
273
285
|
});
|
|
274
286
|
|
|
287
|
+
describe("the route while Inertia catches up", () => {
|
|
288
|
+
it("holds the new period at once", async () => {
|
|
289
|
+
inertiaReplace.mockImplementation(() => {});
|
|
290
|
+
mount("page");
|
|
291
|
+
handles.page!.setPreset("last-7-days");
|
|
292
|
+
await nextTick();
|
|
293
|
+
expect(route.query["page.period"]).toBe("last-7-days");
|
|
294
|
+
expect(search().get("page.period")).toBe("last-7-days");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("does not move the period back when it reports the old URL late", async () => {
|
|
298
|
+
inertiaReplace.mockImplementation(() => {});
|
|
299
|
+
mount("page");
|
|
300
|
+
await nextTick();
|
|
301
|
+
const before = { ...route.query };
|
|
302
|
+
handles.page!.setPreset("last-7-days");
|
|
303
|
+
await nextTick();
|
|
304
|
+
route.query = before;
|
|
305
|
+
await nextTick();
|
|
306
|
+
expect(handles.page!.preset.value).toBe("last-7-days");
|
|
307
|
+
expect(search().get("page.period")).toBe("last-7-days");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
275
311
|
describe("a route changed from outside", () => {
|
|
276
312
|
it("moves the period of the selector it names", async () => {
|
|
277
313
|
mount("sales", "traffic");
|
|
278
|
-
|
|
314
|
+
navigate({ "sales.period": "last-30-days" });
|
|
279
315
|
await nextTick();
|
|
280
316
|
expect(handles.sales!.preset.value).toBe("last-30-days");
|
|
281
317
|
expect(handles.traffic!.preset.value).toBe("this-month");
|
|
@@ -285,7 +321,7 @@ describe("a route changed from outside", () => {
|
|
|
285
321
|
mount("page");
|
|
286
322
|
handles.page!.setPreset("last-7-days");
|
|
287
323
|
await nextTick();
|
|
288
|
-
|
|
324
|
+
navigate({ "page.period": "last-decade" });
|
|
289
325
|
await nextTick();
|
|
290
326
|
expect(handles.page!.preset.value).toBe("last-7-days");
|
|
291
327
|
});
|
|
@@ -453,13 +453,13 @@ describe("deep link to a row the table does not list", () => {
|
|
|
453
453
|
).toEqual({ isMissing: true });
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
it("
|
|
456
|
+
it("marks a refused read as failed and finds nothing without a row route", async () => {
|
|
457
457
|
const refused = async () => {
|
|
458
458
|
throw Object.assign(new Error("Forbidden"), { status: 403 });
|
|
459
459
|
};
|
|
460
460
|
expect(
|
|
461
461
|
await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow: refused }),
|
|
462
|
-
).toEqual({ isMissing: false });
|
|
462
|
+
).toEqual({ isMissing: false, isFailed: true });
|
|
463
463
|
const noRoute = async () => undefined;
|
|
464
464
|
expect(
|
|
465
465
|
await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow: noRoute }),
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
createApp,
|
|
5
|
+
defineComponent,
|
|
6
|
+
h,
|
|
7
|
+
nextTick,
|
|
8
|
+
provide,
|
|
9
|
+
ref,
|
|
10
|
+
shallowRef,
|
|
11
|
+
type App,
|
|
12
|
+
type FunctionalComponent,
|
|
13
|
+
} from "vue";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import TableFiltersRow from "../layers/dms-ui/app/build/components/table/FiltersRow.vue";
|
|
16
|
+
import type { TableUrlFilter } from "../layers/dms-ui/app/build/components/table/Table.vue";
|
|
17
|
+
import {
|
|
18
|
+
visibleUrlFilters,
|
|
19
|
+
withoutQueryParam,
|
|
20
|
+
} from "../layers/dms-ui/app/build/composables/table-view/utils/urlFilters";
|
|
21
|
+
|
|
22
|
+
describe("visibleUrlFilters", () => {
|
|
23
|
+
const declared = {
|
|
24
|
+
email: { field: "email", visible: true, label: "$app.email" },
|
|
25
|
+
template: { field: "templateSlug", mode: "is_not", visible: true },
|
|
26
|
+
tenant: { field: "tenantId" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
it("lists the visible filters the URL applies, hidden ones left out", () => {
|
|
30
|
+
expect(
|
|
31
|
+
visibleUrlFilters(declared, {
|
|
32
|
+
email: "a@b.test",
|
|
33
|
+
tenant: "t1",
|
|
34
|
+
page: "2",
|
|
35
|
+
}),
|
|
36
|
+
).toEqual([
|
|
37
|
+
{
|
|
38
|
+
param: "email",
|
|
39
|
+
label: "$app.email",
|
|
40
|
+
accessorKey: "email",
|
|
41
|
+
mode: "is",
|
|
42
|
+
value: "a@b.test",
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
expect(visibleUrlFilters(declared, { template: "welcome" })).toEqual([
|
|
46
|
+
{
|
|
47
|
+
param: "template",
|
|
48
|
+
label: undefined,
|
|
49
|
+
accessorKey: "templateSlug",
|
|
50
|
+
mode: "is_not",
|
|
51
|
+
value: "welcome",
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
expect(visibleUrlFilters(undefined, { email: "a@b.test" })).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("removes one parameter from the query", () => {
|
|
58
|
+
expect(withoutQueryParam({ email: "a", tab: "all" }, "email")).toEqual({
|
|
59
|
+
tab: "all",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("filters row URL chips", () => {
|
|
65
|
+
let app: App | undefined;
|
|
66
|
+
const emits = vi.fn();
|
|
67
|
+
|
|
68
|
+
const mount = (urlFilters: TableUrlFilter[]) => {
|
|
69
|
+
const Host = defineComponent({
|
|
70
|
+
setup() {
|
|
71
|
+
provide(
|
|
72
|
+
"tableSharedData",
|
|
73
|
+
shallowRef({
|
|
74
|
+
columnFiltersState: ref([]),
|
|
75
|
+
labeledColumns: computed(() => [
|
|
76
|
+
{
|
|
77
|
+
column: { id: "templateSlug", columnDef: {} },
|
|
78
|
+
label: "Template",
|
|
79
|
+
},
|
|
80
|
+
]),
|
|
81
|
+
clearableFilters: computed(() => ({
|
|
82
|
+
filters: false,
|
|
83
|
+
search: false,
|
|
84
|
+
})),
|
|
85
|
+
urlFilters,
|
|
86
|
+
emits,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
return () => h(TableFiltersRow);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
app = createApp(Host);
|
|
93
|
+
const passthrough =
|
|
94
|
+
(tag: string): FunctionalComponent =>
|
|
95
|
+
(_props, { slots }) =>
|
|
96
|
+
h(tag, slots.default?.());
|
|
97
|
+
app.component("UPopover", passthrough("div"));
|
|
98
|
+
app.component("UButton", passthrough("button"));
|
|
99
|
+
app.component("UIcon", passthrough("i"));
|
|
100
|
+
app.component("Suspense", passthrough("div"));
|
|
101
|
+
const container = document.createElement("div");
|
|
102
|
+
app.mount(container);
|
|
103
|
+
return container;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
emits.mockClear();
|
|
108
|
+
vi.stubGlobal("computed", computed);
|
|
109
|
+
vi.stubGlobal("ref", ref);
|
|
110
|
+
vi.stubGlobal("useI18n", () => ({
|
|
111
|
+
t: (key: string) => key,
|
|
112
|
+
locale: ref("en"),
|
|
113
|
+
}));
|
|
114
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
115
|
+
processI18n: (text: string) =>
|
|
116
|
+
text.startsWith("$") ? `t:${text.slice(1)}` : text,
|
|
117
|
+
}));
|
|
118
|
+
vi.stubGlobal("useDmsAppConfig", () => ({
|
|
119
|
+
ui: { icons: { close: "i-close", plus: "i-plus" } },
|
|
120
|
+
}));
|
|
121
|
+
vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
|
|
122
|
+
vi.stubGlobal("isString", (value: unknown) => typeof value === "string");
|
|
123
|
+
vi.stubGlobal("resolveDmsComponent", () => undefined);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
app?.unmount();
|
|
128
|
+
app = undefined;
|
|
129
|
+
vi.unstubAllGlobals();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("draws a chip per URL filter and asks to remove its parameter", async () => {
|
|
133
|
+
const container = mount([
|
|
134
|
+
{
|
|
135
|
+
param: "email",
|
|
136
|
+
label: "$app.email",
|
|
137
|
+
accessorKey: "email",
|
|
138
|
+
mode: "is",
|
|
139
|
+
value: "a@b.test",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
param: "template",
|
|
143
|
+
accessorKey: "templateSlug",
|
|
144
|
+
mode: "is",
|
|
145
|
+
value: "welcome",
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
const chips = container.querySelectorAll("[data-dms-url-filter]");
|
|
149
|
+
expect(chips).toHaveLength(2);
|
|
150
|
+
expect(chips[0]!.textContent).toContain("t:app.email");
|
|
151
|
+
expect(chips[0]!.textContent).toContain("a@b.test");
|
|
152
|
+
expect(chips[1]!.textContent).toContain("Template");
|
|
153
|
+
|
|
154
|
+
chips[1]!.querySelector("button")!.click();
|
|
155
|
+
await nextTick();
|
|
156
|
+
expect(emits).toHaveBeenCalledWith("removeUrlFilter", "template");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
rowNavigation,
|
|
14
14
|
} from "../layers/dms-ui/app/build/composables/actions/rowNavigation";
|
|
15
15
|
import {
|
|
16
|
+
deepLinkOutcome,
|
|
16
17
|
readRecordId,
|
|
17
18
|
recordUrlKey,
|
|
18
19
|
writeRecordId,
|
|
@@ -130,6 +131,28 @@ describe("record deep links", () => {
|
|
|
130
131
|
writeRecordId({ tableId: "runs", isSoleTableView: true });
|
|
131
132
|
expect(window.location.search).toBe("?tab=open");
|
|
132
133
|
});
|
|
134
|
+
|
|
135
|
+
it("notifies a link no row answers, unless a side panel shows it missing", () => {
|
|
136
|
+
const panel = {
|
|
137
|
+
target: { type: "sidePanel" },
|
|
138
|
+
} as unknown as CustomRowAction;
|
|
139
|
+
const drawer = {
|
|
140
|
+
target: { type: "drawer" },
|
|
141
|
+
deepLink: true,
|
|
142
|
+
} as unknown as CustomRowAction;
|
|
143
|
+
const row = { id: "r1" };
|
|
144
|
+
expect(deepLinkOutcome({ row, isMissing: false }, drawer)).toBe("open");
|
|
145
|
+
expect(deepLinkOutcome({ isMissing: true }, panel)).toBe("missingPanel");
|
|
146
|
+
expect(deepLinkOutcome({ isMissing: true }, drawer)).toBe("notify");
|
|
147
|
+
expect(deepLinkOutcome({ isMissing: false }, panel)).toBe("notify");
|
|
148
|
+
expect(deepLinkOutcome({ isMissing: false }, drawer)).toBe("notify");
|
|
149
|
+
expect(deepLinkOutcome({ isMissing: false, isFailed: true }, panel)).toBe(
|
|
150
|
+
"notifyFailed",
|
|
151
|
+
);
|
|
152
|
+
expect(deepLinkOutcome({ isMissing: false, isFailed: true }, drawer)).toBe(
|
|
153
|
+
"notifyFailed",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
133
156
|
});
|
|
134
157
|
|
|
135
158
|
describe("action targets on a selection and on a listed row", () => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A table view opens the tab its URL names (`?<tableId>.tab=`, or `?tab=` on
|
|
4
|
+
* a page with a single table view), on arrival and afterwards: a link or a
|
|
5
|
+
* `router.replace` naming another tab switches to it in place, without
|
|
6
|
+
* remounting the page.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
readTableUrlTab,
|
|
14
|
+
useTableUrlTab,
|
|
15
|
+
} from "../layers/dms-ui/app/build/composables/table-view/useTableUrlTab";
|
|
16
|
+
import type { TableUrlScope } from "../layers/dms-ui/app/build/composables/table-view/utils/views";
|
|
17
|
+
|
|
18
|
+
const OPENABLE_TABS = new Set(["all", "active", "archived"]);
|
|
19
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
20
|
+
|
|
21
|
+
function mountTable(scope: TableUrlScope, query: Record<string, string>) {
|
|
22
|
+
const route = reactive({ query });
|
|
23
|
+
const activeTabId = ref(readTableUrlTab(route.query, scope) ?? "all");
|
|
24
|
+
const app = createApp(
|
|
25
|
+
defineComponent({
|
|
26
|
+
setup() {
|
|
27
|
+
useTableUrlTab({
|
|
28
|
+
route,
|
|
29
|
+
urlScope: scope,
|
|
30
|
+
activeTabId,
|
|
31
|
+
isOpenable: (tabId) => OPENABLE_TABS.has(tabId),
|
|
32
|
+
});
|
|
33
|
+
return () => h("div");
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
app.mount(document.createElement("div"));
|
|
38
|
+
mounted.push(app);
|
|
39
|
+
return { route, activeTabId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("the tab a URL names", () => {
|
|
47
|
+
it("opens on arrival", () => {
|
|
48
|
+
const { activeTabId } = mountTable(
|
|
49
|
+
{ tableId: "runs" },
|
|
50
|
+
{ "runs.tab": "active" },
|
|
51
|
+
);
|
|
52
|
+
expect(activeTabId.value).toBe("active");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("switches the tab when the prefixed key changes after mount", async () => {
|
|
56
|
+
const { route, activeTabId } = mountTable({ tableId: "runs" }, {});
|
|
57
|
+
route.query = { "runs.tab": "archived" };
|
|
58
|
+
await nextTick();
|
|
59
|
+
expect(activeTabId.value).toBe("archived");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("follows the short key on a page with a single table view", async () => {
|
|
63
|
+
const { route, activeTabId } = mountTable(
|
|
64
|
+
{ tableId: "runs", isSoleTableView: true },
|
|
65
|
+
{ tab: "active" },
|
|
66
|
+
);
|
|
67
|
+
expect(activeTabId.value).toBe("active");
|
|
68
|
+
route.query = { tab: "archived" };
|
|
69
|
+
await nextTick();
|
|
70
|
+
expect(activeTabId.value).toBe("archived");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("ignores the short key on a page with several table views", async () => {
|
|
74
|
+
const { route, activeTabId } = mountTable({ tableId: "runs" }, {});
|
|
75
|
+
route.query = { tab: "archived" };
|
|
76
|
+
await nextTick();
|
|
77
|
+
expect(activeTabId.value).toBe("all");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps the open tab when the key is dropped or unknown", async () => {
|
|
81
|
+
const { route, activeTabId } = mountTable(
|
|
82
|
+
{ tableId: "runs" },
|
|
83
|
+
{ "runs.tab": "active" },
|
|
84
|
+
);
|
|
85
|
+
route.query = {};
|
|
86
|
+
await nextTick();
|
|
87
|
+
expect(activeTabId.value).toBe("active");
|
|
88
|
+
route.query = { "runs.tab": "missing" };
|
|
89
|
+
await nextTick();
|
|
90
|
+
expect(activeTabId.value).toBe("active");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("lets the user change tabs while the URL stays put", async () => {
|
|
94
|
+
const { route, activeTabId } = mountTable(
|
|
95
|
+
{ tableId: "runs" },
|
|
96
|
+
{ "runs.tab": "active" },
|
|
97
|
+
);
|
|
98
|
+
activeTabId.value = "archived";
|
|
99
|
+
route.query = { "runs.tab": "active", other: "1" };
|
|
100
|
+
await nextTick();
|
|
101
|
+
expect(activeTabId.value).toBe("archived");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("wires the URL tab into the table view", () => {
|
|
106
|
+
const source = readFileSync(
|
|
107
|
+
resolve(
|
|
108
|
+
__dirname,
|
|
109
|
+
"../layers/dms-ui/app/components/table-view/TableView.vue",
|
|
110
|
+
),
|
|
111
|
+
"utf8",
|
|
112
|
+
);
|
|
113
|
+
expect(source).toContain("readTableUrlTab(route.query, urlScope)");
|
|
114
|
+
expect(source).toContain("useTableUrlTab({");
|
|
115
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/AntelopeJS/dms.git"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
|
|
38
38
|
"@antelopejs/interface-database": ">=0.1.8 <1.0.0",
|
|
39
39
|
"@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
|
|
40
|
-
"@antelopejs/interface-dms": ">=0.5.
|
|
40
|
+
"@antelopejs/interface-dms": ">=0.5.11 <0.6.0",
|
|
41
41
|
"@antelopejs/interface-email": ">=0.0.5 <1.0.0",
|
|
42
42
|
"@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
|
|
43
43
|
"archiver": "^7.0.1",
|
|
@@ -39,7 +39,12 @@ A page's `headerActions` follow its URL: a target URL takes `{{params.X}}` / `{{
|
|
|
39
39
|
(`?workspace={{query.workspace}}`, a pair the page has no value for is dropped), and an
|
|
40
40
|
`availability` reads `context.query`, asked again when the query changes — no need to build
|
|
41
41
|
the header in Vue with `usePageHeaderActions` to carry the scope —
|
|
42
|
-
`docs/07.interfaces/3.base/6.layout.md#buttons-that-follow-the-page-url`.
|
|
42
|
+
`docs/07.interfaces/3.base/6.layout.md#buttons-that-follow-the-page-url`. An `availability`
|
|
43
|
+
answering `{ hidden: true }` leaves the button out for that query (`{ reason }` only
|
|
44
|
+
disables it): no Vue header to hide "Add key" on a read-only workspace. `order` on a
|
|
45
|
+
header button and `usePageHeaderActions(render, { order })` interleave served buttons with a
|
|
46
|
+
component's client-side ones (default `0`, served first on a tie: `order: -1` puts a Vue
|
|
47
|
+
"Export" before a served "Add key") — `docs/07.interfaces/3.base/6.layout.md#ordering-the-header`.
|
|
43
48
|
|
|
44
49
|
A button or row action (`customButtons`, `rowActions.custom`, a page's `headerActions`)
|
|
45
50
|
points at an `ActionTarget`. A `drawer` target opens from the bottom; give it
|