@antelopejs/dms 0.7.11 → 0.7.13
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 +33 -0
- package/dist/implementations/dms/page.js +14 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
- package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
- package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
- package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +23 -2
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
- package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
- package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
- package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
- package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
- package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +7 -1
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
- package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
- package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
- package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
- package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
- package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
- package/frontend-vue/tests/chart-legend.test.ts +133 -0
- package/frontend-vue/tests/code-block.test.ts +28 -0
- package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
- package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
- package/frontend-vue/tests/header-actions.test.ts +1 -1
- package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
- package/frontend-vue/tests/module-routing.test.ts +158 -0
- package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
- package/frontend-vue/tests/page-record-label.test.ts +53 -0
- package/frontend-vue/tests/regional-format.test.ts +69 -2
- package/frontend-vue/tests/scroll-to-block.test.ts +171 -0
- package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
- package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
- package/frontend-vue/tests/table-live-hold.test.ts +90 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +28 -4
- package/skills/dms-module/SKILL.md +2 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module's own address (`/modules/<id>`) has no page: the navigation that
|
|
3
|
+
* enters it goes to the module's landing page instead — its `landingPage`
|
|
4
|
+
* when the caller may open it, else its first page — keeping the query and
|
|
5
|
+
* the hash it was entered with. The middleware answers the landing page
|
|
6
|
+
* rather than navigating to it, so the module's address never renders and,
|
|
7
|
+
* in a server render, becomes the response's redirect. A page the browser
|
|
8
|
+
* already draws at the module's address (a link that skipped the guard) loads
|
|
9
|
+
* the landing page instead of navigating from its own setup.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { ref } from "vue";
|
|
13
|
+
import { MODULE_URL_PREFIX } from "../layers/dms-layout/app/types/page";
|
|
14
|
+
|
|
15
|
+
interface TreeNode {
|
|
16
|
+
id: string;
|
|
17
|
+
fullSlug: string;
|
|
18
|
+
layoutUrl?: string;
|
|
19
|
+
hasAccess?: boolean;
|
|
20
|
+
isModuleRoot?: boolean;
|
|
21
|
+
children: Record<string, TreeNode>;
|
|
22
|
+
childrenOrders: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface GuardedRoute {
|
|
26
|
+
path: string;
|
|
27
|
+
fullPath: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Middleware = (to: GuardedRoute, from?: GuardedRoute) => Promise<unknown>;
|
|
31
|
+
|
|
32
|
+
const navigateDms = vi.fn();
|
|
33
|
+
|
|
34
|
+
function node(
|
|
35
|
+
id: string,
|
|
36
|
+
fullSlug: string,
|
|
37
|
+
children: TreeNode[] = [],
|
|
38
|
+
page: Partial<TreeNode> = {},
|
|
39
|
+
): TreeNode {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
fullSlug,
|
|
43
|
+
children: Object.fromEntries(children.map((child) => [child.id, child])),
|
|
44
|
+
childrenOrders: children.map((child) => child.id),
|
|
45
|
+
...page,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PAGE = { layoutUrl: "/layout" };
|
|
50
|
+
|
|
51
|
+
function treeOf(moduleRoot: TreeNode): TreeNode {
|
|
52
|
+
return node("", "/", [node("modules", "/modules", [moduleRoot])]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const apiModule = (pages: Partial<Record<string, Partial<TreeNode>>> = {}) =>
|
|
56
|
+
node(
|
|
57
|
+
"api",
|
|
58
|
+
"/modules/api",
|
|
59
|
+
[
|
|
60
|
+
node("monitor", "/modules/api", [
|
|
61
|
+
node("routes", "/modules/api/routes", [], { ...PAGE, ...pages.routes }),
|
|
62
|
+
node("overview", "/modules/api/overview", [], {
|
|
63
|
+
...PAGE,
|
|
64
|
+
...pages.overview,
|
|
65
|
+
}),
|
|
66
|
+
]),
|
|
67
|
+
],
|
|
68
|
+
{ isModuleRoot: true },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
function stubSiteLayout(moduleRoot: TreeNode, landingPage?: string) {
|
|
72
|
+
vi.stubGlobal("useSiteLayout", () => ({
|
|
73
|
+
siteLayout: ref({}),
|
|
74
|
+
siteLayoutTree: ref(treeOf(moduleRoot)),
|
|
75
|
+
modules: ref({ api: { id: "api", hasAccess: true, landingPage } }),
|
|
76
|
+
findMatchingRoute: (path: string) =>
|
|
77
|
+
path.startsWith("/modules/api/") ? { metadata: {} } : null,
|
|
78
|
+
loadSiteLayout: vi.fn(),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function enter(
|
|
83
|
+
fullPath: string,
|
|
84
|
+
drawnThere = false,
|
|
85
|
+
): Promise<unknown> {
|
|
86
|
+
const middleware = (
|
|
87
|
+
await import("../layers/dms-layout/app/middleware/module-routing.global")
|
|
88
|
+
).default as unknown as Middleware;
|
|
89
|
+
const path = fullPath.replace(/[?#].*$/, "");
|
|
90
|
+
const to = { path, fullPath };
|
|
91
|
+
return middleware(to, drawnThere ? to : { path: "/home", fullPath: "/home" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.resetModules();
|
|
96
|
+
vi.stubGlobal("defineDmsMiddleware", (handler: Middleware) => handler);
|
|
97
|
+
vi.stubGlobal("MODULE_URL_PREFIX", MODULE_URL_PREFIX);
|
|
98
|
+
vi.stubGlobal("navigateDms", navigateDms);
|
|
99
|
+
vi.stubGlobal("useHomepage", () => "/dashboard");
|
|
100
|
+
vi.stubGlobal("useModuleHistory", () => ({ record: vi.fn() }));
|
|
101
|
+
vi.stubGlobal("createError", (error: unknown) => error);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
navigateDms.mockReset();
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("entering a module", () => {
|
|
110
|
+
it("goes to the module's landingPage", async () => {
|
|
111
|
+
stubSiteLayout(apiModule(), "overview");
|
|
112
|
+
expect(await enter("/modules/api")).toBe("/modules/api/overview");
|
|
113
|
+
expect(navigateDms).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps the query and the hash it was entered with", async () => {
|
|
117
|
+
stubSiteLayout(apiModule(), "overview");
|
|
118
|
+
expect(await enter("/modules/api?live=0#errors")).toBe(
|
|
119
|
+
"/modules/api/overview?live=0#errors",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("goes to the first page when there is no landingPage", async () => {
|
|
124
|
+
stubSiteLayout(apiModule());
|
|
125
|
+
expect(await enter("/modules/api")).toBe("/modules/api/routes");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("goes to the first page the caller may open when the landingPage is closed to them", async () => {
|
|
129
|
+
stubSiteLayout(apiModule({ overview: { hasAccess: false } }), "overview");
|
|
130
|
+
expect(await enter("/modules/api")).toBe("/modules/api/routes");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("sends to the homepage a caller who may open none of its pages", async () => {
|
|
134
|
+
stubSiteLayout(
|
|
135
|
+
apiModule({
|
|
136
|
+
routes: { hasAccess: false },
|
|
137
|
+
overview: { hasAccess: false },
|
|
138
|
+
}),
|
|
139
|
+
"overview",
|
|
140
|
+
);
|
|
141
|
+
await enter("/modules/api");
|
|
142
|
+
expect(navigateDms).toHaveBeenCalledWith("/dashboard", { replace: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("loads the landing page when the browser already draws the module's address", async () => {
|
|
146
|
+
stubSiteLayout(apiModule(), "overview");
|
|
147
|
+
const replace = vi.fn();
|
|
148
|
+
vi.stubGlobal("window", { location: { replace } });
|
|
149
|
+
expect(await enter("/modules/api?live=0", true)).toBe(false);
|
|
150
|
+
expect(replace).toHaveBeenCalledWith("/modules/api/overview?live=0");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves a module page alone", async () => {
|
|
154
|
+
stubSiteLayout(apiModule(), "overview");
|
|
155
|
+
expect(await enter("/modules/api/routes?tab=tester")).toBeUndefined();
|
|
156
|
+
expect(navigateDms).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* `usePageLiveRefresh`, the public face of the Direct / Pause gate: a module's
|
|
4
|
+
* own component refreshes on its topic at the page's `maxRate`, counts what
|
|
5
|
+
* it missed while paused and reads once on resume, reads at once on a user's
|
|
6
|
+
* refresh, and, with `idleRefresh`, reads again after a quiet period while
|
|
7
|
+
* the page is live only.
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, defineComponent, h } from "vue";
|
|
10
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
11
|
+
import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
|
|
12
|
+
import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
|
|
13
|
+
import {
|
|
14
|
+
createPageLiveController,
|
|
15
|
+
type PageLiveController,
|
|
16
|
+
} from "../layers/dms-ui/app/build/composables/live/pageLiveController";
|
|
17
|
+
import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
|
|
18
|
+
import type {
|
|
19
|
+
PageLiveRefresh,
|
|
20
|
+
PageLiveRefreshOptions,
|
|
21
|
+
} from "../layers/dms-ui/app/composables/live/usePageLiveRefresh";
|
|
22
|
+
|
|
23
|
+
const topicHandlers = new Map<string, RealtimeTopicHandler[]>();
|
|
24
|
+
|
|
25
|
+
vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
|
|
26
|
+
useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
|
|
27
|
+
topicHandlers.set(topic, [...(topicHandlers.get(topic) ?? []), handler]);
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const { usePageLiveRefresh } = await import(
|
|
32
|
+
"../layers/dms-ui/app/composables/live/usePageLiveRefresh"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const TOPIC = "api:requests";
|
|
36
|
+
const MAX_RATE_SECONDS = 15;
|
|
37
|
+
const MAX_RATE_MS = MAX_RATE_SECONDS * 1000;
|
|
38
|
+
const IDLE_SECONDS = 60;
|
|
39
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
40
|
+
const controllers: PageLiveController[] = [];
|
|
41
|
+
|
|
42
|
+
function publish() {
|
|
43
|
+
const event = { topic: TOPIC, type: "request", ts: Date.now() };
|
|
44
|
+
for (const handler of topicHandlers.get(TOPIC) ?? []) handler(event);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface MountedRefresh {
|
|
48
|
+
onRefresh: ReturnType<typeof vi.fn>;
|
|
49
|
+
handle: PageLiveRefresh;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mountComponent(
|
|
53
|
+
options: PageLiveRefreshOptions = { realtimeTopic: TOPIC },
|
|
54
|
+
): MountedRefresh {
|
|
55
|
+
const onRefresh = vi.fn();
|
|
56
|
+
let handle: PageLiveRefresh | undefined;
|
|
57
|
+
const app = createApp(
|
|
58
|
+
defineComponent({
|
|
59
|
+
setup() {
|
|
60
|
+
handle = usePageLiveRefresh(onRefresh, options);
|
|
61
|
+
return () => h("div");
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
app.mount(document.createElement("div"));
|
|
66
|
+
mounted.push(app);
|
|
67
|
+
return { onRefresh, handle: handle! };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function installLive(): PageLiveController {
|
|
71
|
+
const controller = createPageLiveController("api.routes", {
|
|
72
|
+
maxRate: MAX_RATE_SECONDS,
|
|
73
|
+
});
|
|
74
|
+
controllers.push(controller);
|
|
75
|
+
setCurrentPageLive(controller);
|
|
76
|
+
return controller;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
topicHandlers.clear();
|
|
82
|
+
window.localStorage.clear();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
87
|
+
controllers.splice(0).forEach((controller) => controller.dispose());
|
|
88
|
+
setCurrentPageLive(null);
|
|
89
|
+
vi.useRealTimers();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reads on its topic at the page's maxRate, the last event of a burst kept", () => {
|
|
93
|
+
installLive();
|
|
94
|
+
const { onRefresh } = mountComponent();
|
|
95
|
+
|
|
96
|
+
publish();
|
|
97
|
+
publish();
|
|
98
|
+
publish();
|
|
99
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
100
|
+
|
|
101
|
+
vi.advanceTimersByTime(MAX_RATE_MS);
|
|
102
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reads nothing while paused, counts what it missed, and reads once on resume", () => {
|
|
106
|
+
const controller = installLive();
|
|
107
|
+
const { onRefresh, handle } = mountComponent();
|
|
108
|
+
controller.pause();
|
|
109
|
+
|
|
110
|
+
publish();
|
|
111
|
+
publish();
|
|
112
|
+
expect(onRefresh).not.toHaveBeenCalled();
|
|
113
|
+
expect(handle.missed.value).toBe(2);
|
|
114
|
+
expect(controller.missedCount.value).toBe(2);
|
|
115
|
+
|
|
116
|
+
controller.resume();
|
|
117
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(handle.missed.value).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads at once on a refresh the user asks for, paused or not", () => {
|
|
122
|
+
const controller = installLive();
|
|
123
|
+
const { onRefresh, handle } = mountComponent();
|
|
124
|
+
controller.pause();
|
|
125
|
+
|
|
126
|
+
refreshPageBlocks();
|
|
127
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
128
|
+
|
|
129
|
+
handle.refreshNow();
|
|
130
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("gates its own trigger like an event", () => {
|
|
134
|
+
const controller = installLive();
|
|
135
|
+
const { onRefresh, handle } = mountComponent({});
|
|
136
|
+
controller.pause();
|
|
137
|
+
|
|
138
|
+
handle.trigger();
|
|
139
|
+
expect(onRefresh).not.toHaveBeenCalled();
|
|
140
|
+
expect(handle.missed.value).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("reads again after a quiet idleRefresh while live, never while paused", () => {
|
|
144
|
+
const controller = installLive();
|
|
145
|
+
const { onRefresh } = mountComponent({
|
|
146
|
+
realtimeTopic: TOPIC,
|
|
147
|
+
idleRefresh: IDLE_SECONDS,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
vi.advanceTimersByTime(IDLE_SECONDS * 1000);
|
|
151
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
152
|
+
|
|
153
|
+
controller.pause();
|
|
154
|
+
vi.advanceTimersByTime(IDLE_SECONDS * 3000);
|
|
155
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(controller.missedCount.value).toBe(0);
|
|
157
|
+
|
|
158
|
+
controller.resume();
|
|
159
|
+
vi.advanceTimersByTime(IDLE_SECONDS * 1000);
|
|
160
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("reads on every event on a page without live", () => {
|
|
164
|
+
const { onRefresh } = mountComponent();
|
|
165
|
+
|
|
166
|
+
publish();
|
|
167
|
+
publish();
|
|
168
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `usePageRecordLabel`, public: the label a record page's header ends its
|
|
3
|
+
* breadcrumb with, held with its page's path so it never outlives the page,
|
|
4
|
+
* and still reachable from its former `build/` path.
|
|
5
|
+
*/
|
|
6
|
+
import { reactive, ref, type Ref } from "vue";
|
|
7
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
const route = reactive({ path: "/modules/api/routes" });
|
|
10
|
+
let sharedState: Map<string, Ref<unknown>>;
|
|
11
|
+
|
|
12
|
+
vi.stubGlobal("useDmsRoute", () => route);
|
|
13
|
+
vi.stubGlobal("useDmsState", <T>(key: string, init?: () => T): Ref<T> => {
|
|
14
|
+
if (!sharedState.has(key)) sharedState.set(key, ref(init?.()));
|
|
15
|
+
return sharedState.get(key) as Ref<T>;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const { usePageRecordLabel } = await import(
|
|
19
|
+
"../layers/dms-core/app/composables/page/usePageRecordLabel"
|
|
20
|
+
);
|
|
21
|
+
const legacy = await import(
|
|
22
|
+
"../layers/dms-core/app/build/composables/page/usePageRecordLabel"
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
sharedState = new Map();
|
|
27
|
+
route.path = "/modules/api/routes";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads the label set for the page on screen, shared between callers", () => {
|
|
31
|
+
const writer = usePageRecordLabel();
|
|
32
|
+
const reader = usePageRecordLabel();
|
|
33
|
+
|
|
34
|
+
writer.setLabel(route.path, "GET /api/invoices");
|
|
35
|
+
|
|
36
|
+
expect(reader.label.value).toBe("GET /api/invoices");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never shows a label on another page, and clears with no value", () => {
|
|
40
|
+
const { label, setLabel } = usePageRecordLabel();
|
|
41
|
+
setLabel(route.path, "GET /api/invoices");
|
|
42
|
+
|
|
43
|
+
route.path = "/modules/api/logs";
|
|
44
|
+
expect(label.value).toBeUndefined();
|
|
45
|
+
|
|
46
|
+
route.path = "/modules/api/routes";
|
|
47
|
+
setLabel(route.path, undefined);
|
|
48
|
+
expect(label.value).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stays importable from its former internal path", () => {
|
|
52
|
+
expect(legacy.usePageRecordLabel).toBe(usePageRecordLabel);
|
|
53
|
+
});
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
expectTypeOf,
|
|
7
|
+
it,
|
|
8
|
+
vi,
|
|
9
|
+
} from "vitest";
|
|
2
10
|
import { ref } from "vue";
|
|
3
11
|
import { useRegionalFormat } from "../layers/dms-core/app/composables/user/useRegionalFormat";
|
|
4
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
regionalFormatLocale,
|
|
14
|
+
setRegionalPreferencesSource,
|
|
15
|
+
} from "../layers/dms-core/app/utils/regional";
|
|
5
16
|
|
|
6
17
|
// 23:30 UTC on 30 Sep: already 1 Oct in Brussels.
|
|
7
18
|
const LATE_EVENING_UTC = new Date(Date.UTC(2026, 8, 30, 23, 30));
|
|
@@ -64,4 +75,60 @@ describe("useRegionalFormat", () => {
|
|
|
64
75
|
const { formatRelativeTime } = useRegionalFormat();
|
|
65
76
|
expect(formatRelativeTime(Date.now())).toBe("common.time.just_now:{}");
|
|
66
77
|
});
|
|
78
|
+
|
|
79
|
+
it("types a formatted number as a string", () => {
|
|
80
|
+
const { formatNumber, formatPercentage, formatPrice } = useRegionalFormat();
|
|
81
|
+
const written: string[] = [
|
|
82
|
+
formatNumber(1234.5),
|
|
83
|
+
formatPercentage(0.25),
|
|
84
|
+
formatPrice(12),
|
|
85
|
+
];
|
|
86
|
+
expect(written).toEqual([
|
|
87
|
+
new Intl.NumberFormat("fr-FR").format(1234.5),
|
|
88
|
+
new Intl.NumberFormat("fr-FR", { style: "percent" }).format(0.25),
|
|
89
|
+
new Intl.NumberFormat("fr-FR", {
|
|
90
|
+
style: "currency",
|
|
91
|
+
currency: "EUR",
|
|
92
|
+
}).format(12),
|
|
93
|
+
]);
|
|
94
|
+
expectTypeOf(formatNumber(1)).toEqualTypeOf<string>();
|
|
95
|
+
expectTypeOf(formatNumber("n/a" as unknown)).toEqualTypeOf<unknown>();
|
|
96
|
+
expect(formatNumber("n/a")).toBe("n/a");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("names the locale of the DMS texts for the interface language", () => {
|
|
100
|
+
locale.value = "en";
|
|
101
|
+
const { formatLocale } = useRegionalFormat();
|
|
102
|
+
expect(formatLocale.value).toBe("en-GB");
|
|
103
|
+
locale.value = "fr";
|
|
104
|
+
expect(formatLocale.value).toBe("fr-FR");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("writes in the locale of the DMS texts when asked", () => {
|
|
108
|
+
locale.value = "en";
|
|
109
|
+
const interfaceFormat = useRegionalFormat();
|
|
110
|
+
const textFormat = useRegionalFormat({ locale: "format" });
|
|
111
|
+
const clock: Intl.DateTimeFormatOptions = {
|
|
112
|
+
hour: "numeric",
|
|
113
|
+
minute: "2-digit",
|
|
114
|
+
};
|
|
115
|
+
expect(interfaceFormat.dateTimeFormat(clock).format(LATE_EVENING_UTC)).toBe(
|
|
116
|
+
"1:30 AM",
|
|
117
|
+
);
|
|
118
|
+
expect(textFormat.dateTimeFormat(clock).format(LATE_EVENING_UTC)).toBe(
|
|
119
|
+
"1:30",
|
|
120
|
+
);
|
|
121
|
+
expect(textFormat.formatDate(LATE_EVENING_UTC, DAY_MONTH_YEAR)).toBe(
|
|
122
|
+
"1 October 2026",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("regionalFormatLocale", () => {
|
|
128
|
+
it("reads a bare DMS language as the region its texts are written for", () => {
|
|
129
|
+
expect(regionalFormatLocale("en")).toBe("en-GB");
|
|
130
|
+
expect(regionalFormatLocale("fr")).toBe("fr-FR");
|
|
131
|
+
expect(regionalFormatLocale("en-US")).toBe("en-US");
|
|
132
|
+
expect(regionalFormatLocale("de")).toBe("de");
|
|
133
|
+
});
|
|
67
134
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A page's block is brought into view by its anchor, the key path it is
|
|
4
|
+
* declared under (`summary`, `tabs.statistics`): `scrollToBlock` scrolls its
|
|
5
|
+
* first element and answers whether it found it. A block that is not on
|
|
6
|
+
* screen — never drawn, or unmounted since — answers false.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createApp,
|
|
10
|
+
defineComponent,
|
|
11
|
+
Fragment,
|
|
12
|
+
h,
|
|
13
|
+
nextTick,
|
|
14
|
+
ref,
|
|
15
|
+
type App,
|
|
16
|
+
type ComponentPublicInstance,
|
|
17
|
+
} from "vue";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
import { registerBlockAnchor } from "../layers/dms-ui/app/build/composables/page/blockAnchors";
|
|
20
|
+
import { scrollToBlock } from "../layers/dms-ui/app/utils/scrollToBlock";
|
|
21
|
+
|
|
22
|
+
vi.mock("#dms-core/app/build/composables/auth/usePermissionPreview", () => ({
|
|
23
|
+
usePermissionPreview: () => ({
|
|
24
|
+
blockFor: () => undefined,
|
|
25
|
+
isActive: ref(false),
|
|
26
|
+
session: ref(null),
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
let app: App | undefined;
|
|
31
|
+
let host: HTMLDivElement;
|
|
32
|
+
const scrolled: Array<{ element: Element; options: unknown }> = [];
|
|
33
|
+
|
|
34
|
+
const Section = defineComponent({
|
|
35
|
+
setup:
|
|
36
|
+
(_, { slots }) =>
|
|
37
|
+
() =>
|
|
38
|
+
h("section", { class: "section" }, slots.default?.()),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// A block rendering several roots: its anchor is its first element.
|
|
42
|
+
const Pair = defineComponent({
|
|
43
|
+
setup: () => () =>
|
|
44
|
+
h(Fragment, [h("h3", { class: "pair-head" }), h("p", "body")]),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A list beside the item it selects, like a split layout: one child in a
|
|
48
|
+
// named slot, the other in the default one.
|
|
49
|
+
const Split = defineComponent({
|
|
50
|
+
setup:
|
|
51
|
+
(_, { slots }) =>
|
|
52
|
+
() =>
|
|
53
|
+
h("div", { class: "split" }, [
|
|
54
|
+
h("aside", slots.aside?.()),
|
|
55
|
+
h("article", slots.default?.()),
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const resolvedChild = (id: string, component: unknown, slot?: string) => ({
|
|
60
|
+
id,
|
|
61
|
+
component,
|
|
62
|
+
options: {},
|
|
63
|
+
children: [],
|
|
64
|
+
...(slot ? { slot } : {}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function mountPage(showSecond = ref(true)) {
|
|
68
|
+
const { default: RecursiveComponent } = await import(
|
|
69
|
+
"../layers/dms-ui/app/components/RecursiveComponent.vue"
|
|
70
|
+
);
|
|
71
|
+
const block = (id: string, component: unknown, children: unknown[] = []) =>
|
|
72
|
+
h(RecursiveComponent, {
|
|
73
|
+
component: { ...resolvedChild(id, component), children },
|
|
74
|
+
pageId: "routes",
|
|
75
|
+
componentId: id,
|
|
76
|
+
layoutPath: id,
|
|
77
|
+
});
|
|
78
|
+
app = createApp({
|
|
79
|
+
render: () =>
|
|
80
|
+
h("main", [
|
|
81
|
+
block("summary", Pair),
|
|
82
|
+
showSecond.value
|
|
83
|
+
? block("tabs", Section, [resolvedChild("statistics", Section)])
|
|
84
|
+
: null,
|
|
85
|
+
block("explorer", Split, [
|
|
86
|
+
resolvedChild("tree", Section, "aside"),
|
|
87
|
+
resolvedChild("header", Pair),
|
|
88
|
+
]),
|
|
89
|
+
]),
|
|
90
|
+
});
|
|
91
|
+
app.component("DmsRecursiveComponent", RecursiveComponent);
|
|
92
|
+
app.mount(host);
|
|
93
|
+
await nextTick();
|
|
94
|
+
return showSecond;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.stubGlobal("useI18n", () => ({ t: String }));
|
|
99
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
100
|
+
vi.stubGlobal("computed", (getter: () => unknown) => ({
|
|
101
|
+
get value() {
|
|
102
|
+
return getter();
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
vi.stubGlobal("provide", () => {});
|
|
106
|
+
host = document.createElement("div");
|
|
107
|
+
document.body.append(host);
|
|
108
|
+
scrolled.length = 0;
|
|
109
|
+
Element.prototype.scrollIntoView = function (options?: unknown) {
|
|
110
|
+
scrolled.push({ element: this, options });
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
app?.unmount();
|
|
116
|
+
host.remove();
|
|
117
|
+
vi.unstubAllGlobals();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("scrollToBlock", () => {
|
|
121
|
+
it("scrolls a top-level block and a nested one by their key path", async () => {
|
|
122
|
+
await mountPage();
|
|
123
|
+
expect(scrollToBlock("tabs.statistics")).toBe(true);
|
|
124
|
+
expect(scrolled.at(-1)?.element).toBe(
|
|
125
|
+
host.querySelectorAll("section.section")[1],
|
|
126
|
+
);
|
|
127
|
+
expect(scrolled.at(-1)?.options).toEqual({ block: "start" });
|
|
128
|
+
expect(scrollToBlock("summary", { behavior: "smooth" })).toBe(true);
|
|
129
|
+
expect(scrolled.at(-1)).toEqual({
|
|
130
|
+
element: host.querySelector(".pair-head"),
|
|
131
|
+
options: { behavior: "smooth" },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("finds the children of a block that places them in its slots", async () => {
|
|
136
|
+
await mountPage();
|
|
137
|
+
expect(scrollToBlock("explorer.header")).toBe(true);
|
|
138
|
+
expect(scrolled.at(-1)?.element).toBe(
|
|
139
|
+
host.querySelector(".split article .pair-head"),
|
|
140
|
+
);
|
|
141
|
+
expect(scrollToBlock("explorer.tree")).toBe(true);
|
|
142
|
+
expect(scrolled.at(-1)?.element).toBe(
|
|
143
|
+
host.querySelector(".split aside section"),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("answers false for a block that is not on screen", async () => {
|
|
148
|
+
const showSecond = await mountPage();
|
|
149
|
+
expect(scrollToBlock("missing")).toBe(false);
|
|
150
|
+
showSecond.value = false;
|
|
151
|
+
await nextTick();
|
|
152
|
+
expect(scrollToBlock("tabs")).toBe(false);
|
|
153
|
+
expect(scrollToBlock("tabs.statistics")).toBe(false);
|
|
154
|
+
expect(scrolled).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("registerBlockAnchor", () => {
|
|
159
|
+
it("leaves an anchor to the block that took it over", () => {
|
|
160
|
+
const first = {} as ComponentPublicInstance;
|
|
161
|
+
const second = {
|
|
162
|
+
$: { subTree: { el: document.createElement("div") } },
|
|
163
|
+
} as unknown as ComponentPublicInstance;
|
|
164
|
+
registerBlockAnchor("chart", first);
|
|
165
|
+
registerBlockAnchor("chart", second);
|
|
166
|
+
registerBlockAnchor("chart", null, first);
|
|
167
|
+
expect(scrollToBlock("chart")).toBe(true);
|
|
168
|
+
registerBlockAnchor("chart", null, second);
|
|
169
|
+
expect(scrollToBlock("chart")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildPageSearchShortcuts,
|
|
14
14
|
pageSearchAriaKeyshortcuts,
|
|
15
15
|
} from "../layers/dms-ui/app/build/composables/global/searchShortcuts";
|
|
16
|
+
import * as publicSearchShortcuts from "../layers/dms-ui/app/composables/global/searchShortcuts";
|
|
16
17
|
import registry from "../layers/dms-ui/app/config/shortcuts-registry";
|
|
17
18
|
|
|
18
19
|
let app: App;
|
|
@@ -130,6 +131,22 @@ describe("settings search shortcut maps", () => {
|
|
|
130
131
|
expect(nav.filter((key) => page.includes(key))).toEqual([]);
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
it("are public where the UI toolkit docs import them", () => {
|
|
135
|
+
expect(publicSearchShortcuts.buildPageSearchShortcuts).toBe(
|
|
136
|
+
buildPageSearchShortcuts,
|
|
137
|
+
);
|
|
138
|
+
expect(publicSearchShortcuts.PAGE_SEARCH_SHORTCUTS).toBe(
|
|
139
|
+
PAGE_SEARCH_SHORTCUTS,
|
|
140
|
+
);
|
|
141
|
+
const docs = readFileSync(
|
|
142
|
+
resolve(__dirname, "../../docs/06.frontend-composables/05.ui-toolkit.md"),
|
|
143
|
+
"utf8",
|
|
144
|
+
);
|
|
145
|
+
expect(docs).toContain(
|
|
146
|
+
'from "#dms-ui/app/composables/global/searchShortcuts"',
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
133
150
|
it("describe the page search keys per platform", () => {
|
|
134
151
|
expect(pageSearchAriaKeyshortcuts(true)).toBe("Meta+/");
|
|
135
152
|
expect(pageSearchAriaKeyshortcuts(false)).toBe("Control+/");
|