@antelopejs/dms 0.4.3 → 0.4.5
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 +32 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js.map +1 -1
- package/dist/implementations/dms/page.d.ts +1 -0
- package/dist/implementations/dms/page.js +8 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/implementations/dms/stale-session.d.ts +2 -0
- package/dist/implementations/dms/stale-session.js +16 -0
- package/dist/implementations/dms/stale-session.js.map +1 -0
- package/dist/implementations/dms-auth/index.d.ts +1 -0
- package/dist/implementations/dms-auth/index.js +19 -0
- package/dist/implementations/dms-auth/index.js.map +1 -1
- package/dist/routes/auth/client-ip.d.ts +7 -0
- package/dist/routes/auth/client-ip.js +42 -0
- package/dist/routes/auth/client-ip.js.map +1 -0
- package/dist/routes/auth/index.d.ts +1 -0
- package/dist/routes/auth/index.js +1 -0
- package/dist/routes/auth/index.js.map +1 -1
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +15 -4
- package/dist/routes/auth.js.map +1 -1
- package/dist/test/integration/auth/client-ip.test.d.ts +1 -0
- package/dist/test/integration/auth/client-ip.test.js +14 -0
- package/dist/test/integration/auth/client-ip.test.js.map +1 -0
- package/dist/test/integration/page-session.test.d.ts +1 -0
- package/dist/test/integration/page-session.test.js +52 -0
- package/dist/test/integration/page-session.test.js.map +1 -0
- package/dist/test/unit/client-ip.test.d.ts +1 -0
- package/dist/test/unit/client-ip.test.js +77 -0
- package/dist/test/unit/client-ip.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js +96 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js.map +1 -0
- package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -2
- package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +27 -2
- package/frontend-vue/layers/dms-core/app/utils/menu.ts +26 -10
- package/frontend-vue/layers/dms-core/app/utils/routePath.ts +23 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +3 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +3 -0
- package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +4 -1
- package/frontend-vue/layers/dms-layout/app/app.config.ts +9 -1
- package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -5
- package/frontend-vue/layers/dms-layout/app/components/profile/ProfileLanguage.vue +3 -23
- package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +33 -0
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +2 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +31 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationBadges.ts +36 -0
- package/frontend-vue/layers/dms-ui/app/build/types/tree.ts +5 -10
- package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +8 -5
- package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
- package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
- package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
- package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +53 -4
- package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
- package/frontend-vue/tests/api-error-title.test.ts +24 -0
- package/frontend-vue/tests/auth-middleware-error.test.ts +58 -0
- package/frontend-vue/tests/button-disabled-theme.test.ts +21 -0
- package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
- package/frontend-vue/tests/form-actions.test.ts +27 -0
- package/frontend-vue/tests/form-submit-target.test.ts +28 -0
- package/frontend-vue/tests/menu-navigation.test.ts +39 -10
- package/frontend-vue/tests/relation-badges.test.ts +32 -0
- package/frontend-vue/tests/tree-without-source.test.ts +79 -0
- package/frontend-vue/tests/user-language.test.ts +58 -0
- package/package.json +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from "vue";
|
|
3
3
|
import { useChartFetch } from "../../composables/chart/useChartFetch";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ABSENT_VALUE_PARTS,
|
|
6
|
+
formatValueParts,
|
|
7
|
+
} from "../../composables/chart/formatValue";
|
|
5
8
|
import { resolveSparklineAccent } from "../../composables/chart/resolveSparklineAccent";
|
|
6
9
|
import type {
|
|
7
10
|
KpiCardResponse,
|
|
@@ -69,18 +72,20 @@ const { data, isLoading } = useChartFetch<KpiCardResponse>({
|
|
|
69
72
|
watchSource: () => watchKey.value,
|
|
70
73
|
});
|
|
71
74
|
|
|
72
|
-
const value = computed(() => data.value?.value
|
|
75
|
+
const value = computed(() => data.value?.value);
|
|
73
76
|
const delta = computed(() => data.value?.delta ?? null);
|
|
74
77
|
const sparkline = computed(() => data.value?.sparkline ?? []);
|
|
75
78
|
|
|
76
79
|
const formattedParts = computed(() =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
value.value === undefined
|
|
81
|
+
? ABSENT_VALUE_PARTS
|
|
82
|
+
: formatValueParts(
|
|
83
|
+
value.value,
|
|
84
|
+
props.valueFormat,
|
|
85
|
+
locale.value,
|
|
86
|
+
props.currencyCode,
|
|
87
|
+
props.valuePrecision,
|
|
88
|
+
),
|
|
84
89
|
);
|
|
85
90
|
|
|
86
91
|
const showTrend = computed(() => props.showDelta && delta.value !== null);
|
|
@@ -112,6 +112,21 @@ export interface FormattedValueParts {
|
|
|
112
112
|
unitIsPrefix: boolean;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* What a card prints where its figure would go when the query measured nothing.
|
|
117
|
+
*
|
|
118
|
+
* An em dash and not a zero: a zero is itself a measurement, so a card printing
|
|
119
|
+
* one for a period with no rows in it states a result nobody computed. No unit
|
|
120
|
+
* either — there is no quantity for it to qualify.
|
|
121
|
+
*/
|
|
122
|
+
export const ABSENT_VALUE_TEXT = "—";
|
|
123
|
+
|
|
124
|
+
export const ABSENT_VALUE_PARTS: FormattedValueParts = {
|
|
125
|
+
value: ABSENT_VALUE_TEXT,
|
|
126
|
+
unit: "",
|
|
127
|
+
unitIsPrefix: false,
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
/** Splits the same formatted value into numeric and unit parts for KPI cards. */
|
|
116
131
|
export function formatValueParts(
|
|
117
132
|
value: number,
|
|
@@ -82,7 +82,8 @@ export interface DonutRecord {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export interface ChartCardResponse {
|
|
85
|
-
|
|
85
|
+
/** Absent when the query measured no group; the card shows no figure. */
|
|
86
|
+
value?: number;
|
|
86
87
|
delta?: number;
|
|
87
88
|
previousValue?: number;
|
|
88
89
|
series: ChartSeries[];
|
|
@@ -90,7 +91,8 @@ export interface ChartCardResponse {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export interface KpiCardResponse {
|
|
93
|
-
|
|
94
|
+
/** Absent when the query measured no group; the card shows no figure. */
|
|
95
|
+
value?: number;
|
|
94
96
|
delta?: number;
|
|
95
97
|
previousValue?: number;
|
|
96
98
|
sparkline?: number[];
|
|
@@ -17,6 +17,8 @@ export interface FormProps extends FormComponentProps {
|
|
|
17
17
|
submitUrl?: string;
|
|
18
18
|
submitUrlMethod?: HttpMethod;
|
|
19
19
|
submitLabel?: string;
|
|
20
|
+
/** Whether the buttons show; left out, once there is somewhere to submit to. */
|
|
21
|
+
showActions?: boolean;
|
|
20
22
|
successMessage?: string;
|
|
21
23
|
errorMessage?: string;
|
|
22
24
|
schema?: Record<string, unknown>;
|
|
@@ -28,6 +28,23 @@ export function cloneFormValue(
|
|
|
28
28
|
return JSON.parse(JSON.stringify(value));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether a form shows its reset and submit buttons.
|
|
33
|
+
*
|
|
34
|
+
* Asked for, they show whatever else holds — a form placed in the builder shows
|
|
35
|
+
* them before it has somewhere to submit to. Left to the form, they show once
|
|
36
|
+
* it has an address and a field someone can fill in.
|
|
37
|
+
*/
|
|
38
|
+
export function formShowsActions(
|
|
39
|
+
options: { showActions?: boolean; submitUrl?: string },
|
|
40
|
+
fields: ReadonlyArray<{ disabled?: boolean }>,
|
|
41
|
+
): boolean {
|
|
42
|
+
return (
|
|
43
|
+
options.showActions ??
|
|
44
|
+
(!!options.submitUrl && !fields.every((field) => field.disabled))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export function computeResetState(
|
|
32
49
|
state: Record<string, unknown>,
|
|
33
50
|
initialValues: FormData,
|
|
@@ -137,6 +154,25 @@ export function replaceUrlVariables(
|
|
|
137
154
|
return processedUrl;
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
/** Where a submit goes, or what it lacks to go anywhere. */
|
|
158
|
+
export type SubmitTarget = { url: string } | { missing: "url" | "token" };
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Where a submit is sent. A form with no URL has nowhere to send it — it is a
|
|
162
|
+
* read-only one — and a URL naming a token the page does not carry, such as an
|
|
163
|
+
* edit form opened without the id of its row, is not a route either: loading
|
|
164
|
+
* already skips such a URL, and submitting to it wrote to a path that was never
|
|
165
|
+
* meant to exist.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveSubmitTarget(
|
|
168
|
+
submitUrl: string | undefined,
|
|
169
|
+
context: ReplaceUrlVariablesContext,
|
|
170
|
+
): SubmitTarget {
|
|
171
|
+
if (!submitUrl) return { missing: "url" };
|
|
172
|
+
const url = replaceUrlVariables(submitUrl, context);
|
|
173
|
+
return url.includes("{{") ? { missing: "token" } : { url };
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
function processBeforeStateMappers(
|
|
141
177
|
data: FormData,
|
|
142
178
|
fields: FormFieldOrGroup[],
|
|
@@ -496,16 +532,29 @@ export const useForm = (props: FormProps) => {
|
|
|
496
532
|
}
|
|
497
533
|
};
|
|
498
534
|
|
|
535
|
+
const showUnresolvedTargetToast = () => {
|
|
536
|
+
toast.add({
|
|
537
|
+
title: processI18n("$dms.form.error_title"),
|
|
538
|
+
description: processI18n("$dms.form.error_no_target"),
|
|
539
|
+
color: Color.error,
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
|
|
499
543
|
const onSubmit = async (event: FormSubmitEvent<FormData>) => {
|
|
544
|
+
const target = resolveSubmitTarget(props.submitUrl, buildUrlContext());
|
|
545
|
+
if ("missing" in target) {
|
|
546
|
+
// A read-only form renders no submit button, but Enter in one of its
|
|
547
|
+
// fields still submits it: that does nothing. Falling back on `/` sent
|
|
548
|
+
// the values to the site root with a PUT and reported nothing.
|
|
549
|
+
if (target.missing === "token") showUnresolvedTargetToast();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const submitUrl = target.url;
|
|
500
553
|
const plainData = collectSubmitData(
|
|
501
554
|
event,
|
|
502
555
|
allFields.value,
|
|
503
556
|
effectiveSubmitDefaults.value,
|
|
504
557
|
);
|
|
505
|
-
const submitUrl = replaceUrlVariables(
|
|
506
|
-
props.submitUrl || "/",
|
|
507
|
-
buildUrlContext(),
|
|
508
|
-
);
|
|
509
558
|
|
|
510
559
|
loading.value = true;
|
|
511
560
|
try {
|
|
@@ -106,12 +106,6 @@ export async function useTree<T = unknown>(props: TreeProps) {
|
|
|
106
106
|
},
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
if (!props.fetchUrl && !props.staticNodes) {
|
|
110
|
-
throw new Error(
|
|
111
|
-
"Either fetchUrl or staticNodes must be provided to useTree",
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
109
|
const loadingNodes = ref<Set<string>>(new Set());
|
|
116
110
|
// Shallow on purpose: `TreeItem` carries a string index signature and
|
|
117
111
|
// recurses through `children`, so Vue's deep `UnwrapRef` on a tree node
|
|
@@ -194,12 +188,16 @@ export async function useTree<T = unknown>(props: TreeProps) {
|
|
|
194
188
|
}
|
|
195
189
|
}
|
|
196
190
|
|
|
191
|
+
// A tree with neither a URL nor nodes of its own holds nothing yet, which is
|
|
192
|
+
// what a block just placed on a page looks like: it is configured after it is
|
|
193
|
+
// placed, and the editor says what is still missing. Refusing to render at
|
|
194
|
+
// all would answer that gesture with a crash.
|
|
197
195
|
const dataLoader = props.fetchUrl
|
|
198
196
|
? () =>
|
|
199
197
|
$authFetch<TreeNode<T>[]>(props.fetchUrl!, {
|
|
200
198
|
method: props.fetchUrlMethod || "GET",
|
|
201
199
|
})
|
|
202
|
-
: () => Promise.resolve(props.staticNodes as TreeNode<T>[]);
|
|
200
|
+
: () => Promise.resolve((props.staticNodes ?? []) as TreeNode<T>[]);
|
|
203
201
|
|
|
204
202
|
const { data, status, refresh } = await useDmsAsyncData(
|
|
205
203
|
`tree-${props.componentId}-${props.pageId}-${props.componentId}`,
|
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
"success_message": "Data has been successfully saved",
|
|
162
162
|
"error_title": "Error",
|
|
163
163
|
"error_unknown": "An unknown error occurred",
|
|
164
|
+
"error_no_target": "This form does not know which item to save. Open it from a link that names one.",
|
|
164
165
|
"no_data": "No data available",
|
|
165
166
|
"empty_value": "—",
|
|
166
167
|
"actions": "Actions",
|
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
"success_message": "Les données ont été enregistrées avec succès",
|
|
162
162
|
"error_title": "Erreur",
|
|
163
163
|
"error_unknown": "Une erreur inconnue s'est produite",
|
|
164
|
+
"error_no_target": "Ce formulaire ne sait pas quel élément enregistrer. Ouvrez-le depuis un lien qui en désigne un.",
|
|
164
165
|
"no_data": "Aucune donnée disponible",
|
|
165
166
|
"empty_value": "—",
|
|
166
167
|
"actions": "Actions",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveApiErrorTitle } from "../layers/dms-core/app/composables/useApiError";
|
|
3
|
+
|
|
4
|
+
describe("resolveApiErrorTitle", () => {
|
|
5
|
+
it("titles a 4xx response as a refused request", () => {
|
|
6
|
+
expect(resolveApiErrorTitle({ statusCode: 403 })).toBe(
|
|
7
|
+
"error.request_refused.title",
|
|
8
|
+
);
|
|
9
|
+
expect(resolveApiErrorTitle({ status: 422 })).toBe(
|
|
10
|
+
"error.request_refused.title",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("titles a 5xx response as a server error", () => {
|
|
15
|
+
expect(resolveApiErrorTitle({ statusCode: 502 })).toBe("error.500.title");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("titles an error without a status as a server error", () => {
|
|
19
|
+
expect(resolveApiErrorTitle(new Error("network down"))).toBe(
|
|
20
|
+
"error.500.title",
|
|
21
|
+
);
|
|
22
|
+
expect(resolveApiErrorTitle(undefined)).toBe("error.500.title");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
type Middleware = (to: unknown) => Promise<unknown>;
|
|
4
|
+
|
|
5
|
+
const pageError = { value: null as { statusCode: number } | null };
|
|
6
|
+
const navigateDms = vi.fn();
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
vi.stubGlobal("defineDmsMiddleware", (handler: Middleware) => handler);
|
|
11
|
+
vi.stubGlobal("useError", () => pageError);
|
|
12
|
+
vi.stubGlobal("abortNavigation", () => false);
|
|
13
|
+
vi.stubGlobal("navigateDms", navigateDms);
|
|
14
|
+
vi.stubGlobal("useSiteLayout", () => ({
|
|
15
|
+
siteLayout: { value: {} },
|
|
16
|
+
findMatchingRoute: () => ({ metadata: {} }),
|
|
17
|
+
}));
|
|
18
|
+
vi.stubGlobal("useUserSession", () => ({ loggedIn: { value: false } }));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
pageError.value = null;
|
|
23
|
+
navigateDms.mockReset();
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function loadMiddleware(): Promise<Middleware> {
|
|
28
|
+
const module = await import("../layers/dms-auth/app/middleware/auth");
|
|
29
|
+
return module.default as unknown as Middleware;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const privateRoute = {
|
|
33
|
+
path: "/projects/project",
|
|
34
|
+
fullPath: "/projects/project",
|
|
35
|
+
matched: [{}],
|
|
36
|
+
meta: { auth: true },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("auth middleware", () => {
|
|
40
|
+
it("aborts without throwing when the page already raised an error", async () => {
|
|
41
|
+
pageError.value = { statusCode: 404 };
|
|
42
|
+
const middleware = await loadMiddleware();
|
|
43
|
+
|
|
44
|
+
await expect(middleware(privateRoute)).resolves.toBe(false);
|
|
45
|
+
expect(navigateDms).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("still sends a signed-out visitor to the login page", async () => {
|
|
49
|
+
const middleware = await loadMiddleware();
|
|
50
|
+
|
|
51
|
+
await middleware(privateRoute);
|
|
52
|
+
|
|
53
|
+
expect(navigateDms).toHaveBeenCalledWith({
|
|
54
|
+
path: "/auth",
|
|
55
|
+
query: { redirect: "/projects/project" },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
import appConfig from "../layers/dms-layout/app/app.config";
|
|
4
|
+
|
|
5
|
+
// Nuxt UI 4's own button base, which the app config is merged over.
|
|
6
|
+
const NUXT_UI_BUTTON_BASE =
|
|
7
|
+
"rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75";
|
|
8
|
+
|
|
9
|
+
describe("button theme", () => {
|
|
10
|
+
const merged = twMerge(NUXT_UI_BUTTON_BASE, appConfig.ui.button.slots.base);
|
|
11
|
+
|
|
12
|
+
it("fades disabled buttons past Nuxt UI's default", () => {
|
|
13
|
+
expect(merged).toContain("disabled:opacity-50");
|
|
14
|
+
expect(merged).toContain("aria-disabled:opacity-50");
|
|
15
|
+
expect(merged).not.toContain("opacity-75");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("cancels the press effect on a disabled button", () => {
|
|
19
|
+
expect(merged).toContain("disabled:active:scale-100");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createSSRApp, defineComponent, h, ref } from "vue";
|
|
2
|
+
import { renderToString } from "vue/server-renderer";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import ChartCard from "../layers/dms-ui/app/components/chart/ChartCard.vue";
|
|
5
|
+
import KpiCard from "../layers/dms-ui/app/components/kpi/KpiCard.vue";
|
|
6
|
+
import { ABSENT_VALUE_TEXT } from "../layers/dms-ui/app/composables/chart/formatValue";
|
|
7
|
+
|
|
8
|
+
// A period the query measured nothing in: the response carries its series and no
|
|
9
|
+
// figure at all, which is what the contract answers rather than a zero.
|
|
10
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
11
|
+
useChartFetch: () => ({
|
|
12
|
+
data: ref({ series: [{ name: "Revenue", data: [] }] }),
|
|
13
|
+
isLoading: ref(false),
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const Slot = defineComponent({
|
|
18
|
+
setup:
|
|
19
|
+
(_, { slots }) =>
|
|
20
|
+
() =>
|
|
21
|
+
h("div", slots.default?.()),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const SLOT_COMPONENTS = [
|
|
25
|
+
"DmsCard",
|
|
26
|
+
"DmsClientOnly",
|
|
27
|
+
"UIcon",
|
|
28
|
+
"USkeleton",
|
|
29
|
+
"DmsTrendBadge",
|
|
30
|
+
"DmsSparkline",
|
|
31
|
+
"UBadge",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function stubNuxt() {
|
|
35
|
+
vi.stubGlobal("useI18n", () => ({
|
|
36
|
+
locale: ref("en-US"),
|
|
37
|
+
t: (key: string) => key,
|
|
38
|
+
}));
|
|
39
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
40
|
+
processI18n: (text: string) => text,
|
|
41
|
+
}));
|
|
42
|
+
vi.stubGlobal("useComponentEvent", () => ({}));
|
|
43
|
+
vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type CardProps = InstanceType<typeof ChartCard>["$props"];
|
|
47
|
+
|
|
48
|
+
async function renderCards() {
|
|
49
|
+
stubNuxt();
|
|
50
|
+
const props: CardProps = {
|
|
51
|
+
title: "Revenue",
|
|
52
|
+
componentId: "card",
|
|
53
|
+
pageId: "absent",
|
|
54
|
+
valueFormat: "currency",
|
|
55
|
+
};
|
|
56
|
+
const app = createSSRApp({
|
|
57
|
+
render: () => h("main", [h(ChartCard, props), h(KpiCard, props)]),
|
|
58
|
+
});
|
|
59
|
+
for (const name of SLOT_COMPONENTS) app.component(name, Slot);
|
|
60
|
+
return renderToString(app);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
64
|
+
|
|
65
|
+
describe("a card whose query measured nothing", () => {
|
|
66
|
+
it("shows an absence where the figure goes, on both cards", async () => {
|
|
67
|
+
const html = await renderCards();
|
|
68
|
+
expect(html.split(ABSENT_VALUE_TEXT)).toHaveLength(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prints no zero, and no unit for a quantity there is none of", async () => {
|
|
72
|
+
const html = await renderCards();
|
|
73
|
+
expect(html, "a zero reads exactly like a measured figure").not.toContain(
|
|
74
|
+
"<span>0</span>",
|
|
75
|
+
);
|
|
76
|
+
expect(html).not.toContain("€");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formShowsActions } from "../layers/dms-ui/app/composables/form/useForm";
|
|
3
|
+
|
|
4
|
+
describe("formShowsActions", () => {
|
|
5
|
+
it("shows the buttons once the form has somewhere to submit to", () => {
|
|
6
|
+
expect(formShowsActions({ submitUrl: "/api/order/new" }, [{}])).toBe(true);
|
|
7
|
+
expect(formShowsActions({}, [{}])).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("hides them on a form nothing in which can be filled in", () => {
|
|
11
|
+
expect(
|
|
12
|
+
formShowsActions({ submitUrl: "/api/order/new" }, [{ disabled: true }]),
|
|
13
|
+
).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows them when asked, before there is an address or a field", () => {
|
|
17
|
+
expect(formShowsActions({ showActions: true }, [])).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("hides them when asked, whatever the form could submit", () => {
|
|
21
|
+
expect(
|
|
22
|
+
formShowsActions({ showActions: false, submitUrl: "/api/order/new" }, [
|
|
23
|
+
{},
|
|
24
|
+
]),
|
|
25
|
+
).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveSubmitTarget } from "../layers/dms-ui/app/composables/form/useForm";
|
|
3
|
+
|
|
4
|
+
const page = { routeParams: { id: "42" }, routeQuery: { id: "7" } };
|
|
5
|
+
|
|
6
|
+
describe("resolveSubmitTarget", () => {
|
|
7
|
+
it("sends a submit where its URL points, tokens filled in", () => {
|
|
8
|
+
expect(resolveSubmitTarget("/api/ticket/edit?id={{query.id}}", page)).toEqual(
|
|
9
|
+
{ url: "/api/ticket/edit?id=7" },
|
|
10
|
+
);
|
|
11
|
+
expect(resolveSubmitTarget("/api/ticket/edit?id={{params.id}}", page)).toEqual(
|
|
12
|
+
{ url: "/api/ticket/edit?id=42" },
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("has nowhere to send a read-only form's values", () => {
|
|
17
|
+
expect(resolveSubmitTarget(undefined, page)).toEqual({ missing: "url" });
|
|
18
|
+
expect(resolveSubmitTarget("", page)).toEqual({ missing: "url" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("refuses a URL naming a row the page does not carry", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveSubmitTarget("/api/ticket/edit?id={{query.id}}", {
|
|
24
|
+
routeQuery: {},
|
|
25
|
+
}),
|
|
26
|
+
).toEqual({ missing: "token" });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -29,10 +29,16 @@ describe("buildMenuItemTarget", () => {
|
|
|
29
29
|
expect(buildMenuItemTarget(node({}))).to.equal("/project");
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it("returns a
|
|
32
|
+
it("returns a string link carrying the query the entry declares", () => {
|
|
33
33
|
expect(
|
|
34
34
|
buildMenuItemTarget(node({ query: { project: "invoicer" } })),
|
|
35
|
-
).to.
|
|
35
|
+
).to.equal("/project?project=invoicer");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("encodes the query values of the link", () => {
|
|
39
|
+
expect(
|
|
40
|
+
buildMenuItemTarget(node({ query: { project: "a b&c", env: "prod" } })),
|
|
41
|
+
).to.equal("/project?project=a+b%26c&env=prod");
|
|
36
42
|
});
|
|
37
43
|
|
|
38
44
|
it("returns no target for a category that has no page behind it", () => {
|
|
@@ -45,11 +51,11 @@ describe("buildMenuItemTarget", () => {
|
|
|
45
51
|
describe("isMenuItemActive with query entries", () => {
|
|
46
52
|
const invoicer = {
|
|
47
53
|
fullId: "cloud.projects.invoicer",
|
|
48
|
-
to:
|
|
54
|
+
to: "/project?project=invoicer",
|
|
49
55
|
};
|
|
50
56
|
const resto = {
|
|
51
57
|
fullId: "cloud.projects.resto",
|
|
52
|
-
to:
|
|
58
|
+
to: "/project?project=resto",
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
it("highlights only the entry matching the current query", () => {
|
|
@@ -63,6 +69,22 @@ describe("isMenuItemActive with query entries", () => {
|
|
|
63
69
|
expect(isMenuItemActive(invoicer, null, current)).to.equal(true);
|
|
64
70
|
});
|
|
65
71
|
|
|
72
|
+
it("tells apart entries whose query values share a prefix", () => {
|
|
73
|
+
const current = route("/project", { project: "invoicer-2" });
|
|
74
|
+
expect(isMenuItemActive(invoicer, null, current)).to.equal(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("stays active on the entry's sub-routes carrying its query", () => {
|
|
78
|
+
const current = route("/project/settings", { project: "invoicer" });
|
|
79
|
+
expect(isMenuItemActive(invoicer, null, current)).to.equal(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("still accepts an object target", () => {
|
|
83
|
+
const entry = { to: { path: "/project", query: { project: "invoicer" } } };
|
|
84
|
+
const current = route("/project", { project: "invoicer" });
|
|
85
|
+
expect(isMenuItemActive(entry, null, current)).to.equal(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
66
88
|
it("stays inactive on the same path without the entry's query", () => {
|
|
67
89
|
expect(isMenuItemActive(invoicer, null, route("/project"))).to.equal(false);
|
|
68
90
|
});
|
|
@@ -86,11 +108,8 @@ describe("addActiveStateToMenuItems", () => {
|
|
|
86
108
|
it("marks a single query entry active across a group", () => {
|
|
87
109
|
const groups = [
|
|
88
110
|
[
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
to: { path: "/project", query: { project: "invoicer" } },
|
|
92
|
-
},
|
|
93
|
-
{ id: "resto", to: { path: "/project", query: { project: "resto" } } },
|
|
111
|
+
{ id: "invoicer", to: "/project?project=invoicer" },
|
|
112
|
+
{ id: "resto", to: "/project?project=resto" },
|
|
94
113
|
],
|
|
95
114
|
];
|
|
96
115
|
|
|
@@ -100,7 +119,17 @@ describe("addActiveStateToMenuItems", () => {
|
|
|
100
119
|
route("/project", { project: "resto" }),
|
|
101
120
|
);
|
|
102
121
|
|
|
103
|
-
expect(group?.[0]?.active).to.equal(
|
|
122
|
+
expect(group?.[0]?.active).to.equal(false);
|
|
104
123
|
expect(group?.[1]?.active).to.equal(true);
|
|
105
124
|
});
|
|
125
|
+
|
|
126
|
+
it("leaves the native highlighting of an inactive plain entry alone", () => {
|
|
127
|
+
const [group] = addActiveStateToMenuItems(
|
|
128
|
+
[[{ id: "orders", to: "/orders" }]],
|
|
129
|
+
null,
|
|
130
|
+
route("/invoices"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(group?.[0]?.active).to.equal(undefined);
|
|
134
|
+
});
|
|
106
135
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildRelationBadges } from "../layers/dms-ui/app/build/composables/data-types/relationBadges";
|
|
3
|
+
|
|
4
|
+
describe("buildRelationBadges", () => {
|
|
5
|
+
it("reads each resolved related row through the label key", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildRelationBadges([{ name: "Admin" }, { name: "Billing" }], "name"),
|
|
8
|
+
).to.deep.equal({ visible: ["Admin", "Billing"], hidden: [] });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("folds the labels past the limit into the hidden list", () => {
|
|
12
|
+
const roles = ["A", "B", "C", "D", "E"].map((name) => ({ name }));
|
|
13
|
+
expect(buildRelationBadges(roles, "name", 3)).to.deep.equal({
|
|
14
|
+
visible: ["A", "B", "C"],
|
|
15
|
+
hidden: ["D", "E"],
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("shows a raw id when the relation was not resolved", () => {
|
|
20
|
+
expect(buildRelationBadges(["role-1"], "name").visible).to.deep.equal([
|
|
21
|
+
"role-1",
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops items without a label and renders nothing for no value", () => {
|
|
26
|
+
expect(buildRelationBadges([{ other: "x" }, null], "name")).to.deep.equal({
|
|
27
|
+
visible: [],
|
|
28
|
+
hidden: [],
|
|
29
|
+
});
|
|
30
|
+
expect(buildRelationBadges([], "name").visible).to.deep.equal([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A tree is configured after it is placed.
|
|
4
|
+
*
|
|
5
|
+
* The editor drops a block and the author points it at its data afterwards, so
|
|
6
|
+
* a tree with neither a URL nor nodes of its own is a page under construction,
|
|
7
|
+
* not a mistake. It used to throw during setup, which the canvas could only
|
|
8
|
+
* report as a crash on the block the author had just placed.
|
|
9
|
+
*/
|
|
10
|
+
import * as vue from "vue";
|
|
11
|
+
import { ref } from "vue";
|
|
12
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
import type { TreeProps } from "../layers/dms-ui/app/composables/tree/types";
|
|
14
|
+
|
|
15
|
+
// The layer's auto-imports, as the Nuxt build would supply them: Vue's own
|
|
16
|
+
// first, then the DMS helpers the composable reaches for.
|
|
17
|
+
for (const name of [
|
|
18
|
+
"ref",
|
|
19
|
+
"shallowRef",
|
|
20
|
+
"computed",
|
|
21
|
+
"watch",
|
|
22
|
+
"watchEffect",
|
|
23
|
+
"toValue",
|
|
24
|
+
"nextTick",
|
|
25
|
+
] as const) {
|
|
26
|
+
vi.stubGlobal(name, vue[name]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
vi.stubGlobal(
|
|
30
|
+
"TreeEvents",
|
|
31
|
+
new Proxy({}, { get: (_target, key) => String(key) }),
|
|
32
|
+
);
|
|
33
|
+
vi.stubGlobal("createError", (payload: unknown) => new Error(String(payload)));
|
|
34
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
35
|
+
$authFetch: async () => [],
|
|
36
|
+
}));
|
|
37
|
+
vi.stubGlobal("useComponentEvent", () => ({ sendComponentEvent: () => {} }));
|
|
38
|
+
vi.stubGlobal("useDefinedFunctions", () => ({ getFunction: () => undefined }));
|
|
39
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: (text: string) => text }));
|
|
40
|
+
vi.stubGlobal("useWatch", () => ({ isLoading: ref(false), state: ref({}) }));
|
|
41
|
+
vi.stubGlobal("useToast", () => ({ add: () => {} }));
|
|
42
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
43
|
+
vi.stubGlobal("useEventedAction", () => ({ execute: async () => [] }));
|
|
44
|
+
vi.stubGlobal(
|
|
45
|
+
"useDmsAsyncData",
|
|
46
|
+
async (_key: string, handler: () => Promise<unknown>) => ({
|
|
47
|
+
data: ref(await handler()),
|
|
48
|
+
status: ref("success"),
|
|
49
|
+
refresh: async () => {},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const { useTree } = await import(
|
|
54
|
+
"../layers/dms-ui/app/composables/tree/useTree"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
describe("a tree with no source yet", () => {
|
|
58
|
+
const props = (extra: Partial<TreeProps> = {}) =>
|
|
59
|
+
({
|
|
60
|
+
selectionBehavior: "toggle",
|
|
61
|
+
componentId: "tree",
|
|
62
|
+
pageId: "page",
|
|
63
|
+
...extra,
|
|
64
|
+
}) as TreeProps;
|
|
65
|
+
|
|
66
|
+
it("holds nothing, rather than refusing to render", async () => {
|
|
67
|
+
const tree = await useTree(props());
|
|
68
|
+
|
|
69
|
+
expect(tree.items.value).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("still reads the nodes it is given", async () => {
|
|
73
|
+
const tree = await useTree(
|
|
74
|
+
props({ staticNodes: [{ value: "one", label: "One" }] }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(tree.items.value.map((node) => node.value)).toEqual(["one"]);
|
|
78
|
+
});
|
|
79
|
+
});
|