@antelopejs/dms 0.7.10 → 0.7.12
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 +29 -0
- 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 +1 -1
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -10
- 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 +5 -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/guard/Guard.vue +3 -2
- 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/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/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 +139 -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 +142 -0
- package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
- package/frontend-vue/tests/tab-slot-spacing.test.ts +8 -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,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,142 @@
|
|
|
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
|
+
const resolvedChild = (id: string, component: unknown) => ({
|
|
48
|
+
id,
|
|
49
|
+
component,
|
|
50
|
+
options: {},
|
|
51
|
+
children: [],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function mountPage(showSecond = ref(true)) {
|
|
55
|
+
const { default: RecursiveComponent } = await import(
|
|
56
|
+
"../layers/dms-ui/app/components/RecursiveComponent.vue"
|
|
57
|
+
);
|
|
58
|
+
const block = (id: string, component: unknown, children: unknown[] = []) =>
|
|
59
|
+
h(RecursiveComponent, {
|
|
60
|
+
component: { ...resolvedChild(id, component), children },
|
|
61
|
+
pageId: "routes",
|
|
62
|
+
componentId: id,
|
|
63
|
+
layoutPath: id,
|
|
64
|
+
});
|
|
65
|
+
app = createApp({
|
|
66
|
+
render: () =>
|
|
67
|
+
h("main", [
|
|
68
|
+
block("summary", Pair),
|
|
69
|
+
showSecond.value
|
|
70
|
+
? block("tabs", Section, [resolvedChild("statistics", Section)])
|
|
71
|
+
: null,
|
|
72
|
+
]),
|
|
73
|
+
});
|
|
74
|
+
app.component("DmsRecursiveComponent", RecursiveComponent);
|
|
75
|
+
app.mount(host);
|
|
76
|
+
await nextTick();
|
|
77
|
+
return showSecond;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.stubGlobal("useI18n", () => ({ t: String }));
|
|
82
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
83
|
+
vi.stubGlobal("computed", (getter: () => unknown) => ({
|
|
84
|
+
get value() {
|
|
85
|
+
return getter();
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
vi.stubGlobal("provide", () => {});
|
|
89
|
+
host = document.createElement("div");
|
|
90
|
+
document.body.append(host);
|
|
91
|
+
scrolled.length = 0;
|
|
92
|
+
Element.prototype.scrollIntoView = function (options?: unknown) {
|
|
93
|
+
scrolled.push({ element: this, options });
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
app?.unmount();
|
|
99
|
+
host.remove();
|
|
100
|
+
vi.unstubAllGlobals();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("scrollToBlock", () => {
|
|
104
|
+
it("scrolls a top-level block and a nested one by their key path", async () => {
|
|
105
|
+
await mountPage();
|
|
106
|
+
expect(scrollToBlock("tabs.statistics")).toBe(true);
|
|
107
|
+
expect(scrolled.at(-1)?.element).toBe(
|
|
108
|
+
host.querySelectorAll("section.section")[1],
|
|
109
|
+
);
|
|
110
|
+
expect(scrolled.at(-1)?.options).toEqual({ block: "start" });
|
|
111
|
+
expect(scrollToBlock("summary", { behavior: "smooth" })).toBe(true);
|
|
112
|
+
expect(scrolled.at(-1)).toEqual({
|
|
113
|
+
element: host.querySelector(".pair-head"),
|
|
114
|
+
options: { behavior: "smooth" },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("answers false for a block that is not on screen", async () => {
|
|
119
|
+
const showSecond = await mountPage();
|
|
120
|
+
expect(scrollToBlock("missing")).toBe(false);
|
|
121
|
+
showSecond.value = false;
|
|
122
|
+
await nextTick();
|
|
123
|
+
expect(scrollToBlock("tabs")).toBe(false);
|
|
124
|
+
expect(scrollToBlock("tabs.statistics")).toBe(false);
|
|
125
|
+
expect(scrolled).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("registerBlockAnchor", () => {
|
|
130
|
+
it("leaves an anchor to the block that took it over", () => {
|
|
131
|
+
const first = {} as ComponentPublicInstance;
|
|
132
|
+
const second = {
|
|
133
|
+
$: { subTree: { el: document.createElement("div") } },
|
|
134
|
+
} as unknown as ComponentPublicInstance;
|
|
135
|
+
registerBlockAnchor("chart", first);
|
|
136
|
+
registerBlockAnchor("chart", second);
|
|
137
|
+
registerBlockAnchor("chart", null, first);
|
|
138
|
+
expect(scrollToBlock("chart")).toBe(true);
|
|
139
|
+
registerBlockAnchor("chart", null, second);
|
|
140
|
+
expect(scrollToBlock("chart")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -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+/");
|
|
@@ -19,3 +19,11 @@ it("spaces the blocks of a tab the way the page spaces its own", () => {
|
|
|
19
19
|
),
|
|
20
20
|
);
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
// A guard holding two blocks drew them edge to edge: the page spaces its own
|
|
24
|
+
// wrapper, not the blocks inside it.
|
|
25
|
+
it("spaces the blocks of a guard the way the page spaces its own", () => {
|
|
26
|
+
expect(sourceOf("dms-ui/app/components/guard/Guard.vue")).toMatch(
|
|
27
|
+
new RegExp(`<div class="[^"]*\\b${BLOCK_SPACING}\\b[^"]*" data-dms-guard>`),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A tab set keeping its open tab in the URL writes the tab's slot
|
|
4
|
+
* (`?tab=tester`), which stays right when tabs are added or reordered, and
|
|
5
|
+
* still opens a tab named by its index (`?tab=2`). A tab's badge is a
|
|
6
|
+
* BlockText: an i18n key is written in the reader's language.
|
|
7
|
+
*/
|
|
8
|
+
import * as vue from "vue";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { useTab } from "../layers/dms-ui/app/composables/tab/useTab";
|
|
11
|
+
import { TabEvents } from "../layers/dms-ui/app/composables/tab/types/events";
|
|
12
|
+
import type { TabProps } from "../layers/dms-ui/app/composables/tab/types";
|
|
13
|
+
import { AxeOrientation } from "../layers/dms-core/app/types/orientation";
|
|
14
|
+
import { Color } from "../layers/dms-core/app/types/color";
|
|
15
|
+
import { Size } from "../layers/dms-core/app/types/size";
|
|
16
|
+
|
|
17
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
18
|
+
useChartFetch: () => ({ data: vue.ref(null) }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const ITEMS = [
|
|
22
|
+
{ label: "Statistics", slot: "statistics" },
|
|
23
|
+
{ label: "Documentation", slot: "documentation" },
|
|
24
|
+
{ label: "Tester", slot: "tester" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
let route: { query: Record<string, string> };
|
|
28
|
+
const push = vi.fn(async (to: { query: Record<string, string> }) => {
|
|
29
|
+
route.query = to.query;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function tabsAt(query: Record<string, string>) {
|
|
33
|
+
route.query = query;
|
|
34
|
+
return useTab(ITEMS.length, {
|
|
35
|
+
items: ITEMS,
|
|
36
|
+
persistState: true,
|
|
37
|
+
} as TabProps);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
route = vue.reactive({ query: {} });
|
|
42
|
+
push.mockClear();
|
|
43
|
+
for (const name of ["computed", "ref", "watch", "useTemplateRef"] as const)
|
|
44
|
+
vi.stubGlobal(name, vue[name]);
|
|
45
|
+
vi.stubGlobal("TabEvents", TabEvents);
|
|
46
|
+
vi.stubGlobal("isNumber", (value: unknown) => typeof value === "number");
|
|
47
|
+
vi.stubGlobal("isString", (value: unknown) => typeof value === "string");
|
|
48
|
+
vi.stubGlobal("useComponentEvent", () => ({ sendComponentEvent: vi.fn() }));
|
|
49
|
+
vi.stubGlobal("useWatch", () => ({
|
|
50
|
+
isLoading: vue.ref(false),
|
|
51
|
+
state: vue.ref({}),
|
|
52
|
+
}));
|
|
53
|
+
vi.stubGlobal("useDmsRoute", () => route);
|
|
54
|
+
vi.stubGlobal("useDmsRouter", () => ({ push }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
58
|
+
|
|
59
|
+
describe("a tab in the URL", () => {
|
|
60
|
+
it("opens the tab its slot names", () => {
|
|
61
|
+
expect(tabsAt({ tab: "tester" }).activeTab.value).toBe("2");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("still opens a tab named by its index", () => {
|
|
65
|
+
expect(tabsAt({ tab: "1" }).activeTab.value).toBe("1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("opens the first tab on a name it does not know", () => {
|
|
69
|
+
expect(tabsAt({ tab: "settings" }).activeTab.value).toBe("0");
|
|
70
|
+
expect(tabsAt({ tab: "7" }).activeTab.value).toBe("0");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("writes the open tab's slot, and nothing for the first", async () => {
|
|
74
|
+
const { goToTab } = tabsAt({});
|
|
75
|
+
goToTab(2);
|
|
76
|
+
await vue.nextTick();
|
|
77
|
+
expect(push).toHaveBeenLastCalledWith({ query: { tab: "tester" } });
|
|
78
|
+
goToTab("statistics");
|
|
79
|
+
await vue.nextTick();
|
|
80
|
+
expect(push).toHaveBeenLastCalledWith({ query: {} });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("follows a URL naming another slot", async () => {
|
|
84
|
+
const { activeTab } = tabsAt({});
|
|
85
|
+
route.query = { tab: "documentation" };
|
|
86
|
+
await vue.nextTick();
|
|
87
|
+
expect(activeTab.value).toBe("1");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("a tab's badge", () => {
|
|
92
|
+
interface TabsItem {
|
|
93
|
+
badge?: unknown;
|
|
94
|
+
}
|
|
95
|
+
let received: TabsItem[] = [];
|
|
96
|
+
let app: vue.App | undefined;
|
|
97
|
+
|
|
98
|
+
const Tabs = vue.defineComponent({
|
|
99
|
+
props: { items: { type: Array, default: () => [] } },
|
|
100
|
+
setup: (props) => () => {
|
|
101
|
+
received = props.items as TabsItem[];
|
|
102
|
+
return vue.h("div");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
async function badgesOf(badges: unknown[]): Promise<unknown[]> {
|
|
107
|
+
vi.stubGlobal("AxeOrientation", AxeOrientation);
|
|
108
|
+
vi.stubGlobal("Color", Color);
|
|
109
|
+
vi.stubGlobal("Size", Size);
|
|
110
|
+
vi.stubGlobal("useI18n", () => ({
|
|
111
|
+
locale: vue.ref("en"),
|
|
112
|
+
t: (key: string) => `t(${key})`,
|
|
113
|
+
}));
|
|
114
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
115
|
+
vi.stubGlobal("useTab", () => ({ activeTab: vue.ref("0") }));
|
|
116
|
+
vi.stubGlobal("useTabShortcuts", () => {});
|
|
117
|
+
const { default: Tab } = await import(
|
|
118
|
+
"../layers/dms-ui/app/components/tab/Tab.vue"
|
|
119
|
+
);
|
|
120
|
+
app = vue.createApp({
|
|
121
|
+
render: () =>
|
|
122
|
+
vue.h(Tab, {
|
|
123
|
+
items: badges.map((badge, index) => ({ ...ITEMS[index], badge })),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
app.component("UTabs", Tabs);
|
|
127
|
+
app.mount(document.createElement("div"));
|
|
128
|
+
await vue.nextTick();
|
|
129
|
+
app.unmount();
|
|
130
|
+
return received.map((item) => item.badge);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it("writes an i18n key, a composed text and a badge label in the reader's language", async () => {
|
|
134
|
+
expect(
|
|
135
|
+
await badgesOf([
|
|
136
|
+
"$api.tabs.beta",
|
|
137
|
+
{ key: "api.tabs.count" },
|
|
138
|
+
{ label: "$api.tabs.new", color: "primary" },
|
|
139
|
+
]),
|
|
140
|
+
).toEqual([
|
|
141
|
+
"t(api.tabs.beta)",
|
|
142
|
+
"t(api.tabs.count)",
|
|
143
|
+
{ label: "t(api.tabs.new)", color: "primary" },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps a count and a plain text as they are", async () => {
|
|
148
|
+
expect(await badgesOf([3, "Beta"])).toEqual([3, "Beta"]);
|
|
149
|
+
});
|
|
150
|
+
});
|