@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
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
/package/frontend-vue/layers/dms-ui/app/{build/components → components}/form/FormRequiredLegend.vue
RENAMED
|
File without changes
|