@antelopejs/dms 0.7.12 → 0.7.14

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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/implementations/dms/client-ip.d.ts +2 -0
  3. package/dist/implementations/dms/client-ip.js +9 -0
  4. package/dist/implementations/dms/client-ip.js.map +1 -0
  5. package/dist/implementations/dms/page.js +14 -1
  6. package/dist/implementations/dms/page.js.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/routes/auth/client-ip.d.ts +1 -4
  10. package/dist/routes/auth/client-ip.js.map +1 -1
  11. package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
  12. package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
  13. package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms/client-ip.test.d.ts +1 -0
  15. package/dist/test/unit/interfaces/dms/client-ip.test.js +61 -0
  16. package/dist/test/unit/interfaces/dms/client-ip.test.js.map +1 -0
  17. package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
  18. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  19. package/frontend-vue/layers/dms-core/app/composables/period/periodLink.ts +60 -0
  20. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +9 -4
  21. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +34 -4
  22. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +2 -0
  23. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +22 -1
  24. package/frontend-vue/layers/dms-layout/app/build/utils/pageValidationRedirect.ts +32 -0
  25. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +27 -56
  26. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +29 -7
  27. package/frontend-vue/layers/dms-layout/app/types/page.ts +2 -0
  28. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
  29. package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +28 -7
  31. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmTextMatch.ts +16 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
  34. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
  35. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +2 -0
  36. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
  37. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
  38. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +46 -7
  39. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +23 -1
  40. package/frontend-vue/layers/dms-ui/app/composables/confirm/index.ts +1 -0
  41. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +20 -0
  42. package/frontend-vue/layers/dms-ui/app/composables/confirm/useConfirmFrom.ts +32 -0
  43. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
  44. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
  45. package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
  46. package/frontend-vue/tests/confirm-from.test.ts +101 -0
  47. package/frontend-vue/tests/confirm-typed-text.test.ts +35 -0
  48. package/frontend-vue/tests/context-links.test.ts +220 -0
  49. package/frontend-vue/tests/field-errors.test.ts +35 -0
  50. package/frontend-vue/tests/form-required-legend.test.ts +34 -0
  51. package/frontend-vue/tests/form-server-field-errors.test.ts +18 -1
  52. package/frontend-vue/tests/input-number-range.test.ts +135 -0
  53. package/frontend-vue/tests/module-routing.test.ts +23 -4
  54. package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
  55. package/frontend-vue/tests/scroll-to-block.test.ts +30 -1
  56. package/package.json +2 -2
  57. package/skills/dms-module/SKILL.md +3 -0
  58. /package/frontend-vue/layers/dms-ui/app/{build/components → components}/form/FormRequiredLegend.vue +0 -0
@@ -171,6 +171,41 @@ it("flags an empty or different typed text instead of disabling confirm", async
171
171
  expect(closed).toHaveBeenCalledWith(true);
172
172
  });
173
173
 
174
+ it("matches the typed text ignoring case when asked", async () => {
175
+ const onConfirm = vi.fn(async () => undefined);
176
+ mount({
177
+ title: "Delete?",
178
+ confirmLabel: "Delete",
179
+ confirmText: "Acme Corp",
180
+ confirmTextCaseInsensitive: true,
181
+ onConfirm,
182
+ });
183
+ await flush();
184
+
185
+ await type("acme corp");
186
+ confirmButton().click();
187
+ await flush();
188
+ expect(typedError()).toBeUndefined();
189
+ expect(onConfirm).toHaveBeenCalledOnce();
190
+ });
191
+
192
+ it("keeps the case-sensitive match by default", async () => {
193
+ const onConfirm = vi.fn(async () => undefined);
194
+ mount({
195
+ title: "Delete?",
196
+ confirmLabel: "Delete",
197
+ confirmText: "Acme Corp",
198
+ onConfirm,
199
+ });
200
+ await flush();
201
+
202
+ await type("acme corp");
203
+ confirmButton().click();
204
+ await flush();
205
+ expect(typedError()).toBe('dms.confirm.type_mismatch {"text":"Acme Corp"}');
206
+ expect(onConfirm).not.toHaveBeenCalled();
207
+ });
208
+
174
209
  it("runs the body check with the typed one and stays open when it fails", async () => {
175
210
  const validate = vi.fn(() => false);
176
211
  const onConfirm = vi.fn(async () => undefined);
@@ -0,0 +1,220 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A link followed from a page keeps its context: a TopList row's `to` and a
4
+ * link row action fill the page URL's `{{params.X}}` / `{{query.X}}` tokens,
5
+ * a dotted period key included, and `withPeriodQuery` carries a selector's
6
+ * period onto a link a module builds itself.
7
+ */
8
+ import {
9
+ createApp,
10
+ createSSRApp,
11
+ defineComponent,
12
+ h,
13
+ ref,
14
+ type App,
15
+ } from "vue";
16
+ import { renderToString } from "vue/server-renderer";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import TopListCard from "../layers/dms-ui/app/components/top-list/TopListCard.vue";
19
+ import { useActionTargets } from "../layers/dms-ui/app/build/composables/actions/useActionTargets";
20
+ import { interpolateUrl } from "../layers/dms-core/app/utils/url-interpolation";
21
+ import { resolveLinkVariables } from "../layers/dms-ui/app/build/utils/urlVariables";
22
+ import {
23
+ periodQueryKeys,
24
+ withPeriodQuery,
25
+ } from "../layers/dms-core/app/composables/period/periodLink";
26
+
27
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
28
+ useChartFetch: () => ({
29
+ data: ref({
30
+ items: [
31
+ {
32
+ id: "pricing",
33
+ title: "/pricing",
34
+ value: 12,
35
+ to: "/modules/marketing/pages?path=%2Fpricing&website={{query.website}}&mk.period={{query.mk.period}}&mk.from={{query.mk.from}}",
36
+ },
37
+ { id: "plain", title: "/", value: 3, to: "/modules/marketing/pages" },
38
+ ],
39
+ }),
40
+ isLoading: ref(false),
41
+ }),
42
+ }));
43
+
44
+ const PAGE_QUERY = {
45
+ website: "site-1",
46
+ "mk.period": "last-7-days",
47
+ "mk.compare": "previous-period",
48
+ };
49
+
50
+ const navigateDms = vi.fn();
51
+ let app: App | undefined;
52
+
53
+ beforeEach(() => {
54
+ navigateDms.mockClear();
55
+ vi.stubGlobal("useDmsRoute", () => ({
56
+ path: "/modules/marketing/overview",
57
+ query: PAGE_QUERY,
58
+ }));
59
+ vi.stubGlobal("useSiteLayout", () => ({
60
+ findMatchingRoute: () => ({ params: { id: "o1" } }),
61
+ }));
62
+ });
63
+
64
+ afterEach(() => {
65
+ app?.unmount();
66
+ app = undefined;
67
+ vi.unstubAllGlobals();
68
+ });
69
+
70
+ describe("resolveLinkVariables", () => {
71
+ it("leaves out a pair whose dotted query key the page does not hold", () => {
72
+ expect(
73
+ resolveLinkVariables(
74
+ "/p?mk.period={{query.mk.period}}&mk.from={{query.mk.from}}",
75
+ { routeQuery: PAGE_QUERY },
76
+ ),
77
+ ).toBe("/p?mk.period=last-7-days");
78
+ });
79
+ });
80
+
81
+ describe("withPeriodQuery", () => {
82
+ it("names the keys of a selector in the page URL", () => {
83
+ expect(periodQueryKeys("mk").compareFrom).toBe("mk.compareFrom");
84
+ });
85
+
86
+ it("carries the selector's period, the link's own keys winning", () => {
87
+ expect(
88
+ withPeriodQuery(
89
+ "/modules/marketing/funnel?id=f1&mk.compare=none#steps",
90
+ PAGE_QUERY,
91
+ "mk",
92
+ ),
93
+ ).toBe(
94
+ "/modules/marketing/funnel?id=f1&mk.compare=none&mk.period=last-7-days#steps",
95
+ );
96
+ });
97
+
98
+ it("leaves a link bare when the page carries no period", () => {
99
+ expect(withPeriodQuery("/modules/marketing/funnels", {}, "mk")).toBe(
100
+ "/modules/marketing/funnels",
101
+ );
102
+ });
103
+
104
+ it("ignores another selector's keys", () => {
105
+ expect(withPeriodQuery("/p", { "sales.period": "ytd" }, "mk")).toBe("/p");
106
+ });
107
+ });
108
+
109
+ const Link = defineComponent({
110
+ props: { to: { type: String, required: true } },
111
+ setup: (props) => () => h("a", { "data-row": "", href: props.to }),
112
+ });
113
+
114
+ const Slot = defineComponent({
115
+ setup:
116
+ (_, { slots }) =>
117
+ () =>
118
+ h("div", slots.default?.()),
119
+ });
120
+
121
+ async function renderTopList(): Promise<string> {
122
+ const ssrApp = createSSRApp({
123
+ render: () =>
124
+ h(TopListCard, {
125
+ title: "Top pages",
126
+ componentId: "pages",
127
+ pageId: "overview",
128
+ }),
129
+ });
130
+ for (const name of ["DmsCard", "UBadge", "UIcon", "USkeleton", "UButton"]) {
131
+ ssrApp.component(name, Slot);
132
+ }
133
+ ssrApp.component("DmsLink", Link);
134
+ return renderToString(ssrApp);
135
+ }
136
+
137
+ describe("a TopList row link", () => {
138
+ beforeEach(() => {
139
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-US"), t: String }));
140
+ vi.stubGlobal("useTranslation", () => ({
141
+ processI18n: (text: string) => text,
142
+ }));
143
+ vi.stubGlobal("useComponentEvent", () => ({}));
144
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
145
+ });
146
+
147
+ it("fills the page's tokens and drops a pair the page has no value for", async () => {
148
+ const html = await renderTopList();
149
+ const hrefs = [...html.matchAll(/href="([^"]*)"/g)].map((match) =>
150
+ match[1]!.replaceAll("&amp;", "&"),
151
+ );
152
+ expect(hrefs).toEqual([
153
+ "/modules/marketing/pages?path=%2Fpricing&website=site-1&mk.period=last-7-days",
154
+ "/modules/marketing/pages",
155
+ ]);
156
+ });
157
+ });
158
+
159
+ function mountTargets() {
160
+ let targets: ReturnType<typeof useActionTargets> | undefined;
161
+ app = createApp(
162
+ defineComponent({
163
+ setup() {
164
+ targets = useActionTargets({
165
+ api: vi.fn() as never,
166
+ pageId: "orders-page",
167
+ componentId: "orders",
168
+ handleApiError: vi.fn(),
169
+ });
170
+ return () => h("div");
171
+ },
172
+ }),
173
+ );
174
+ app.mount(document.createElement("div"));
175
+ return targets!;
176
+ }
177
+
178
+ describe("a link row action", () => {
179
+ beforeEach(() => {
180
+ vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
181
+ vi.stubGlobal("useTranslation", () => ({
182
+ processI18n: (text: string) => text,
183
+ processApiMessage: (text: string) => text,
184
+ }));
185
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
186
+ vi.stubGlobal("useModal", () => ({ open: vi.fn() }));
187
+ vi.stubGlobal("useDrawer", () => ({ open: vi.fn() }));
188
+ vi.stubGlobal("useExportJob", () => ({ runJob: vi.fn() }));
189
+ vi.stubGlobal("useConfirm", () => ({ confirm: vi.fn(async () => true) }));
190
+ vi.stubGlobal("useAuthFetch", () => ({ $authFetch: vi.fn() }));
191
+ vi.stubGlobal("interpolateUrl", interpolateUrl);
192
+ vi.stubGlobal("navigateDms", navigateDms);
193
+ });
194
+
195
+ it("fills the page's tokens, then the row's fields", async () => {
196
+ mountTargets().handleCustomRowAction(
197
+ {
198
+ label: "Report",
199
+ target: {
200
+ type: "page",
201
+ url: "/modules/marketing/funnel?id={_id}&website={{query.website}}&mk.period={{query.mk.period}}&mk.to={{query.mk.to}}&order={{params.id}}",
202
+ },
203
+ },
204
+ { _id: "f 1" },
205
+ );
206
+ await vi.waitFor(() => expect(navigateDms).toHaveBeenCalledTimes(1));
207
+ expect(navigateDms).toHaveBeenCalledWith(
208
+ "/modules/marketing/funnel?id=f%201&website=site-1&mk.period=last-7-days&order=o1",
209
+ );
210
+ });
211
+
212
+ it("leaves a URL without page tokens as before", async () => {
213
+ mountTargets().handleCustomRowAction(
214
+ { label: "Open", target: { type: "page", url: "/orders/{_id}" } },
215
+ { _id: "o2" },
216
+ );
217
+ await vi.waitFor(() => expect(navigateDms).toHaveBeenCalledTimes(1));
218
+ expect(navigateDms).toHaveBeenCalledWith("/orders/o2");
219
+ });
220
+ });
@@ -4,6 +4,7 @@ import {
4
4
  describeApiError,
5
5
  resolveFieldErrors,
6
6
  } from "../layers/dms-core/app/composables/useFieldErrors";
7
+ import { resolveApiMessage } from "../layers/dms-core/app/composables/translation/useTranslation";
7
8
 
8
9
  const FIELDS = ["name", "email", "password"] as const;
9
10
 
@@ -132,6 +133,25 @@ describe("resolveFieldErrors", () => {
132
133
  ]);
133
134
  });
134
135
 
136
+ it("reads the params of a { field, message, params } body", () => {
137
+ const error = {
138
+ statusCode: 400,
139
+ data: {
140
+ message: "$page.campaigns.error.budget_max",
141
+ field: "budget",
142
+ params: { max: 5000, currency: "EUR", nested: { no: 1 } },
143
+ },
144
+ };
145
+ expect(resolveFieldErrors(error, { fields: ["budget"] }).fields).toEqual([
146
+ {
147
+ field: "budget",
148
+ message: "$page.campaigns.error.budget_max",
149
+ values: undefined,
150
+ params: { max: 5000, currency: "EUR" },
151
+ },
152
+ ]);
153
+ });
154
+
135
155
  it("reads the data API's missing and invalid field lists", () => {
136
156
  expect(
137
157
  resolveFieldErrors(
@@ -210,3 +230,18 @@ describe("describeApiError", () => {
210
230
  expect(apiErrorText(new Error("down"))).toBeUndefined();
211
231
  });
212
232
  });
233
+
234
+ describe("resolveApiMessage", () => {
235
+ const translate = (key: string, params: Record<string, unknown>) =>
236
+ key === "page.error.budget_max" ? `At most ${String(params.max)}` : key;
237
+
238
+ it("fills the translation's placeholders with the params", () => {
239
+ expect(
240
+ resolveApiMessage(translate, "$page.error.budget_max", { max: 5000 }),
241
+ ).toBe("At most 5000");
242
+ });
243
+
244
+ it("keeps an untranslated message as it came", () => {
245
+ expect(resolveApiMessage(translate, "Too big", { max: 1 })).toBe("Too big");
246
+ });
247
+ });
@@ -0,0 +1,34 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The required-fields legend is a public component, auto-registered as
4
+ * `DmsFormRequiredLegend`: a module's own form prints it like a stock one.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { createApp, h } from "vue";
9
+ import { expect, it } from "vitest";
10
+ import FormRequiredLegend from "../layers/dms-ui/app/components/form/FormRequiredLegend.vue";
11
+
12
+ it("lives with the public components, not the layer's internals", () => {
13
+ const layer = resolve(__dirname, "../layers/dms-ui/app");
14
+ expect(existsSync(`${layer}/components/form/FormRequiredLegend.vue`)).toBe(
15
+ true,
16
+ );
17
+ expect(
18
+ existsSync(`${layer}/build/components/form/FormRequiredLegend.vue`),
19
+ ).toBe(false);
20
+ });
21
+
22
+ it("prints the asterisk and the translated legend, with the caller's class", () => {
23
+ const host = document.createElement("div");
24
+ const app = createApp({
25
+ render: () => h(FormRequiredLegend, { class: "mt-4" }),
26
+ });
27
+ app.config.globalProperties.$t = (key: string) => `t(${key})`;
28
+ app.mount(host);
29
+ const legend = host.querySelector("p")!;
30
+ expect(legend.classList.contains("mt-4")).toBe(true);
31
+ expect(legend.textContent).toContain("*");
32
+ expect(legend.textContent).toContain("t(dms.form.required_legend)");
33
+ app.unmount();
34
+ });
@@ -65,7 +65,8 @@ beforeEach(() => {
65
65
  vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
66
66
  vi.stubGlobal("useTranslation", () => ({
67
67
  processI18n: (key: string) => key,
68
- processApiMessage: (message: string) => `t(${message})`,
68
+ processApiMessage: (message: string, params?: Record<string, unknown>) =>
69
+ params ? `t(${message}, ${JSON.stringify(params)})` : `t(${message})`,
69
70
  }));
70
71
  vi.stubGlobal("useI18n", () => ({
71
72
  t: (key: string, named?: Record<string, unknown>) =>
@@ -137,6 +138,22 @@ describe("DMS form server errors", () => {
137
138
  expect(addToast).not.toHaveBeenCalled();
138
139
  });
139
140
 
141
+ it("fills the message's placeholders from the error's params", async () => {
142
+ authFetch.mockRejectedValue({
143
+ statusCode: 400,
144
+ data: {
145
+ message: "$budget_max",
146
+ field: "name",
147
+ params: { max: 5000 },
148
+ },
149
+ });
150
+ await submit(setupForm());
151
+
152
+ expect(showFieldErrors).toHaveBeenCalledExactlyOnceWith([
153
+ { name: "name", message: 't($budget_max, {"max":5000})' },
154
+ ]);
155
+ });
156
+
140
157
  it("keeps a failure tied to no field as a toast", async () => {
141
158
  authFetch.mockRejectedValue({ statusCode: 500, data: "error.unexpected" });
142
159
  await submit(setupForm());
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A number field clamps a value typed outside `min` / `max` into range on
4
+ * blur, as the input does by default. With `clamp: false` it keeps the value
5
+ * as typed and reports it as the field's error, which blocks the submit; its
6
+ * steppers stop at the bounds.
7
+ */
8
+ import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ type App,
12
+ type Component,
13
+ computed,
14
+ createApp,
15
+ defineComponent,
16
+ h,
17
+ nextTick,
18
+ ref,
19
+ } from "vue";
20
+ import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/useControlError";
21
+ import {
22
+ ABOVE_MAX_MESSAGE,
23
+ BELOW_MIN_MESSAGE,
24
+ numberRangeError,
25
+ numberStepperState,
26
+ } from "../layers/dms-ui/app/build/composables/form/numberRange";
27
+
28
+ let app: App | undefined;
29
+ let host: HTMLDivElement;
30
+ let inputAttrs: Record<string, unknown> = {};
31
+ const reported = new Map<string, string | undefined>();
32
+
33
+ const InputNumberStub = defineComponent({
34
+ inheritAttrs: false,
35
+ setup:
36
+ (_, { attrs }) =>
37
+ () => {
38
+ inputAttrs = { ...attrs };
39
+ return h("input");
40
+ },
41
+ });
42
+
43
+ async function mount(props: Record<string, unknown>) {
44
+ const InputNumber = (
45
+ await import(
46
+ "../layers/dms-ui/app/components/form/components/InputNumber.vue"
47
+ )
48
+ ).default as Component;
49
+ const value = ref(props.modelValue);
50
+ app = createApp({
51
+ render: () => h(InputNumber, { ...props, modelValue: value.value }),
52
+ });
53
+ app.component("UInputNumber", InputNumberStub);
54
+ app.provide(
55
+ formFieldInjectionKey,
56
+ computed(() => ({ name: "budget" })) as never,
57
+ );
58
+ app.provide(FORM_CONTROL_ERRORS_KEY, {
59
+ report: (fieldId: string, message: string | undefined) =>
60
+ reported.set(fieldId, message),
61
+ });
62
+ app.mount(host);
63
+ await nextTick();
64
+ return value;
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.stubGlobal("useTranslation", () => ({
69
+ processI18n: (text: string) => text,
70
+ }));
71
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en") }));
72
+ host = document.createElement("div");
73
+ document.body.append(host);
74
+ reported.clear();
75
+ inputAttrs = {};
76
+ });
77
+
78
+ afterEach(() => {
79
+ app?.unmount();
80
+ host.remove();
81
+ vi.unstubAllGlobals();
82
+ });
83
+
84
+ describe("numberRangeError", () => {
85
+ it("names the bound a value crosses", () => {
86
+ const bounds = { min: 1, max: 10 };
87
+ expect(numberRangeError(0, bounds)).toBe(BELOW_MIN_MESSAGE);
88
+ expect(numberRangeError(11, bounds)).toBe(ABOVE_MAX_MESSAGE);
89
+ expect(numberRangeError(5, bounds)).toBeUndefined();
90
+ expect(numberRangeError(undefined, bounds)).toBeUndefined();
91
+ expect(numberRangeError(Number.NaN, bounds)).toBeUndefined();
92
+ });
93
+ });
94
+
95
+ describe("numberStepperState", () => {
96
+ it("stops the steppers at the bounds", () => {
97
+ expect(numberStepperState(10, { min: 1, max: 10 })).toEqual({
98
+ incrementDisabled: true,
99
+ decrementDisabled: false,
100
+ });
101
+ expect(numberStepperState(0, { min: 1 })).toEqual({
102
+ incrementDisabled: false,
103
+ decrementDisabled: true,
104
+ });
105
+ expect(numberStepperState(undefined, { min: 1, max: 10 })).toEqual({
106
+ incrementDisabled: false,
107
+ decrementDisabled: false,
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("a number field", () => {
113
+ it("hands its bounds to the input by default, which clamps", async () => {
114
+ await mount({ modelValue: 50, min: 1, max: 10 });
115
+ expect(inputAttrs.min).toBe(1);
116
+ expect(inputAttrs.max).toBe(10);
117
+ expect(reported.get("budget")).toBeUndefined();
118
+ });
119
+
120
+ it("keeps an out-of-range value and reports it with clamp: false", async () => {
121
+ await mount({ modelValue: 50, min: 1, max: 10, clamp: false });
122
+ expect(inputAttrs.min).toBeUndefined();
123
+ expect(inputAttrs.max).toBeUndefined();
124
+ expect(inputAttrs.incrementDisabled).toBe(true);
125
+ expect(reported.get("budget")).toBe(ABOVE_MAX_MESSAGE);
126
+ });
127
+
128
+ it("clears the error once the value is back in range", async () => {
129
+ const value = await mount({ modelValue: 0, min: 1, clamp: false });
130
+ expect(reported.get("budget")).toBe(BELOW_MIN_MESSAGE);
131
+ value.value = 3;
132
+ await nextTick();
133
+ expect(reported.get("budget")).toBeUndefined();
134
+ });
135
+ });
@@ -4,7 +4,9 @@
4
4
  * when the caller may open it, else its first page — keeping the query and
5
5
  * the hash it was entered with. The middleware answers the landing page
6
6
  * rather than navigating to it, so the module's address never renders and,
7
- * in a server render, becomes the response's redirect.
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.
8
10
  */
9
11
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
12
  import { ref } from "vue";
@@ -20,7 +22,12 @@ interface TreeNode {
20
22
  childrenOrders: string[];
21
23
  }
22
24
 
23
- type Middleware = (to: { path: string; fullPath: string }) => Promise<unknown>;
25
+ interface GuardedRoute {
26
+ path: string;
27
+ fullPath: string;
28
+ }
29
+
30
+ type Middleware = (to: GuardedRoute, from?: GuardedRoute) => Promise<unknown>;
24
31
 
25
32
  const navigateDms = vi.fn();
26
33
 
@@ -72,12 +79,16 @@ function stubSiteLayout(moduleRoot: TreeNode, landingPage?: string) {
72
79
  }));
73
80
  }
74
81
 
75
- async function enter(fullPath: string): Promise<unknown> {
82
+ async function enter(
83
+ fullPath: string,
84
+ drawnThere = false,
85
+ ): Promise<unknown> {
76
86
  const middleware = (
77
87
  await import("../layers/dms-layout/app/middleware/module-routing.global")
78
88
  ).default as unknown as Middleware;
79
89
  const path = fullPath.replace(/[?#].*$/, "");
80
- return middleware({ path, fullPath });
90
+ const to = { path, fullPath };
91
+ return middleware(to, drawnThere ? to : { path: "/home", fullPath: "/home" });
81
92
  }
82
93
 
83
94
  beforeEach(() => {
@@ -131,6 +142,14 @@ describe("entering a module", () => {
131
142
  expect(navigateDms).toHaveBeenCalledWith("/dashboard", { replace: true });
132
143
  });
133
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
+
134
153
  it("leaves a module page alone", async () => {
135
154
  stubSiteLayout(apiModule(), "overview");
136
155
  expect(await enter("/modules/api/routes?tab=tester")).toBeUndefined();