@antelopejs/dms 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +2 -2
  78. package/frontend-vue/pnpm-lock.yaml +5 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -17,6 +17,7 @@ import {
17
17
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
18
  import {
19
19
  replaceUrlVariables,
20
+ resolveTextVariables,
20
21
  resolveUrlVariables,
21
22
  } from "../layers/dms-ui/app/build/utils/urlVariables";
22
23
 
@@ -96,6 +97,53 @@ describe("resolveUrlVariables", () => {
96
97
  );
97
98
  });
98
99
 
100
+ it("encodes the values it fills, so each stays one parameter", () => {
101
+ expect(
102
+ resolveUrlVariables("/api/log/{{params.id}}?route={{query.route}}", {
103
+ routeParams: { id: "a b" },
104
+ routeQuery: { route: "GET /api/a?b=c&d" },
105
+ }),
106
+ ).toBe("/api/log/a%20b?route=GET%20%2Fapi%2Fa%3Fb%3Dc%26d");
107
+ });
108
+
109
+ // The router keeps the page path percent-encoded, and route parameters are
110
+ // read from it.
111
+ it("does not encode a route parameter twice", () => {
112
+ expect(
113
+ replaceUrlVariables("/api/ws/{{params.id}}", {
114
+ routeParams: { id: "caf%C3%A9%20bar" },
115
+ routeQuery: {},
116
+ }),
117
+ ).toBe("/api/ws/caf%C3%A9%20bar");
118
+ expect(
119
+ replaceUrlVariables("/api/ws/{{params.id}}", {
120
+ routeParams: { id: "100%" },
121
+ routeQuery: {},
122
+ }),
123
+ ).toBe("/api/ws/100%25");
124
+ });
125
+
126
+ it("writes response values as answered", () => {
127
+ expect(
128
+ replaceUrlVariables("{{response.redirectPath}}&tab={{query.tab}}", {
129
+ routeQuery: { tab: "a&b" },
130
+ response: { redirectPath: "/settings/users?invited=1" },
131
+ }),
132
+ ).toBe("/settings/users?invited=1&tab=a%26b");
133
+ });
134
+
135
+ it("fills a plain value without encoding it", () => {
136
+ expect(
137
+ resolveTextVariables("{{query.status}} / {{params.id}}", {
138
+ routeParams: { id: "ws-1" },
139
+ routeQuery: { status: "on hold" },
140
+ }),
141
+ ).toBe("on hold / ws-1");
142
+ expect(resolveTextVariables("{{query.status}}", { routeQuery: {} })).toBe(
143
+ undefined,
144
+ );
145
+ });
146
+
99
147
  it("leaves a URL without tokens as it is", () => {
100
148
  expect(resolveUrlVariables("/api/plain", { routeQuery: {} })).toBe(
101
149
  "/api/plain",
@@ -0,0 +1,155 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { type App, createApp, h, ref } from "vue";
4
+
5
+ const data = ref<Record<string, unknown> | null>(null);
6
+ const error = ref<unknown>(null);
7
+ const isLoading = ref(false);
8
+ const fetchOptions = vi.fn();
9
+
10
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
11
+ useChartFetch: (options: unknown) => {
12
+ fetchOptions(options);
13
+ return { data, isLoading, error, refresh: async () => {} };
14
+ },
15
+ }));
16
+
17
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
18
+ useWatch: () => ({ state: ref({}) }),
19
+ }));
20
+
21
+ let app: App | undefined;
22
+
23
+ const flushPromises = () => new Promise((resolve) => setTimeout(resolve));
24
+ let host: HTMLDivElement;
25
+
26
+ async function mount(path: string, props: Record<string, unknown>) {
27
+ const { default: component } = await import(path);
28
+ app = createApp({ setup: () => () => h(component, props) });
29
+ for (const name of ["UIcon", "USkeleton", "UButton"]) {
30
+ app.component(name, (_, { attrs }) =>
31
+ h("i", { ...attrs, "data-stub": name }),
32
+ );
33
+ }
34
+ app.mount(host);
35
+ await flushPromises();
36
+ await vi.dynamicImportSettled();
37
+ await flushPromises();
38
+ }
39
+
40
+ const mountSnippet = (props: Record<string, unknown>) =>
41
+ mount("../layers/dms-ui/app/components/code-snippet/CodeSnippet.vue", props);
42
+ const mountBlock = (props: Record<string, unknown>) =>
43
+ mount("../layers/dms-ui/app/components/blocks/CodeBlock.vue", {
44
+ componentId: "code",
45
+ pageId: "page",
46
+ ...props,
47
+ });
48
+
49
+ const pre = () => host.querySelector("pre")!;
50
+ const copyButton = () => host.querySelector("button");
51
+
52
+ beforeEach(() => {
53
+ data.value = null;
54
+ error.value = null;
55
+ isLoading.value = false;
56
+ fetchOptions.mockClear();
57
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
58
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
59
+ host = document.createElement("div");
60
+ document.body.append(host);
61
+ });
62
+
63
+ afterEach(() => {
64
+ app?.unmount();
65
+ host.remove();
66
+ vi.unstubAllGlobals();
67
+ });
68
+
69
+ describe("DmsCodeSnippet", () => {
70
+ it("highlights the code in its language, under a head naming it", async () => {
71
+ await mountSnippet({
72
+ code: '{ "id": 1 }',
73
+ language: "json",
74
+ title: "Body",
75
+ });
76
+ expect(pre().textContent).toBe('{ "id": 1 }');
77
+ expect(pre().querySelector('[style*="--ui-info"]')?.textContent).toBe(
78
+ '"id"',
79
+ );
80
+ expect(host.textContent).toContain("Body");
81
+ expect(host.textContent).toContain("JSON");
82
+ expect(copyButton()).not.toBeNull();
83
+ });
84
+
85
+ it("reads an alias as its language", async () => {
86
+ await mountSnippet({ code: "pnpm install", language: "bash" });
87
+ expect(pre().dataset.language).toBe("shell");
88
+ expect(host.textContent).toContain("Shell");
89
+ });
90
+
91
+ it("drops the copy button and the head when nothing is left to show", async () => {
92
+ await mountSnippet({ code: "plain words", copy: false });
93
+ expect(copyButton()).toBeNull();
94
+ expect(pre().previousElementSibling).toBeNull();
95
+ });
96
+
97
+ it("wraps long lines or scrolls them, and caps the height in lines", async () => {
98
+ await mountSnippet({ code: "a\nb\nc", wrap: true, maxLines: 2 });
99
+ expect(pre().className).toContain("whitespace-pre-wrap");
100
+ expect(pre().style.maxHeight).toBe("54px");
101
+ app?.unmount();
102
+ await mountSnippet({ code: "a" });
103
+ expect(pre().className).toContain("whitespace-pre");
104
+ expect(pre().className).not.toContain("whitespace-pre-wrap");
105
+ expect(pre().style.maxHeight).toBe("");
106
+ });
107
+ });
108
+
109
+ describe("CodeBlock", () => {
110
+ it("shows its static code and title", async () => {
111
+ await mountBlock({ code: "SELECT 1", language: "sql", title: "Query" });
112
+ expect(pre().textContent).toBe("SELECT 1");
113
+ expect(host.textContent).toContain("Query");
114
+ });
115
+
116
+ it("replaces the code and the language with what its route answers", async () => {
117
+ data.value = { code: "echo hi", language: "shell" };
118
+ await mountBlock({
119
+ code: "old",
120
+ language: "json",
121
+ fetchUrl: "/api/snippet",
122
+ });
123
+ expect(fetchOptions).toHaveBeenCalledWith(
124
+ expect.objectContaining({ fetchUrl: "/api/snippet" }),
125
+ );
126
+ expect(pre().textContent).toBe("echo hi");
127
+ expect(pre().dataset.language).toBe("shell");
128
+ });
129
+
130
+ it("draws placeholder lines until the first answer", async () => {
131
+ isLoading.value = true;
132
+ await mountBlock({ fetchUrl: "/api/snippet" });
133
+ expect(host.querySelector("pre")).toBeNull();
134
+ expect(host.querySelector("[aria-busy]")).not.toBeNull();
135
+ });
136
+
137
+ it("offers a retry when the first load fails", async () => {
138
+ error.value = new Error("network");
139
+ await mountBlock({ fetchUrl: "/api/snippet" });
140
+ expect(host.querySelector("pre")).toBeNull();
141
+ expect(host.textContent).toContain("dms.blocks.load_error");
142
+ });
143
+ });
144
+
145
+ describe("EmptyState block", () => {
146
+ it("shows a snippet between its text and its actions", async () => {
147
+ await mount("../layers/dms-ui/app/components/blocks/EmptyStateBlock.vue", {
148
+ title: "No email sent yet",
149
+ code: { content: "await mail.send({ to })", language: "typescript" },
150
+ });
151
+ expect(pre().textContent).toBe("await mail.send({ to })");
152
+ expect(host.textContent).toContain("TypeScript");
153
+ expect(copyButton()).not.toBeNull();
154
+ });
155
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type CodeToken,
4
+ highlightCode,
5
+ } from "../layers/dms-ui/app/build/utils/codeHighlight";
6
+ import { resolveCodeLanguage } from "../layers/dms-ui/app/build/utils/codeLanguages";
7
+
8
+ const KEYWORD = "var(--ui-primary)";
9
+ const STRING = "var(--ui-success)";
10
+ const NUMBER = "var(--ui-warning)";
11
+ const COMMENT = "var(--ui-text-dimmed)";
12
+ const PROPERTY = "var(--ui-info)";
13
+
14
+ const colorOf = (tokens: CodeToken[], text: string) =>
15
+ tokens.find((token) => token.text === text)?.style?.color;
16
+
17
+ const joined = (tokens: CodeToken[]) =>
18
+ tokens.map((token) => token.text).join("");
19
+
20
+ describe("read-only code highlighting", () => {
21
+ it("keeps every character of the code, in order", () => {
22
+ const code = '{\n "name": "media",\n "size": 42\n}';
23
+ expect(joined(highlightCode(code, "json"))).toBe(code);
24
+ });
25
+
26
+ it("colours JSON keys, strings and numbers", () => {
27
+ const tokens = highlightCode('{ "name": "media", "size": 42 }', "json");
28
+ expect(colorOf(tokens, '"name"')).toBe(PROPERTY);
29
+ expect(colorOf(tokens, '"media"')).toBe(STRING);
30
+ expect(colorOf(tokens, "42")).toBe(NUMBER);
31
+ });
32
+
33
+ it("reads TypeScript, SQL and HTML with the editor's languages", () => {
34
+ expect(
35
+ colorOf(highlightCode("const x: number = 1;", "typescript"), "const"),
36
+ ).toBe(KEYWORD);
37
+ expect(
38
+ colorOf(highlightCode("SELECT id FROM users", "sql"), "SELECT"),
39
+ ).toBe(KEYWORD);
40
+ expect(colorOf(highlightCode('<a href="/">x</a>', "html"), "a")).toBe(
41
+ KEYWORD,
42
+ );
43
+ });
44
+
45
+ it("marks a shell command, its flags, strings, variables and comments", () => {
46
+ const code =
47
+ 'curl -X POST "$API_URL" \\\n -H "Accept: json" # send\necho $HOME | grep root';
48
+ const tokens = highlightCode(code, "shell");
49
+ expect(joined(tokens)).toBe(code);
50
+ expect(colorOf(tokens, "curl")).toBe(KEYWORD);
51
+ expect(colorOf(tokens, "-X")).toBe(PROPERTY);
52
+ expect(colorOf(tokens, '"$API_URL"')).toBe(STRING);
53
+ expect(colorOf(tokens, "-H")).toBe(PROPERTY);
54
+ expect(colorOf(tokens, "# send")).toBe(COMMENT);
55
+ expect(colorOf(tokens, "echo")).toBe(KEYWORD);
56
+ expect(colorOf(tokens, "$HOME")).toBe(NUMBER);
57
+ expect(colorOf(tokens, "grep")).toBe(KEYWORD);
58
+ expect(colorOf(tokens, "root")).toBeUndefined();
59
+ });
60
+
61
+ it("shows plain text and unknown languages as written", () => {
62
+ expect(highlightCode("SELECT 1", "text")).toEqual([{ text: "SELECT 1" }]);
63
+ expect(highlightCode("x = 1", "cobol")).toEqual([{ text: "x = 1" }]);
64
+ });
65
+
66
+ it("reads the usual aliases as their language", () => {
67
+ expect(resolveCodeLanguage("bash")).toBe("shell");
68
+ expect(resolveCodeLanguage("TS")).toBe("typescript");
69
+ expect(resolveCodeLanguage("plain")).toBe("text");
70
+ expect(resolveCodeLanguage(undefined)).toBe("text");
71
+ });
72
+ });
@@ -57,6 +57,11 @@ vi.mock(
57
57
  () => ({ useComponentEvent: () => ({}) }),
58
58
  );
59
59
 
60
+ vi.mock(
61
+ "../layers/dms-ui/app/build/composables/actions/useActionTargets",
62
+ () => ({ useActionTargets: () => ({ handleCustomButton: () => {} }) }),
63
+ );
64
+
60
65
  // The generic components only lay out what the block resolved: their props
61
66
  // are what is checked here.
62
67
  const propsProbe = (name: string, keys: string[]) => ({
@@ -0,0 +1,391 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * ActivityFeed, Meter, TopListCard and NavCardGrid draw a composed text
4
+ * wherever they draw a text — from their options or from the JSON their
5
+ * route answers — in the reader's language, and keep drawing plain and `$`
6
+ * strings as before.
7
+ */
8
+ import {
9
+ computed,
10
+ createApp,
11
+ defineComponent,
12
+ h,
13
+ reactive,
14
+ ref,
15
+ type Component,
16
+ } from "vue";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import { createI18n } from "vue-i18n";
19
+ import { resolveI18nKey } from "../layers/dms-core/app/composables/translation/useTranslation";
20
+ import { setRegionalPreferencesSource } from "../layers/dms-core/app/utils/regional";
21
+
22
+ const NOW = Date.UTC(2026, 9, 10, 12);
23
+ const THREE_MINUTES_AGO = new Date(NOW - 3 * 60 * 1000).toISOString();
24
+
25
+ const i18n = createI18n({
26
+ legacy: false,
27
+ locale: "en",
28
+ fallbackLocale: "en",
29
+ missingWarn: false,
30
+ fallbackWarn: false,
31
+ messages: {
32
+ en: {
33
+ mail: {
34
+ to_review:
35
+ "no message to review | {count} message to review | {count} messages to review",
36
+ bounced: "{count} bounced on {list}",
37
+ device: "{browser} on {os}",
38
+ signed_in: "Signed in on {device}",
39
+ sent: "Sent {when}",
40
+ },
41
+ media: {
42
+ files: "{count} file | {count} files",
43
+ used: "{size} used",
44
+ quota: "{used} of {total}",
45
+ library: "Library",
46
+ new: "New",
47
+ },
48
+ api: { last_call: "Last call {at}" },
49
+ },
50
+ fr: {
51
+ mail: {
52
+ to_review:
53
+ "aucun message à traiter | {count} message à traiter | {count} messages à traiter",
54
+ bounced: "{count} rejetés sur {list}",
55
+ device: "{browser} sous {os}",
56
+ signed_in: "Connexion sur {device}",
57
+ sent: "Envoyé {when}",
58
+ },
59
+ media: {
60
+ files: "{count} fichier | {count} fichiers",
61
+ used: "{size} utilisés",
62
+ quota: "{used} sur {total}",
63
+ library: "Médiathèque",
64
+ new: "Nouveau",
65
+ },
66
+ api: { last_call: "Dernier appel {at}" },
67
+ },
68
+ },
69
+ });
70
+
71
+ const meterData = ref<Record<string, unknown> | null>(null);
72
+
73
+ vi.stubGlobal("useAuthFetch", () => ({
74
+ $authFetch: async () => ({ items: [] }),
75
+ }));
76
+ vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
77
+
78
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
79
+ useWatch: () => ({ state: ref({}) }),
80
+ }));
81
+ vi.mock(
82
+ "../layers/dms-core/app/composables/components/useComponentEvent",
83
+ () => ({ useComponentEvent: () => ({}) }),
84
+ );
85
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
86
+ useChartFetch: () => ({
87
+ data: meterData,
88
+ isLoading: ref(false),
89
+ error: ref(null),
90
+ refresh: async () => {},
91
+ }),
92
+ }));
93
+ vi.mock(
94
+ "#dms-layout/app/build/composables/navigation/useCategoryNavCards",
95
+ () => ({
96
+ useCategoryNavCards: () => ({ cards: computed(() => []) }),
97
+ usePreviewEntryVeil: () => ({
98
+ state: () => null,
99
+ label: () => "",
100
+ detail: () => undefined,
101
+ isActive: ref(false),
102
+ }),
103
+ }),
104
+ );
105
+
106
+ // The generic components only lay out what the block resolved: their props
107
+ // are what is checked here.
108
+ const { propsProbe } = vi.hoisted(() => ({
109
+ propsProbe: (name: string, keys: string[]) => async () => {
110
+ const vue = await import("vue");
111
+ return {
112
+ default: vue.defineComponent({
113
+ props: keys,
114
+ setup:
115
+ (props, { slots }) =>
116
+ () =>
117
+ vue.h("div", [
118
+ vue.h("pre", { "data-probe": name }, JSON.stringify(props)),
119
+ slots.default?.(),
120
+ ]),
121
+ }),
122
+ };
123
+ },
124
+ }));
125
+ vi.mock(
126
+ "../layers/dms-ui/app/components/activity/ActivityItem.vue",
127
+ propsProbe("activity-item", ["title", "subtitle", "trailing"]),
128
+ );
129
+ vi.mock(
130
+ "../layers/dms-ui/app/components/meter/Meter.vue",
131
+ propsProbe("meter", ["label", "hint", "valueLabel", "segments"]),
132
+ );
133
+ vi.mock(
134
+ "../layers/dms-ui/app/components/card/NavCard.vue",
135
+ propsProbe("nav-card", ["title", "description", "state", "tag", "readout"]),
136
+ );
137
+ vi.mock(
138
+ "../layers/dms-ui/app/build/components/permission/PermissionVeil.vue",
139
+ propsProbe("veil", []),
140
+ );
141
+
142
+ const ActivityFeed = (
143
+ await import("../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue")
144
+ ).default;
145
+ const MeterBlock = (
146
+ await import("../layers/dms-ui/app/components/meter/MeterBlock.vue")
147
+ ).default;
148
+ const TopListRow = (
149
+ await import(
150
+ "../layers/dms-ui/app/components/top-list/internal/TopListRow.vue"
151
+ )
152
+ ).default;
153
+ const NavCardGridBlock = (
154
+ await import("../layers/dms-ui/app/components/blocks/NavCardGridBlock.vue")
155
+ ).default;
156
+
157
+ const mounted: Array<{ unmount: () => void }> = [];
158
+ const plainSpaces = (text: string) => text.replace(/[\u00a0\u202f]/g, " ");
159
+
160
+ function mount(component: Component, props: Record<string, unknown>) {
161
+ const root = document.createElement("div");
162
+ const app = createApp(component, props);
163
+ app.component(
164
+ "DmsCard",
165
+ defineComponent({
166
+ setup:
167
+ (_, { slots }) =>
168
+ () =>
169
+ h("div", slots.default?.()),
170
+ }),
171
+ );
172
+ app.mount(root);
173
+ mounted.push(app);
174
+ return root;
175
+ }
176
+
177
+ function probes(root: HTMLElement, name: string): Record<string, unknown>[] {
178
+ return [...root.querySelectorAll(`[data-probe="${name}"]`)].map((probe) =>
179
+ JSON.parse(plainSpaces(probe.textContent ?? "{}")),
180
+ );
181
+ }
182
+
183
+ const count = (value: number) => ({ type: "count", value });
184
+
185
+ beforeEach(() => {
186
+ vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
187
+ i18n.global.locale.value = "en";
188
+ meterData.value = null;
189
+ setRegionalPreferencesSource(() => ({ timeZone: "UTC" }));
190
+ vi.stubGlobal("useI18n", () => ({
191
+ t: i18n.global.t,
192
+ locale: i18n.global.locale,
193
+ }));
194
+ vi.stubGlobal("useTranslation", () => ({
195
+ processI18n: (key: string, params?: Record<string, unknown> | null) =>
196
+ resolveI18nKey(i18n.global.t, key, params),
197
+ }));
198
+ vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
199
+ });
200
+
201
+ afterEach(() => {
202
+ mounted.splice(0).forEach((app) => app.unmount());
203
+ setRegionalPreferencesSource(() => ({}));
204
+ vi.useRealTimers();
205
+ });
206
+
207
+ describe("ActivityFeed", () => {
208
+ const items = [
209
+ {
210
+ id: "review",
211
+ title: { key: "mail.to_review", params: { count: count(3) } },
212
+ meta: [
213
+ {
214
+ key: "mail.sent",
215
+ params: { when: { type: "relative", value: THREE_MINUTES_AGO } },
216
+ },
217
+ "Inbox",
218
+ ],
219
+ time: { key: "media.new" },
220
+ },
221
+ {
222
+ id: "bounced",
223
+ title: "$mail.bounced",
224
+ params: { count: count(1284), list: "Newsletter" },
225
+ },
226
+ {
227
+ id: "legacy",
228
+ title: "$mail.signed_in",
229
+ meta: ["$mail.device"],
230
+ params: { device: "$mail.device", browser: "Chrome", os: "Windows" },
231
+ },
232
+ ];
233
+
234
+ it("composes titles, details and the trailing text of its entries", () => {
235
+ const root = mount(ActivityFeed, { items, groupByDay: false, card: false });
236
+
237
+ expect(probes(root, "activity-item")).toEqual([
238
+ {
239
+ title: "3 messages to review",
240
+ subtitle: "Sent 3 minutes ago · Inbox",
241
+ trailing: "New",
242
+ },
243
+ { title: "1,284 bounced on Newsletter" },
244
+ {
245
+ title: "Signed in on Chrome on Windows",
246
+ subtitle: "Chrome on Windows",
247
+ },
248
+ ]);
249
+ });
250
+
251
+ it("picks the plural form of a `$` title from its count value, in French", () => {
252
+ i18n.global.locale.value = "fr";
253
+ const root = mount(ActivityFeed, {
254
+ items: [
255
+ { title: "$mail.to_review", params: { count: count(1) } },
256
+ {
257
+ title: "$mail.signed_in",
258
+ params: { device: "$mail.device", browser: "Firefox", os: "Linux" },
259
+ },
260
+ ],
261
+ groupByDay: false,
262
+ card: false,
263
+ });
264
+
265
+ expect(probes(root, "activity-item").map((item) => item.title)).toEqual([
266
+ "1 message à traiter",
267
+ "Connexion sur Firefox sous Linux",
268
+ ]);
269
+ });
270
+ });
271
+
272
+ describe("MeterBlock", () => {
273
+ it("composes the hint, the value text and the segment legends", () => {
274
+ i18n.global.locale.value = "fr";
275
+ const root = mount(MeterBlock, {
276
+ label: "$media.library",
277
+ hint: { key: "media.files", params: { count: count(2) } },
278
+ valueLabel: {
279
+ key: "media.quota",
280
+ params: {
281
+ used: { type: "number", value: 0.42, format: "percent" },
282
+ total: "10 Go",
283
+ },
284
+ },
285
+ segments: [
286
+ {
287
+ value: 3,
288
+ label: { key: "media.files", params: { count: count(1) } },
289
+ },
290
+ { value: 2, label: "Plain" },
291
+ ],
292
+ });
293
+
294
+ expect(probes(root, "meter")).toEqual([
295
+ {
296
+ label: "Médiathèque",
297
+ hint: "2 fichiers",
298
+ valueLabel: "42 % sur 10 Go",
299
+ segments: [
300
+ { value: 3, label: "1 fichier" },
301
+ { value: 2, label: "Plain" },
302
+ ],
303
+ },
304
+ ]);
305
+ });
306
+
307
+ it("composes the texts its route answers", () => {
308
+ meterData.value = {
309
+ value: 4,
310
+ max: 10,
311
+ hint: { key: "media.used", params: { size: "4 GB" } },
312
+ };
313
+ const root = mount(MeterBlock, { fetchUrl: "/api/storage" });
314
+
315
+ expect(probes(root, "meter")[0]?.hint).toBe("4 GB used");
316
+ });
317
+ });
318
+
319
+ describe("TopListRow", () => {
320
+ it("composes the title and the description of a row", () => {
321
+ i18n.global.locale.value = "fr";
322
+ const root = mount(TopListRow, {
323
+ item: {
324
+ id: "orders",
325
+ title: "$media.library",
326
+ description: {
327
+ key: "api.last_call",
328
+ params: { at: { type: "relative", value: THREE_MINUTES_AGO } },
329
+ },
330
+ value: 12,
331
+ },
332
+ showRank: false,
333
+ rankLabel: "1",
334
+ rankClass: "",
335
+ hasAnyIcon: false,
336
+ showSparkline: false,
337
+ sparklineAccent: "primary",
338
+ showDelta: false,
339
+ formattedValue: "12",
340
+ });
341
+
342
+ expect(plainSpaces(root.textContent ?? "")).toContain("Médiathèque");
343
+ expect(plainSpaces(root.textContent ?? "")).toContain(
344
+ "Dernier appel il y a 3 minutes",
345
+ );
346
+ });
347
+
348
+ it("keeps a plain title as written", () => {
349
+ const root = mount(TopListRow, {
350
+ item: { id: 1, title: "GET /orders", value: 1 },
351
+ showRank: false,
352
+ rankLabel: "1",
353
+ rankClass: "",
354
+ hasAnyIcon: false,
355
+ showSparkline: false,
356
+ sparklineAccent: "primary",
357
+ showDelta: false,
358
+ formattedValue: "1",
359
+ });
360
+
361
+ expect(root.textContent).toContain("GET /orders");
362
+ });
363
+ });
364
+
365
+ describe("NavCardGridBlock", () => {
366
+ it("composes the title, description, tag, state and readout of a card", () => {
367
+ const root = mount(NavCardGridBlock, {
368
+ items: [
369
+ {
370
+ icon: "i-ph-images",
371
+ to: "/media",
372
+ title: { key: "media.library" },
373
+ description: "$media.new",
374
+ tag: { key: "media.new" },
375
+ state: { key: "media.files", params: { count: count(1284) } },
376
+ readout: [{ key: "media.used", params: { size: "4 GB" } }, "plain"],
377
+ },
378
+ ],
379
+ });
380
+
381
+ expect(probes(root, "nav-card")).toEqual([
382
+ {
383
+ title: "Library",
384
+ description: "New",
385
+ tag: "New",
386
+ state: "1,284 files",
387
+ readout: ["4 GB used", "plain"],
388
+ },
389
+ ]);
390
+ });
391
+ });