@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
@@ -0,0 +1,221 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createSSRApp,
5
+ h,
6
+ inject,
7
+ onMounted,
8
+ onUnmounted,
9
+ provide,
10
+ ref,
11
+ toRef,
12
+ watch,
13
+ type FunctionalComponent,
14
+ } from "vue";
15
+ import { renderToString } from "vue/server-renderer";
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+ import CatchAllPage from "../layers/dms-layout/app/pages/[...slug].vue";
18
+ import { pageValidationRedirect } from "../layers/dms-layout/app/build/utils/pageValidationRedirect";
19
+ import {
20
+ runCustomValidation,
21
+ validateRequiredQueryParams,
22
+ } from "../layers/dms-core/app/utils/query-validation";
23
+ import {
24
+ PAGE_FILL_HEIGHT_CLASSES,
25
+ providePageFillHeight,
26
+ usePageFillHeight,
27
+ } from "../layers/dms-layout/app/composables/layout/usePageFillHeight";
28
+ import type { PageValidation } from "../layers/dms-layout/app/types/page";
29
+
30
+ vi.mock(
31
+ "../layers/dms-layout/app/build/composables/dev-reload/useDevReloadHolder",
32
+ async () => {
33
+ const { ref } = await import("vue");
34
+ return { useDevReloading: () => ref(false) };
35
+ },
36
+ );
37
+
38
+ const PAGE_PATH = "/modules/marketing/funnel";
39
+ const LIST_PATH = "/modules/marketing/funnels";
40
+ const NOT_FOUND = 404;
41
+
42
+ class HttpError extends Error {
43
+ constructor(readonly statusCode: number) {
44
+ super(`HTTP ${statusCode}`);
45
+ }
46
+ }
47
+
48
+ const navigateDms = vi.fn(async () => {});
49
+ const pageQuery = ref<Record<string, unknown>>({});
50
+ const validation = ref<PageValidation | undefined>(undefined);
51
+
52
+ const PageComponent: FunctionalComponent<{ componentId: string }> = (props) =>
53
+ h("section", { "data-component": props.componentId });
54
+ PageComponent.props = ["componentId"];
55
+
56
+ function installRuntime(): void {
57
+ const pageLayout = ref({
58
+ components: { report: { componentName: "FunnelReport" } },
59
+ });
60
+ const runtime: Record<string, unknown> = {
61
+ computed,
62
+ inject,
63
+ onMounted,
64
+ onUnmounted,
65
+ provide,
66
+ ref,
67
+ toRef,
68
+ watch,
69
+ HTTP_FORBIDDEN: 403,
70
+ HTTP_INTERNAL_SERVER_ERROR: 500,
71
+ HTTP_NOT_FOUND: NOT_FOUND,
72
+ PAGE_FILL_HEIGHT_CLASSES,
73
+ providePageFillHeight,
74
+ usePageFillHeight,
75
+ createError: ({ statusCode }: { statusCode: number }) =>
76
+ new HttpError(statusCode),
77
+ defineDmsPageMeta: vi.fn(),
78
+ firstAccessiblePagePath: vi.fn(),
79
+ navigateDms,
80
+ preloadPageLayoutComponents: vi.fn(),
81
+ providePageRealtime: vi.fn(),
82
+ resolveDmsComponent: () => "div",
83
+ runCustomValidation,
84
+ showError: vi.fn(),
85
+ validateRequiredQueryParams,
86
+ useDefinedFunctions: () => ({ getFunction: () => undefined }),
87
+ useDmsRoute: () => ({ path: PAGE_PATH, query: pageQuery.value }),
88
+ useHomepage: () => "/",
89
+ useI18n: () => ({ t: (key: string) => key }),
90
+ usePageLayout: async () => ({ pageLayout }),
91
+ usePageRealtime: () => ({}),
92
+ usePermissions: () => ({
93
+ permissions: ref([]),
94
+ refreshIfStale: async () => {},
95
+ }),
96
+ useSessionRecovery: () => ({
97
+ loggedIn: ref(true),
98
+ reconcileSession: async () => {},
99
+ redirectToAuth: async () => {},
100
+ }),
101
+ useSiteLayout: () => ({
102
+ siteLayout: ref({ pages: {} }),
103
+ siteLayoutTree: ref(undefined),
104
+ loadingError: ref(null),
105
+ loadSiteLayout: async () => {},
106
+ refreshIfStale: async () => {},
107
+ findMatchingRoute: () => ({
108
+ metadata: {
109
+ fullId: "marketing.funnel",
110
+ hasAccess: true,
111
+ validation: validation.value,
112
+ },
113
+ params: {},
114
+ }),
115
+ }),
116
+ };
117
+ Object.entries(runtime).forEach(([name, value]) =>
118
+ vi.stubGlobal(name, value),
119
+ );
120
+ }
121
+
122
+ interface RenderedPage {
123
+ host: HTMLElement;
124
+ errors: unknown[];
125
+ }
126
+
127
+ async function renderPage(): Promise<RenderedPage> {
128
+ const errors: unknown[] = [];
129
+ const app = createSSRApp(() => h(CatchAllPage));
130
+ app.config.errorHandler = (error) => {
131
+ errors.push(error);
132
+ };
133
+ app.component("DmsRecursiveComponent", PageComponent);
134
+ const host = document.createElement("div");
135
+ host.innerHTML = await renderToString(app);
136
+ return { host, errors };
137
+ }
138
+
139
+ beforeEach(() => {
140
+ navigateDms.mockClear();
141
+ pageQuery.value = {};
142
+ validation.value = undefined;
143
+ installRuntime();
144
+ });
145
+
146
+ afterEach(() => {
147
+ vi.unstubAllGlobals();
148
+ });
149
+
150
+ describe("pageValidationRedirect", () => {
151
+ const context = {
152
+ routeParams: { id: "42" },
153
+ routeQuery: { website: "site-1" },
154
+ };
155
+
156
+ it("is undefined without a redirectTo", () => {
157
+ expect(
158
+ pageValidationRedirect(
159
+ { requiredQueryParams: ["id"] },
160
+ PAGE_PATH,
161
+ context,
162
+ ),
163
+ ).toBeUndefined();
164
+ });
165
+
166
+ it("fills the page URL's tokens and leaves out an empty query pair", () => {
167
+ expect(
168
+ pageValidationRedirect(
169
+ {
170
+ redirectTo: `${LIST_PATH}?website={{query.website}}&tab={{query.tab}}`,
171
+ },
172
+ PAGE_PATH,
173
+ context,
174
+ ),
175
+ ).toBe(`${LIST_PATH}?website=site-1`);
176
+ });
177
+
178
+ it("refuses a target on the page's own path, which would fail again", () => {
179
+ expect(
180
+ pageValidationRedirect(
181
+ { redirectTo: `${PAGE_PATH}/?id={{query.id}}` },
182
+ PAGE_PATH,
183
+ context,
184
+ ),
185
+ ).toBeUndefined();
186
+ expect(
187
+ pageValidationRedirect({ redirectTo: "?id=1" }, PAGE_PATH, context),
188
+ ).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("a page failing its validation", () => {
193
+ it("answers not found without a redirectTo", async () => {
194
+ validation.value = { requiredQueryParams: ["id"] };
195
+ const { errors } = await renderPage();
196
+ expect(errors).toEqual([new HttpError(NOT_FOUND)]);
197
+ expect(navigateDms).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it("replaces the URL with its redirectTo, drawing none of its blocks", async () => {
201
+ validation.value = {
202
+ requiredQueryParams: ["id"],
203
+ redirectTo: `${LIST_PATH}?website={{query.website}}`,
204
+ };
205
+ pageQuery.value = { website: "site-1" };
206
+ const { host, errors } = await renderPage();
207
+ expect(errors).toEqual([]);
208
+ expect(navigateDms).toHaveBeenCalledWith(`${LIST_PATH}?website=site-1`, {
209
+ replace: true,
210
+ });
211
+ expect(host.querySelector("[data-component]")).toBeNull();
212
+ });
213
+
214
+ it("draws its blocks once the required query is there", async () => {
215
+ validation.value = { requiredQueryParams: ["id"], redirectTo: LIST_PATH };
216
+ pageQuery.value = { id: "f1" };
217
+ const { host } = await renderPage();
218
+ expect(navigateDms).not.toHaveBeenCalled();
219
+ expect(host.querySelector("[data-component]")).not.toBeNull();
220
+ });
221
+ });
@@ -44,11 +44,24 @@ const Pair = defineComponent({
44
44
  h(Fragment, [h("h3", { class: "pair-head" }), h("p", "body")]),
45
45
  });
46
46
 
47
- const resolvedChild = (id: string, component: unknown) => ({
47
+ // A list beside the item it selects, like a split layout: one child in a
48
+ // named slot, the other in the default one.
49
+ const Split = defineComponent({
50
+ setup:
51
+ (_, { slots }) =>
52
+ () =>
53
+ h("div", { class: "split" }, [
54
+ h("aside", slots.aside?.()),
55
+ h("article", slots.default?.()),
56
+ ]),
57
+ });
58
+
59
+ const resolvedChild = (id: string, component: unknown, slot?: string) => ({
48
60
  id,
49
61
  component,
50
62
  options: {},
51
63
  children: [],
64
+ ...(slot ? { slot } : {}),
52
65
  });
53
66
 
54
67
  async function mountPage(showSecond = ref(true)) {
@@ -69,6 +82,10 @@ async function mountPage(showSecond = ref(true)) {
69
82
  showSecond.value
70
83
  ? block("tabs", Section, [resolvedChild("statistics", Section)])
71
84
  : null,
85
+ block("explorer", Split, [
86
+ resolvedChild("tree", Section, "aside"),
87
+ resolvedChild("header", Pair),
88
+ ]),
72
89
  ]),
73
90
  });
74
91
  app.component("DmsRecursiveComponent", RecursiveComponent);
@@ -115,6 +132,18 @@ describe("scrollToBlock", () => {
115
132
  });
116
133
  });
117
134
 
135
+ it("finds the children of a block that places them in its slots", async () => {
136
+ await mountPage();
137
+ expect(scrollToBlock("explorer.header")).toBe(true);
138
+ expect(scrolled.at(-1)?.element).toBe(
139
+ host.querySelector(".split article .pair-head"),
140
+ );
141
+ expect(scrollToBlock("explorer.tree")).toBe(true);
142
+ expect(scrolled.at(-1)?.element).toBe(
143
+ host.querySelector(".split aside section"),
144
+ );
145
+ });
146
+
118
147
  it("answers false for a block that is not on screen", async () => {
119
148
  const showSecond = await mountPage();
120
149
  expect(scrollToBlock("missing")).toBe(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.5.7 <0.6.0",
40
+ "@antelopejs/interface-dms": ">=0.5.9 <0.6.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -86,6 +86,9 @@ An absolute link (a public file, a snippet to copy, a mail) is built on `await G
86
86
  (`@antelopejs/interface-dms/api-base-url`, the backend) or `await GetClientBaseUrl()`
87
87
  (`/client-base-url`, the dashboard), never on `Host` / `X-Forwarded-*`: a reverse proxy may not
88
88
  forward them. Both answer the DMS config (`apiBaseUrl`, `clientBaseUrl`).
89
+ A client address (a rate limit, an anonymous visitor) comes from `await getClientIp(context)`
90
+ (`/client-ip`), never from `X-Forwarded-For` read by hand: it follows the DMS's
91
+ `auth.trustedProxies`, so the operator declares the proxies once.
89
92
 
90
93
  ## Navigation & access — module pages are owner-only
91
94