@antelopejs/dms 0.7.15 → 0.7.17
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 +34 -0
- 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 +79 -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-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/bulkSelection.ts +7 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +42 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +1 -59
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -1
- 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/utils/fileDownload.ts +69 -0
- 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 +14 -4
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +13 -0
- package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -4
- package/frontend-vue/tests/download-action-target.test.ts +172 -0
- package/frontend-vue/tests/header-action-page-query.test.ts +120 -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/table-view-url-tab.test.ts +115 -0
- package/package.json +2 -2
- package/skills/dms-pages/REFERENCE.md +9 -1
|
@@ -25,6 +25,18 @@ export interface SidePanelTargetOptions {
|
|
|
25
25
|
notFound?: SidePanelNotFoundTexts;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Fetches a file with the user's session and saves it (backend
|
|
30
|
+
* `DownloadActionTarget`).
|
|
31
|
+
*/
|
|
32
|
+
export interface DownloadActionTarget {
|
|
33
|
+
type: "download";
|
|
34
|
+
/** Takes the `{field}` and `{{params.X}}` / `{{query.X}}` tokens. */
|
|
35
|
+
url: string;
|
|
36
|
+
/** Saved under; the response's `Content-Disposition` name, else the URL's. */
|
|
37
|
+
filename?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
export type ActionTarget =
|
|
29
41
|
| {
|
|
30
42
|
type: "drawer";
|
|
@@ -45,6 +57,7 @@ export type ActionTarget =
|
|
|
45
57
|
| ({ type: "sidePanel"; component: ComponentInfo } & SidePanelTargetOptions)
|
|
46
58
|
| { type: "page"; url: string }
|
|
47
59
|
| { type: "external"; url: string; newTab?: boolean }
|
|
60
|
+
| DownloadActionTarget
|
|
48
61
|
/** Runs a quick action, by its `category:id` key or bare id. */
|
|
49
62
|
| { type: "quickAction"; id: string }
|
|
50
63
|
| {
|
|
@@ -12,10 +12,8 @@ import {
|
|
|
12
12
|
import { createI18n } from "vue-i18n";
|
|
13
13
|
import ConfirmModal from "../layers/dms-ui/app/components/confirm/ConfirmModal.vue";
|
|
14
14
|
import { resolveConfirmDialog } from "../layers/dms-ui/app/build/composables/confirm/confirmDialogTexts";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
secondaryTargetRunner,
|
|
18
|
-
} from "../layers/dms-ui/app/build/composables/confirm/secondaryTarget";
|
|
15
|
+
import { secondaryTargetRunner } from "../layers/dms-ui/app/build/composables/confirm/secondaryTarget";
|
|
16
|
+
import { downloadFilename } from "../layers/dms-ui/app/build/utils/fileDownload";
|
|
19
17
|
|
|
20
18
|
vi.mock("reka-ui", async () => {
|
|
21
19
|
const { defineComponent: define, h: render } = await import("vue");
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A `download` action target fetches its file with the user's session and
|
|
4
|
+
* saves it: its URL and file name take the page's `{{params.X}}` /
|
|
5
|
+
* `{{query.X}}` and the row's `{field}`, and a refusal is toasted.
|
|
6
|
+
*/
|
|
7
|
+
import { createApp, defineComponent, h, reactive, type App } from "vue";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { useActionTargets } from "../layers/dms-ui/app/build/composables/actions/useActionTargets";
|
|
10
|
+
import { interpolateUrl } from "../layers/dms-core/app/utils/url-interpolation";
|
|
11
|
+
import type { CustomButton } from "../layers/dms-ui/app/composables/table-view/types/custom-button";
|
|
12
|
+
import type { CustomRowAction } from "../layers/dms-ui/app/types/row-action";
|
|
13
|
+
|
|
14
|
+
const route = reactive<{ path: string; query: Record<string, unknown> }>({
|
|
15
|
+
path: "/modules/lang/queue/de",
|
|
16
|
+
query: {},
|
|
17
|
+
});
|
|
18
|
+
const fetchRaw = vi.fn();
|
|
19
|
+
const handleApiError = vi.fn();
|
|
20
|
+
const saved: string[] = [];
|
|
21
|
+
let app: App | undefined;
|
|
22
|
+
|
|
23
|
+
function mountTargets() {
|
|
24
|
+
let targets: ReturnType<typeof useActionTargets> | undefined;
|
|
25
|
+
app = createApp(
|
|
26
|
+
defineComponent({
|
|
27
|
+
setup() {
|
|
28
|
+
targets = useActionTargets({
|
|
29
|
+
api: vi.fn() as never,
|
|
30
|
+
pageId: "queue-page",
|
|
31
|
+
componentId: "page-header",
|
|
32
|
+
handleApiError,
|
|
33
|
+
});
|
|
34
|
+
return () => h("div");
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
app.mount(document.createElement("div"));
|
|
39
|
+
return targets!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const downloadButton = (filename?: string): CustomButton => ({
|
|
43
|
+
id: "download-language",
|
|
44
|
+
label: "Download",
|
|
45
|
+
target: {
|
|
46
|
+
type: "download",
|
|
47
|
+
url: "/api/lang/translations/export?workspace={{query.workspace}}&locale={{query.locale}}",
|
|
48
|
+
filename,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const served = (disposition?: string) =>
|
|
53
|
+
fetchRaw.mockResolvedValue({
|
|
54
|
+
_data: new Blob(["{}"]),
|
|
55
|
+
headers: new Headers(
|
|
56
|
+
disposition ? { "content-disposition": disposition } : {},
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
route.query = { workspace: "web", locale: "pt BR" };
|
|
62
|
+
fetchRaw.mockReset();
|
|
63
|
+
handleApiError.mockReset();
|
|
64
|
+
saved.length = 0;
|
|
65
|
+
vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
|
|
66
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
67
|
+
processI18n: (text: string) => text,
|
|
68
|
+
processApiMessage: (text: string) => text,
|
|
69
|
+
}));
|
|
70
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
71
|
+
vi.stubGlobal("useModal", () => ({ open: vi.fn() }));
|
|
72
|
+
vi.stubGlobal("useDrawer", () => ({ open: vi.fn() }));
|
|
73
|
+
vi.stubGlobal("useExportJob", () => ({ runJob: vi.fn() }));
|
|
74
|
+
vi.stubGlobal("useConfirm", () => ({ confirm: vi.fn(async () => true) }));
|
|
75
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
76
|
+
$authFetch: Object.assign(vi.fn(), { raw: fetchRaw }),
|
|
77
|
+
}));
|
|
78
|
+
vi.stubGlobal("interpolateUrl", interpolateUrl);
|
|
79
|
+
vi.stubGlobal("useDmsRoute", () => route);
|
|
80
|
+
vi.stubGlobal("useSiteLayout", () => ({
|
|
81
|
+
findMatchingRoute: () => ({ params: { locale: "de" } }),
|
|
82
|
+
}));
|
|
83
|
+
vi.stubGlobal(
|
|
84
|
+
"URL",
|
|
85
|
+
Object.assign(URL, {
|
|
86
|
+
createObjectURL: vi.fn(() => "blob:file"),
|
|
87
|
+
revokeObjectURL: vi.fn(),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
|
91
|
+
this: HTMLAnchorElement,
|
|
92
|
+
) {
|
|
93
|
+
saved.push(this.download);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
app?.unmount();
|
|
99
|
+
app = undefined;
|
|
100
|
+
vi.restoreAllMocks();
|
|
101
|
+
vi.unstubAllGlobals();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("a download action target", () => {
|
|
105
|
+
it("fetches the page's file with the session, under the declared name", async () => {
|
|
106
|
+
served('attachment; filename="served.json"');
|
|
107
|
+
mountTargets().handleCustomButton(downloadButton("{{query.locale}}.json"));
|
|
108
|
+
await vi.waitFor(() => expect(saved).toEqual(["pt BR.json"]));
|
|
109
|
+
expect(fetchRaw).toHaveBeenCalledWith(
|
|
110
|
+
"/api/lang/translations/export?workspace=web&locale=pt%20BR",
|
|
111
|
+
{ responseType: "blob" },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves out a query pair the page has no value for", async () => {
|
|
116
|
+
route.query = { locale: "fr" };
|
|
117
|
+
served();
|
|
118
|
+
mountTargets().handleCustomButton(downloadButton());
|
|
119
|
+
await vi.waitFor(() => expect(saved).toHaveLength(1));
|
|
120
|
+
expect(fetchRaw).toHaveBeenCalledWith(
|
|
121
|
+
"/api/lang/translations/export?locale=fr",
|
|
122
|
+
{ responseType: "blob" },
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is saved under the server's name, else the URL's, without one declared", async () => {
|
|
127
|
+
served('attachment; filename="fr.json"');
|
|
128
|
+
mountTargets().handleCustomButton(downloadButton());
|
|
129
|
+
await vi.waitFor(() => expect(saved).toEqual(["fr.json"]));
|
|
130
|
+
|
|
131
|
+
served();
|
|
132
|
+
mountTargets().handleCustomButton(downloadButton());
|
|
133
|
+
await vi.waitFor(() => expect(saved).toEqual(["fr.json", "export"]));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("falls back to the server's name while a name token has no value", async () => {
|
|
137
|
+
route.query = { workspace: "web" };
|
|
138
|
+
served('attachment; filename="web.json"');
|
|
139
|
+
mountTargets().handleCustomButton(downloadButton("{{query.locale}}.json"));
|
|
140
|
+
await vi.waitFor(() => expect(saved).toEqual(["web.json"]));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fills a row action's URL and name with the row's fields", async () => {
|
|
144
|
+
served();
|
|
145
|
+
const action: CustomRowAction = {
|
|
146
|
+
label: "Download",
|
|
147
|
+
target: {
|
|
148
|
+
type: "download",
|
|
149
|
+
url: "/api/files/{_id}?workspace={{query.workspace}}",
|
|
150
|
+
filename: "{name}",
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
mountTargets().handleCustomRowAction(action, {
|
|
154
|
+
_id: "f 1",
|
|
155
|
+
name: "report.csv",
|
|
156
|
+
});
|
|
157
|
+
await vi.waitFor(() => expect(saved).toEqual(["report.csv"]));
|
|
158
|
+
expect(fetchRaw).toHaveBeenCalledWith("/api/files/f%201?workspace=web", {
|
|
159
|
+
responseType: "blob",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("toasts a refused download under the button's label", async () => {
|
|
164
|
+
const refusal = new Error("Forbidden");
|
|
165
|
+
fetchRaw.mockRejectedValue(refusal);
|
|
166
|
+
mountTargets().handleCustomButton(downloadButton());
|
|
167
|
+
await vi.waitFor(() =>
|
|
168
|
+
expect(handleApiError).toHaveBeenCalledWith(refusal, "Download"),
|
|
169
|
+
);
|
|
170
|
+
expect(saved).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* A page header's buttons follow the page URL: a target URL takes the
|
|
4
4
|
* `{{params.X}}` / `{{query.X}}` tokens of a block's `fetchUrl`, and a
|
|
5
5
|
* button whose availability reads the query is asked for again when the
|
|
6
|
-
* query changes.
|
|
6
|
+
* query changes. The served buttons and a page's own actions
|
|
7
|
+
* (`usePageHeaderActions`) share one order.
|
|
7
8
|
*/
|
|
8
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
10
|
import {
|
|
@@ -23,6 +24,11 @@ import {
|
|
|
23
24
|
layoutUrlWithQuery,
|
|
24
25
|
pageQueryValues,
|
|
25
26
|
} from "../layers/dms-layout/app/utils/pageQuery";
|
|
27
|
+
import {
|
|
28
|
+
providePageHeaderActions,
|
|
29
|
+
usePageHeaderActions,
|
|
30
|
+
type PageHeaderActionsOptions,
|
|
31
|
+
} from "../layers/dms-layout/app/composables/layout/usePageHeaderActions";
|
|
26
32
|
|
|
27
33
|
const handleCustomButton = vi.fn();
|
|
28
34
|
vi.mock("#dms-ui/app/build/composables/actions/useActionTargets", () => ({
|
|
@@ -106,6 +112,7 @@ const mountBar = (actions: unknown[]) =>
|
|
|
106
112
|
);
|
|
107
113
|
|
|
108
114
|
const buttons = () => [...host.querySelectorAll("button")];
|
|
115
|
+
const labels = () => buttons().map((button) => button.textContent);
|
|
109
116
|
|
|
110
117
|
describe("resolveLinkVariables", () => {
|
|
111
118
|
const context = {
|
|
@@ -237,6 +244,30 @@ describe("a header button's target URL", () => {
|
|
|
237
244
|
}),
|
|
238
245
|
);
|
|
239
246
|
});
|
|
247
|
+
it("runs a download target on the page's scope, as a button", async () => {
|
|
248
|
+
await mountBar([
|
|
249
|
+
{
|
|
250
|
+
id: "download-language",
|
|
251
|
+
label: "Download",
|
|
252
|
+
target: {
|
|
253
|
+
type: "download",
|
|
254
|
+
url: "/api/lang/translations/export?workspace={{query.workspace}}&locale={{params.locale}}",
|
|
255
|
+
filename: "{{params.locale}}.json",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
expect(buttons()[0]?.dataset.to).toBeUndefined();
|
|
260
|
+
buttons()[0]?.click();
|
|
261
|
+
expect(handleCustomButton).toHaveBeenCalledWith(
|
|
262
|
+
expect.objectContaining({
|
|
263
|
+
target: {
|
|
264
|
+
type: "download",
|
|
265
|
+
url: "/api/lang/translations/export?workspace=web&locale=de",
|
|
266
|
+
filename: "{{params.locale}}.json",
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
240
271
|
});
|
|
241
272
|
|
|
242
273
|
describe("a header button's availability", () => {
|
|
@@ -277,6 +308,33 @@ describe("a header button's availability", () => {
|
|
|
277
308
|
expect(buttons()[0]?.disabled).toBe(false);
|
|
278
309
|
});
|
|
279
310
|
|
|
311
|
+
it("shows a button it hides only for the queries it does not hide it for", async () => {
|
|
312
|
+
const addKey = {
|
|
313
|
+
id: "add-key",
|
|
314
|
+
label: "Add key",
|
|
315
|
+
target: download(false).target,
|
|
316
|
+
};
|
|
317
|
+
authFetch.mockResolvedValue({
|
|
318
|
+
components: {},
|
|
319
|
+
layout: { options: { headerActions: [download(false)] } },
|
|
320
|
+
});
|
|
321
|
+
await mount(
|
|
322
|
+
"../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue",
|
|
323
|
+
{ actions: [download(false), addKey], servedQuery: { workspace: "web" } },
|
|
324
|
+
);
|
|
325
|
+
expect(labels()).toEqual(["Download", "Add key"]);
|
|
326
|
+
|
|
327
|
+
route.query = { workspace: "modules" };
|
|
328
|
+
await nextTick();
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
330
|
+
expect(authFetch).toHaveBeenCalledWith(`${LAYOUT_URL}?workspace=modules`);
|
|
331
|
+
expect(labels()).toEqual(["Download"]);
|
|
332
|
+
|
|
333
|
+
route.query = { workspace: "web" };
|
|
334
|
+
await nextTick();
|
|
335
|
+
expect(labels()).toEqual(["Download", "Add key"]);
|
|
336
|
+
});
|
|
337
|
+
|
|
280
338
|
it("is never asked again when no button declares one", async () => {
|
|
281
339
|
await mountOutlet(undefined);
|
|
282
340
|
route.query = { workspace: "gone" };
|
|
@@ -284,3 +342,64 @@ describe("a header button's availability", () => {
|
|
|
284
342
|
expect(authFetch).not.toHaveBeenCalled();
|
|
285
343
|
});
|
|
286
344
|
});
|
|
345
|
+
|
|
346
|
+
describe("the order of the header's buttons", () => {
|
|
347
|
+
const served = (id: string, order?: number) => ({
|
|
348
|
+
id,
|
|
349
|
+
label: id,
|
|
350
|
+
target: { type: "page", url: `/${id}` },
|
|
351
|
+
order,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// A layout's page body: the outlet, then the page calling
|
|
355
|
+
// usePageHeaderActions, then the marker saying the page has rendered.
|
|
356
|
+
async function mountPage(
|
|
357
|
+
actions: unknown[],
|
|
358
|
+
options?: PageHeaderActionsOptions,
|
|
359
|
+
) {
|
|
360
|
+
const { default: Outlet } = await import(
|
|
361
|
+
"../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue"
|
|
362
|
+
);
|
|
363
|
+
const Page = defineComponent({
|
|
364
|
+
setup() {
|
|
365
|
+
usePageHeaderActions(() => h("button", "Export"), options);
|
|
366
|
+
return () => null;
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const Body = defineComponent({
|
|
370
|
+
setup() {
|
|
371
|
+
const pageHost = providePageHeaderActions();
|
|
372
|
+
const Rendered = defineComponent({
|
|
373
|
+
setup() {
|
|
374
|
+
pageHost.markSlotRendered();
|
|
375
|
+
return () => null;
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
return () => [h(Outlet, { actions }), h(Page), h(Rendered)];
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
app = createApp(Body);
|
|
382
|
+
app.component("UButton", ButtonStub);
|
|
383
|
+
app.component("UTooltip", SlotStub);
|
|
384
|
+
app.mount(host);
|
|
385
|
+
await nextTick();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
it("puts the page's actions after the served buttons by default", async () => {
|
|
389
|
+
await mountPage([served("add-key"), served("add-language")]);
|
|
390
|
+
expect(labels()).toEqual(["add-key", "add-language", "Export"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("puts them before the served buttons of a greater order", async () => {
|
|
394
|
+
await mountPage([served("add-key")], { order: -1 });
|
|
395
|
+
expect(labels()).toEqual(["Export", "add-key"]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("sorts the served buttons by order, the declared order breaking ties", async () => {
|
|
399
|
+
await mountPage(
|
|
400
|
+
[served("last", 2), served("first"), served("second"), served("mid", 1)],
|
|
401
|
+
{ order: 1 },
|
|
402
|
+
);
|
|
403
|
+
expect(labels()).toEqual(["first", "second", "mid", "Export", "last"]);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -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
|
});
|