@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  3. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  5. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  6. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  7. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  8. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  9. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  10. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  11. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +1 -1
  12. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -10
  13. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  14. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  15. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  16. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  17. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  18. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  19. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  20. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  21. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  22. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  23. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  24. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  25. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  26. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  27. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  28. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -1
  29. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  30. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  31. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  32. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +3 -2
  33. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  34. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  35. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  36. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  37. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  39. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  40. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  41. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  42. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  43. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  44. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  45. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  46. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  47. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  48. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  49. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  50. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  51. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  52. package/frontend-vue/tests/code-block.test.ts +28 -0
  53. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  54. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  55. package/frontend-vue/tests/header-actions.test.ts +1 -1
  56. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  57. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  58. package/frontend-vue/tests/module-routing.test.ts +139 -0
  59. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  60. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  61. package/frontend-vue/tests/regional-format.test.ts +69 -2
  62. package/frontend-vue/tests/scroll-to-block.test.ts +142 -0
  63. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  64. package/frontend-vue/tests/tab-slot-spacing.test.ts +8 -0
  65. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  66. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  67. package/package.json +2 -2
  68. package/skills/dms/SKILL.md +28 -4
  69. package/skills/dms-module/SKILL.md +2 -1
@@ -0,0 +1,141 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What a `Form` offers a custom field through `useEnclosingForm`: submitting
4
+ * the form as its save button does, a validator that can stop a submit and
5
+ * leaves with its component, and focusing a field by id. A page opened on a
6
+ * field's anchor focuses that field.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { createApp, defineComponent, h } from "vue";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import {
13
+ FORM_HANDLE_KEY,
14
+ type FormHandle,
15
+ linkedFieldId,
16
+ } from "../layers/dms-ui/app/build/composables/form/enclosingForm";
17
+ import {
18
+ type EnclosingForm,
19
+ useEnclosingForm,
20
+ } from "../layers/dms-ui/app/composables/form/useEnclosingForm";
21
+ import type { FormValidator } from "../layers/dms-ui/app/composables/form/types/validation";
22
+
23
+ const FIELD_IDS = ["retentionDays", "requestLogMaxBodyKb"];
24
+ const mounted: Array<{ unmount: () => void }> = [];
25
+
26
+ afterEach(() => mounted.splice(0).forEach((app) => app.unmount()));
27
+
28
+ interface FakeForm {
29
+ handle: FormHandle;
30
+ validators: Set<FormValidator>;
31
+ }
32
+
33
+ function fakeForm(): FakeForm {
34
+ const validators = new Set<FormValidator>();
35
+ return {
36
+ validators,
37
+ handle: {
38
+ submit: vi.fn(async () => {}),
39
+ registerValidator: (validator) => {
40
+ validators.add(validator);
41
+ return () => validators.delete(validator);
42
+ },
43
+ revealField: vi.fn(async () => {}),
44
+ },
45
+ };
46
+ }
47
+
48
+ function mountField(
49
+ handle: FormHandle | null,
50
+ setup: (form: EnclosingForm | null) => void,
51
+ ) {
52
+ const app = createApp(
53
+ defineComponent({
54
+ setup() {
55
+ setup(useEnclosingForm());
56
+ return () => h("div");
57
+ },
58
+ }),
59
+ );
60
+ if (handle) app.provide(FORM_HANDLE_KEY, handle);
61
+ app.mount(document.createElement("div"));
62
+ mounted.push(app);
63
+ return app;
64
+ }
65
+
66
+ describe("useEnclosingForm", () => {
67
+ it("is null outside a Form", () => {
68
+ let form: EnclosingForm | null | undefined;
69
+ mountField(null, (enclosing) => (form = enclosing));
70
+ expect(form).toBeNull();
71
+ });
72
+
73
+ it("submits the form and focuses a field through the form", async () => {
74
+ const { handle } = fakeForm();
75
+ let form: EnclosingForm | null = null;
76
+ mountField(handle, (enclosing) => (form = enclosing));
77
+
78
+ await form!.submit();
79
+ await form!.focusField("retentionDays");
80
+
81
+ expect(handle.submit).toHaveBeenCalledOnce();
82
+ expect(handle.revealField).toHaveBeenCalledWith("retentionDays");
83
+ });
84
+
85
+ it("adds a validator that leaves with its component", () => {
86
+ const { handle, validators } = fakeForm();
87
+ const validator = () => false;
88
+ const app = mountField(handle, (form) => form?.addValidator(validator));
89
+
90
+ expect([...validators]).toEqual([validator]);
91
+
92
+ app.unmount();
93
+ mounted.splice(mounted.indexOf(app), 1);
94
+ expect(validators.size).toBe(0);
95
+ });
96
+
97
+ it("removes a validator on request", () => {
98
+ const { handle, validators } = fakeForm();
99
+ let remove: (() => void) | undefined;
100
+ mountField(handle, (form) => (remove = form?.addValidator(() => true)));
101
+
102
+ remove?.();
103
+ expect(validators.size).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe("a field's anchor", () => {
108
+ it("names a field of the form by its id", () => {
109
+ expect(linkedFieldId("#retentionDays", FIELD_IDS)).toBe("retentionDays");
110
+ expect(
111
+ linkedFieldId("#request%4CogMaxBodyKb", ["requestLogMaxBodyKb"]),
112
+ ).toBe("requestLogMaxBodyKb");
113
+ });
114
+
115
+ it("names nothing for another anchor, none, or a broken one", () => {
116
+ expect(linkedFieldId("#dms-form-settings-section-logs", FIELD_IDS)).toBe(
117
+ undefined,
118
+ );
119
+ expect(linkedFieldId("", FIELD_IDS)).toBe(undefined);
120
+ expect(linkedFieldId("#%E0%A4%A", FIELD_IDS)).toBe(undefined);
121
+ });
122
+ });
123
+
124
+ describe("the Form", () => {
125
+ const formSource = readFileSync(
126
+ resolve(__dirname, "../layers/dms-ui/app/components/form/Form.vue"),
127
+ "utf8",
128
+ );
129
+
130
+ it("offers its fields the submit, its validators and the focus", () => {
131
+ expect(formSource).toMatch(
132
+ /provide\(FORM_HANDLE_KEY, \{\s*submit: async \(\) => \{\s*await form\.value\?\.submit\(\);\s*\},\s*registerValidator,\s*revealField,\s*\}\);/,
133
+ );
134
+ });
135
+
136
+ it("follows the anchor of a field once mounted, a page form only", () => {
137
+ expect(formSource).toMatch(
138
+ /if \(!inFormContainer && !container\) \{\s*const followFieldAnchor = \(\) => \{\s*const fieldId = linkedFieldId\(window\.location\.hash, allFieldIds\.value\);/,
139
+ );
140
+ });
141
+ });
@@ -28,7 +28,7 @@ vi.mock(
28
28
  "../layers/dms-layout/app/build/components/layout/QuickActionsPopover.vue",
29
29
  () => ({ default: () => null }),
30
30
  );
31
- vi.mock("#dms-core/app/build/composables/page/usePageRecordLabel", () => ({
31
+ vi.mock("#dms-core/app/composables/page/usePageRecordLabel", () => ({
32
32
  usePageRecordLabel: () => ({ label: ref(null) }),
33
33
  }));
34
34
 
@@ -0,0 +1,176 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A number field keeps a typed value within its bounds and on its steps when
4
+ * it is left or on Enter. It says so with `adjust` — 1020 typed in a field of
5
+ * steps of 50 kept as 1,000 — and draws the kept value again, so a module
6
+ * shows "1020 → 1,000" without remounting the field.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import {
10
+ type App,
11
+ type Component,
12
+ createApp,
13
+ defineComponent,
14
+ h,
15
+ nextTick,
16
+ ref,
17
+ } from "vue";
18
+ import {
19
+ numberAdjustment,
20
+ parseTypedNumber,
21
+ } from "../layers/dms-ui/app/build/utils/numberAdjustment";
22
+ import type { InputNumberAdjustment } from "../layers/dms-ui/app/types/input-number";
23
+
24
+ let app: App | undefined;
25
+ let host: HTMLDivElement;
26
+
27
+ // The Nuxt UI field as far as the wrapper sees it: it applies the typed text
28
+ // on blur or Enter, kept within `min` / `max` and on `step`, then emits blur.
29
+ const NumberField = defineComponent({
30
+ inheritAttrs: false,
31
+ props: {
32
+ modelValue: { type: Number, default: undefined },
33
+ min: { type: Number, default: undefined },
34
+ max: { type: Number, default: undefined },
35
+ step: { type: Number, default: 1 },
36
+ },
37
+ emits: ["update:modelValue", "blur"],
38
+ setup(props, { emit }) {
39
+ const apply = (input: HTMLInputElement) => {
40
+ const typed = Number(input.value.replace(/,/g, ""));
41
+ const bounded = Math.min(
42
+ props.max ?? Infinity,
43
+ Math.max(props.min ?? -Infinity, typed),
44
+ );
45
+ emit("update:modelValue", Math.round(bounded / props.step) * props.step);
46
+ };
47
+ return () =>
48
+ h("input", {
49
+ onBlur: (event: FocusEvent) => {
50
+ apply(event.target as HTMLInputElement);
51
+ emit("blur", event);
52
+ },
53
+ onKeydown: (event: KeyboardEvent) => {
54
+ if (event.key === "Enter") apply(event.target as HTMLInputElement);
55
+ },
56
+ });
57
+ },
58
+ });
59
+
60
+ async function mount(props: Record<string, unknown>) {
61
+ const value = ref<number | undefined>(props.modelValue as number);
62
+ const adjustments: InputNumberAdjustment[] = [];
63
+ const field = (
64
+ await import(
65
+ "../layers/dms-ui/app/components/form/components/InputNumber.vue"
66
+ )
67
+ ).default as Component;
68
+ app = createApp({
69
+ render: () =>
70
+ h(field, {
71
+ ...props,
72
+ modelValue: value.value,
73
+ "onUpdate:modelValue": (next: number) => (value.value = next),
74
+ onAdjust: (adjustment: InputNumberAdjustment) =>
75
+ adjustments.push(adjustment),
76
+ }),
77
+ });
78
+ app.component("UInputNumber", NumberField);
79
+ app.mount(host);
80
+ await nextTick();
81
+ return { value, adjustments, input: host.querySelector("input")! };
82
+ }
83
+
84
+ async function type(input: HTMLInputElement, text: string) {
85
+ input.value = text;
86
+ input.dispatchEvent(new Event("input", { bubbles: true }));
87
+ }
88
+
89
+ async function settled() {
90
+ await nextTick();
91
+ await nextTick();
92
+ }
93
+
94
+ beforeEach(() => {
95
+ vi.stubGlobal("useTranslation", () => ({
96
+ processI18n: (text: string) => text,
97
+ }));
98
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en") }));
99
+ host = document.createElement("div");
100
+ document.body.append(host);
101
+ });
102
+
103
+ afterEach(() => {
104
+ app?.unmount();
105
+ host.remove();
106
+ vi.unstubAllGlobals();
107
+ });
108
+
109
+ describe("parseTypedNumber", () => {
110
+ it("reads a number as the locale writes it", () => {
111
+ expect(parseTypedNumber("1,020", "en")).toBe(1020);
112
+ expect(parseTypedNumber(" -100 ", "en")).toBe(-100);
113
+ expect(parseTypedNumber("1 020,5", "fr")).toBe(1020.5);
114
+ expect(parseTypedNumber("", "en")).toBeNull();
115
+ expect(parseTypedNumber("abc", "en")).toBeNull();
116
+ });
117
+ });
118
+
119
+ describe("numberAdjustment", () => {
120
+ it("names the bound or the step the value was kept on", () => {
121
+ const bounds = { min: 0, max: 5000 };
122
+ expect(numberAdjustment(9000, 5000, bounds)?.reason).toBe("max");
123
+ expect(numberAdjustment(-3, 0, bounds)?.reason).toBe("min");
124
+ expect(numberAdjustment(1020, 1000, bounds)).toEqual({
125
+ typed: 1020,
126
+ value: 1000,
127
+ reason: "step",
128
+ });
129
+ expect(numberAdjustment(1000, 1000, bounds)).toBeNull();
130
+ expect(numberAdjustment(null, 1000, bounds)).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("DmsInputNumber adjust", () => {
135
+ it("says the field kept a typed value on its steps when it is left", async () => {
136
+ const { input, adjustments, value } = await mount({
137
+ modelValue: 1000,
138
+ step: 50,
139
+ min: 0,
140
+ });
141
+ await type(input, "1020");
142
+ input.dispatchEvent(new FocusEvent("blur"));
143
+ await settled();
144
+ expect(value.value).toBe(1000);
145
+ expect(adjustments).toEqual([{ typed: 1020, value: 1000, reason: "step" }]);
146
+ });
147
+
148
+ it("says the field kept a typed value within its maximum on Enter", async () => {
149
+ const { input, adjustments } = await mount({ modelValue: 10, max: 100 });
150
+ await type(input, "250");
151
+ input.dispatchEvent(
152
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
153
+ );
154
+ await settled();
155
+ expect(adjustments).toEqual([{ typed: 250, value: 100, reason: "max" }]);
156
+ });
157
+
158
+ it("redraws the kept value through the field's change handler", async () => {
159
+ const { input } = await mount({ modelValue: 1000, step: 50 });
160
+ const changes = vi.fn();
161
+ input.addEventListener("change", changes);
162
+ await type(input, "1020");
163
+ input.dispatchEvent(new FocusEvent("blur"));
164
+ await settled();
165
+ expect(changes).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("stays quiet when the field keeps what was typed, or nothing was typed", async () => {
169
+ const { input, adjustments } = await mount({ modelValue: 10, step: 5 });
170
+ input.dispatchEvent(new FocusEvent("blur"));
171
+ await type(input, "15");
172
+ input.dispatchEvent(new FocusEvent("blur"));
173
+ await settled();
174
+ expect(adjustments).toEqual([]);
175
+ });
176
+ });
@@ -1,7 +1,10 @@
1
1
  // @vitest-environment jsdom
2
2
  /**
3
- * The strip of a held table: "N new · Show", Show reading the rows again.
3
+ * The strip of a held table: "N new · Show", Show reading the rows again,
4
+ * led by why the table holds by itself ("Table on hold · search active").
4
5
  */
6
+ import { readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
5
8
  import { createApp, h, type App } from "vue";
6
9
  import { afterEach, expect, it, vi } from "vitest";
7
10
 
@@ -12,7 +15,7 @@ afterEach(() => {
12
15
  vi.unstubAllGlobals();
13
16
  });
14
17
 
15
- it("draws the banner and reads the rows on Show", async () => {
18
+ async function mountBanner(props: Record<string, unknown>) {
16
19
  vi.stubGlobal("useI18n", () => ({
17
20
  t: (key: string, params?: { count?: number }) =>
18
21
  params ? `${key}:${params.count}` : key,
@@ -20,18 +23,73 @@ it("draws the banner and reads the rows on Show", async () => {
20
23
  const { default: LiveHoldBanner } = await import(
21
24
  "../layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue"
22
25
  );
23
- const onShow = vi.fn();
24
26
  const host = document.createElement("div");
25
- app = createApp({
26
- render: () => h(LiveHoldBanner, { count: 3, onShow }),
27
- });
27
+ app = createApp({ render: () => h(LiveHoldBanner, props) });
28
28
  app.component("UIcon", () => h("i"));
29
- app.component("UButton", (props: { label: string }, { attrs }) =>
30
- h("button", attrs, props.label),
29
+ app.component("UButton", (buttonProps: { label: string }, { attrs }) =>
30
+ h("button", attrs, buttonProps.label),
31
31
  );
32
32
  app.mount(host);
33
+ return host;
34
+ }
35
+
36
+ it("draws the banner and reads the rows on Show", async () => {
37
+ const onShow = vi.fn();
38
+ const host = await mountBanner({ count: 3, onShow });
33
39
 
34
40
  expect(host.textContent).toContain("dms.live.new_rows:3");
35
41
  host.querySelector("button")?.click();
36
42
  expect(onShow).toHaveBeenCalledTimes(1);
37
43
  });
44
+
45
+ it("says why the table holds, with no count nor Show before anything came in", async () => {
46
+ const host = await mountBanner({ count: 0, reason: "searching" });
47
+
48
+ expect(host.textContent).toContain(
49
+ "dms.live.table_held · dms.live.held_searching",
50
+ );
51
+ expect(host.textContent).not.toContain("dms.live.new_rows");
52
+ expect(host.querySelector("button")).toBeNull();
53
+ });
54
+
55
+ it("adds the count and Show after the reason", async () => {
56
+ const host = await mountBanner({ count: 2, reason: "rowOpen" });
57
+
58
+ expect(host.textContent).toContain("dms.live.held_row_open");
59
+ expect(host.textContent).toContain("dms.live.new_rows:2");
60
+ expect(host.querySelector("button")).not.toBeNull();
61
+ });
62
+
63
+ it("has every reason in English and French", () => {
64
+ for (const file of ["ui-en-GB.json", "ui-fr-FR.json"]) {
65
+ const live = JSON.parse(
66
+ readFileSync(
67
+ resolve(__dirname, `../layers/dms-ui/i18n/locales/${file}`),
68
+ "utf8",
69
+ ),
70
+ ).dms.live;
71
+ for (const key of [
72
+ "table_held",
73
+ "held_row_open",
74
+ "held_searching",
75
+ "held_sorted",
76
+ "held_scrolled",
77
+ ]) {
78
+ expect(live[key], `${file} ${key}`).toBeTruthy();
79
+ }
80
+ }
81
+ });
82
+
83
+ it("words the count with missedLabel and adds the paused page's breakdown", async () => {
84
+ const host = await mountBanner({
85
+ count: 12,
86
+ missedLabel: "$api.live.new_requests",
87
+ detail: { text: "$api.live.in_5xx", params: { count: 2 }, tone: "error" },
88
+ });
89
+
90
+ expect(host.textContent).toContain("api.live.new_requests:12");
91
+ expect(host.textContent).not.toContain("dms.live.new_rows");
92
+ const detail = host.querySelector(".dms-live-hold-banner__detail");
93
+ expect(detail?.textContent?.trim()).toBe("api.live.in_5xx:2");
94
+ expect(detail?.classList.contains("text-error")).toBe(true);
95
+ });
@@ -0,0 +1,139 @@
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.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { ref } from "vue";
11
+ import { MODULE_URL_PREFIX } from "../layers/dms-layout/app/types/page";
12
+
13
+ interface TreeNode {
14
+ id: string;
15
+ fullSlug: string;
16
+ layoutUrl?: string;
17
+ hasAccess?: boolean;
18
+ isModuleRoot?: boolean;
19
+ children: Record<string, TreeNode>;
20
+ childrenOrders: string[];
21
+ }
22
+
23
+ type Middleware = (to: { path: string; fullPath: string }) => Promise<unknown>;
24
+
25
+ const navigateDms = vi.fn();
26
+
27
+ function node(
28
+ id: string,
29
+ fullSlug: string,
30
+ children: TreeNode[] = [],
31
+ page: Partial<TreeNode> = {},
32
+ ): TreeNode {
33
+ return {
34
+ id,
35
+ fullSlug,
36
+ children: Object.fromEntries(children.map((child) => [child.id, child])),
37
+ childrenOrders: children.map((child) => child.id),
38
+ ...page,
39
+ };
40
+ }
41
+
42
+ const PAGE = { layoutUrl: "/layout" };
43
+
44
+ function treeOf(moduleRoot: TreeNode): TreeNode {
45
+ return node("", "/", [node("modules", "/modules", [moduleRoot])]);
46
+ }
47
+
48
+ const apiModule = (pages: Partial<Record<string, Partial<TreeNode>>> = {}) =>
49
+ node(
50
+ "api",
51
+ "/modules/api",
52
+ [
53
+ node("monitor", "/modules/api", [
54
+ node("routes", "/modules/api/routes", [], { ...PAGE, ...pages.routes }),
55
+ node("overview", "/modules/api/overview", [], {
56
+ ...PAGE,
57
+ ...pages.overview,
58
+ }),
59
+ ]),
60
+ ],
61
+ { isModuleRoot: true },
62
+ );
63
+
64
+ function stubSiteLayout(moduleRoot: TreeNode, landingPage?: string) {
65
+ vi.stubGlobal("useSiteLayout", () => ({
66
+ siteLayout: ref({}),
67
+ siteLayoutTree: ref(treeOf(moduleRoot)),
68
+ modules: ref({ api: { id: "api", hasAccess: true, landingPage } }),
69
+ findMatchingRoute: (path: string) =>
70
+ path.startsWith("/modules/api/") ? { metadata: {} } : null,
71
+ loadSiteLayout: vi.fn(),
72
+ }));
73
+ }
74
+
75
+ async function enter(fullPath: string): Promise<unknown> {
76
+ const middleware = (
77
+ await import("../layers/dms-layout/app/middleware/module-routing.global")
78
+ ).default as unknown as Middleware;
79
+ const path = fullPath.replace(/[?#].*$/, "");
80
+ return middleware({ path, fullPath });
81
+ }
82
+
83
+ beforeEach(() => {
84
+ vi.resetModules();
85
+ vi.stubGlobal("defineDmsMiddleware", (handler: Middleware) => handler);
86
+ vi.stubGlobal("MODULE_URL_PREFIX", MODULE_URL_PREFIX);
87
+ vi.stubGlobal("navigateDms", navigateDms);
88
+ vi.stubGlobal("useHomepage", () => "/dashboard");
89
+ vi.stubGlobal("useModuleHistory", () => ({ record: vi.fn() }));
90
+ vi.stubGlobal("createError", (error: unknown) => error);
91
+ });
92
+
93
+ afterEach(() => {
94
+ navigateDms.mockReset();
95
+ vi.unstubAllGlobals();
96
+ });
97
+
98
+ describe("entering a module", () => {
99
+ it("goes to the module's landingPage", async () => {
100
+ stubSiteLayout(apiModule(), "overview");
101
+ expect(await enter("/modules/api")).toBe("/modules/api/overview");
102
+ expect(navigateDms).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("keeps the query and the hash it was entered with", async () => {
106
+ stubSiteLayout(apiModule(), "overview");
107
+ expect(await enter("/modules/api?live=0#errors")).toBe(
108
+ "/modules/api/overview?live=0#errors",
109
+ );
110
+ });
111
+
112
+ it("goes to the first page when there is no landingPage", async () => {
113
+ stubSiteLayout(apiModule());
114
+ expect(await enter("/modules/api")).toBe("/modules/api/routes");
115
+ });
116
+
117
+ it("goes to the first page the caller may open when the landingPage is closed to them", async () => {
118
+ stubSiteLayout(apiModule({ overview: { hasAccess: false } }), "overview");
119
+ expect(await enter("/modules/api")).toBe("/modules/api/routes");
120
+ });
121
+
122
+ it("sends to the homepage a caller who may open none of its pages", async () => {
123
+ stubSiteLayout(
124
+ apiModule({
125
+ routes: { hasAccess: false },
126
+ overview: { hasAccess: false },
127
+ }),
128
+ "overview",
129
+ );
130
+ await enter("/modules/api");
131
+ expect(navigateDms).toHaveBeenCalledWith("/dashboard", { replace: true });
132
+ });
133
+
134
+ it("leaves a module page alone", async () => {
135
+ stubSiteLayout(apiModule(), "overview");
136
+ expect(await enter("/modules/api/routes?tab=tester")).toBeUndefined();
137
+ expect(navigateDms).not.toHaveBeenCalled();
138
+ });
139
+ });