@antelopejs/dms 0.7.13 → 0.7.15
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 +44 -0
- package/dist/db/models/userNotifications.model.js +3 -27
- package/dist/db/models/userNotifications.model.js.map +1 -1
- 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/frontend-modules.d.ts +4 -2
- package/dist/implementations/dms/frontend-modules.js +4 -0
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
- package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
- package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +1 -0
- package/dist/implementations/dms/manifest.js +3 -1
- package/dist/implementations/dms/manifest.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/integration/notifications.test.js +6 -3
- package/dist/test/integration/notifications.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- 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/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/utils/pageValidationRedirect.ts +32 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
- package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
- 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-ui/app/build/components/table/FiltersRow.vue +39 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
- 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/formControlErrors.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +2 -2
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +47 -8
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +61 -2
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +21 -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/useControlError.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -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/control-error-path.test.ts +11 -0
- package/frontend-vue/tests/email-layout.test.ts +124 -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/input-tags.test.ts +1 -1
- package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
- package/frontend-vue/tests/row-side-panel.test.ts +2 -2
- package/frontend-vue/tests/table-url-filters.test.ts +158 -0
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as legacy from "../layers/dms-ui/app/build/composables/form/useControlError";
|
|
3
|
+
import { useControlError } from "../layers/dms-ui/app/composables/form/useControlError";
|
|
4
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/formControlErrors";
|
|
5
|
+
|
|
6
|
+
describe("useControlError location", () => {
|
|
7
|
+
it("stays importable from its former build/ path", () => {
|
|
8
|
+
expect(legacy.useControlError).toBe(useControlError);
|
|
9
|
+
expect(legacy.FORM_CONTROL_ERRORS_KEY).toBe(FORM_CONTROL_ERRORS_KEY);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createSSRApp, defineComponent, h } from "vue";
|
|
3
|
+
import { renderToString } from "vue/server-renderer";
|
|
4
|
+
import EmailLayout from "../layers/dms-layout/app/emails/components/EmailLayout.vue";
|
|
5
|
+
import { emailLogoUrl } from "../layers/dms-layout/app/emails/components/index";
|
|
6
|
+
|
|
7
|
+
const PUBLIC_BASE = "https://admin.example.com";
|
|
8
|
+
const ELEMENTS: Record<string, string> = {
|
|
9
|
+
EHtml: "html",
|
|
10
|
+
EHead: "head",
|
|
11
|
+
EBody: "body",
|
|
12
|
+
EContainer: "div",
|
|
13
|
+
ESection: "section",
|
|
14
|
+
EHeading: "h1",
|
|
15
|
+
EImg: "img",
|
|
16
|
+
EHr: "hr",
|
|
17
|
+
EPreview: "span",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function element(tag: string) {
|
|
21
|
+
return defineComponent({
|
|
22
|
+
inheritAttrs: false,
|
|
23
|
+
setup:
|
|
24
|
+
(_, { attrs, slots }) =>
|
|
25
|
+
() =>
|
|
26
|
+
h(tag, { src: attrs.src, alt: attrs.alt }, slots.default?.()),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function renderLayout(
|
|
31
|
+
props: Record<string, unknown>,
|
|
32
|
+
logo = "/images/logo.png",
|
|
33
|
+
clientBaseUrl = PUBLIC_BASE,
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
vi.stubGlobal("useDmsAppConfig", () => ({
|
|
36
|
+
branding: { logo: { default: { light: logo } } },
|
|
37
|
+
}));
|
|
38
|
+
vi.stubGlobal("useDmsRuntimeConfig", () => ({
|
|
39
|
+
public: { dms: { clientBaseUrl } },
|
|
40
|
+
}));
|
|
41
|
+
const app = createSSRApp({
|
|
42
|
+
render: () =>
|
|
43
|
+
h(EmailLayout, props, {
|
|
44
|
+
default: () => h("p", "Body"),
|
|
45
|
+
footer: () => h("small", "Footer"),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
Object.entries(ELEMENTS).forEach(([name, tag]) =>
|
|
49
|
+
app.component(name, element(tag)),
|
|
50
|
+
);
|
|
51
|
+
const html = await renderToString(app);
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
return html;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("EmailLayout", () => {
|
|
57
|
+
it("keeps the heading, the separator and the logo by default", async () => {
|
|
58
|
+
const html = await renderLayout({ title: "Welcome" });
|
|
59
|
+
expect(html).toContain("<h1>Welcome</h1>");
|
|
60
|
+
expect(html).toContain("<hr>");
|
|
61
|
+
expect(html).toContain(
|
|
62
|
+
`<img src="${PUBLIC_BASE}/images/logo.png" alt="Logo">`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("drops the heading without a title and the separator on request", async () => {
|
|
67
|
+
const html = await renderLayout({ divider: false, preheader: "Preview" });
|
|
68
|
+
expect(html).not.toContain("<h1>");
|
|
69
|
+
expect(html).not.toContain("<hr>");
|
|
70
|
+
expect(html).toContain("<span>Preview</span>");
|
|
71
|
+
expect(html).toContain("<p>Body</p>");
|
|
72
|
+
expect(html).toContain("<small>Footer</small>");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps any logo by default", async () => {
|
|
76
|
+
expect(
|
|
77
|
+
await renderLayout({ title: "Hi" }, "/images/logo.svg", ""),
|
|
78
|
+
).toContain('<img src="/images/logo.svg" alt="Logo">');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves out a logo a mail client cannot load with publicLogoOnly", async () => {
|
|
82
|
+
const guarded = { title: "Hi", publicLogoOnly: true };
|
|
83
|
+
expect(await renderLayout(guarded, "/images/logo.svg")).not.toContain(
|
|
84
|
+
"<img",
|
|
85
|
+
);
|
|
86
|
+
expect(
|
|
87
|
+
await renderLayout(guarded, "/logo.png", "http://localhost:3001"),
|
|
88
|
+
).not.toContain("<img");
|
|
89
|
+
expect(await renderLayout(guarded)).toContain(
|
|
90
|
+
`<img src="${PUBLIC_BASE}/images/logo.png" alt="Logo">`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("emailLogoUrl", () => {
|
|
96
|
+
it("resolves a root path against the public base URL", () => {
|
|
97
|
+
expect(emailLogoUrl("/logo.png", PUBLIC_BASE)).toBe(
|
|
98
|
+
`${PUBLIC_BASE}/logo.png`,
|
|
99
|
+
);
|
|
100
|
+
expect(emailLogoUrl("https://cdn.example.com/logo.png", "")).toBe(
|
|
101
|
+
"https://cdn.example.com/logo.png",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
[undefined, PUBLIC_BASE],
|
|
107
|
+
["/logo.svg", PUBLIC_BASE],
|
|
108
|
+
["https://cdn.example.com/logo.SVG?v=2", ""],
|
|
109
|
+
["data:image/png;base64,AAAA", ""],
|
|
110
|
+
["/logo.png", ""],
|
|
111
|
+
["/logo.png", "http://localhost:3001"],
|
|
112
|
+
["/logo.png", "http://dms.localhost"],
|
|
113
|
+
["/logo.png", "http://127.0.0.1:3001"],
|
|
114
|
+
["/logo.png", "http://10.0.0.5"],
|
|
115
|
+
["/logo.png", "http://172.20.1.1"],
|
|
116
|
+
["/logo.png", "http://192.168.1.10"],
|
|
117
|
+
["/logo.png", "http://169.254.169.254"],
|
|
118
|
+
["/logo.png", "http://[::1]:3001"],
|
|
119
|
+
["/logo.png", "http://[fd00::1]"],
|
|
120
|
+
["/logo.png", "http://intranet.internal"],
|
|
121
|
+
])("leaves out %s on %s", (logo, base) => {
|
|
122
|
+
expect(emailLogoUrl(logo, base)).toBe("");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -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/formControlErrors";
|
|
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
|
+
});
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
formErrorsInjectionKey,
|
|
17
17
|
formFieldInjectionKey,
|
|
18
18
|
} from "@nuxt/ui/composables/useFormField";
|
|
19
|
-
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/
|
|
19
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../layers/dms-ui/app/build/composables/form/formControlErrors";
|
|
20
20
|
|
|
21
21
|
const formErrors = ref<Array<Record<string, unknown>>>([]);
|
|
22
22
|
const model = ref<string[]>([]);
|