@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.
- package/CHANGELOG.md +36 -0
- package/dist/implementations/dms/client-ip.d.ts +2 -0
- package/dist/implementations/dms/client-ip.js +9 -0
- package/dist/implementations/dms/client-ip.js.map +1 -0
- package/dist/implementations/dms/page.js +14 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/auth/client-ip.d.ts +1 -4
- package/dist/routes/auth/client-ip.js.map +1 -1
- package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/client-ip.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/client-ip.test.js +61 -0
- package/dist/test/unit/interfaces/dms/client-ip.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/periodLink.ts +60 -0
- package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +9 -4
- package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +34 -4
- package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +22 -1
- package/frontend-vue/layers/dms-layout/app/build/utils/pageValidationRedirect.ts +32 -0
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +27 -56
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +29 -7
- package/frontend-vue/layers/dms-layout/app/types/page.ts +2 -0
- package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
- package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +28 -7
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmTextMatch.ts +16 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +2 -0
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +46 -7
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +23 -1
- package/frontend-vue/layers/dms-ui/app/composables/confirm/index.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +20 -0
- package/frontend-vue/layers/dms-ui/app/composables/confirm/useConfirmFrom.ts +32 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
- package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
- package/frontend-vue/tests/confirm-from.test.ts +101 -0
- package/frontend-vue/tests/confirm-typed-text.test.ts +35 -0
- package/frontend-vue/tests/context-links.test.ts +220 -0
- package/frontend-vue/tests/field-errors.test.ts +35 -0
- package/frontend-vue/tests/form-required-legend.test.ts +34 -0
- package/frontend-vue/tests/form-server-field-errors.test.ts +18 -1
- package/frontend-vue/tests/input-number-range.test.ts +135 -0
- package/frontend-vue/tests/module-routing.test.ts +23 -4
- package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
- package/frontend-vue/tests/scroll-to-block.test.ts +30 -1
- package/package.json +2 -2
- package/skills/dms-module/SKILL.md +3 -0
- /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("&", "&"),
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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();
|