@antelopejs/dms 0.4.2 → 0.4.4
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 +43 -0
- package/dist/pages/settings/users/invites.js +6 -5
- package/dist/pages/settings/users/invites.js.map +1 -1
- package/dist/pages/settings/users/members.d.ts +1 -0
- package/dist/pages/settings/users/members.js +12 -3
- package/dist/pages/settings/users/members.js.map +1 -1
- 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/frontend-vue/dms.frontend.ts +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +4 -2
- package/frontend-vue/layers/dms-layout/app/composables/general/types/index.ts +7 -0
- package/frontend-vue/layers/dms-layout/app/composables/general/useColorModePreference.ts +17 -0
- package/frontend-vue/layers/dms-layout/app/custom-pages/settings/appearance.vue +6 -6
- package/frontend-vue/layers/dms-layout/app/plugins/color-mode.ts +100 -0
- 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 +49 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/Calendar.vue +49 -26
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +77 -32
- 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/chart/useThemeRevision.ts +0 -22
- 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 +76 -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 +2 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +2 -0
- package/frontend-vue/package.json +2 -2
- package/frontend-vue/pnpm-lock.yaml +7 -33
- package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
- package/frontend-vue/tests/color-mode-server-render.test.ts +84 -0
- package/frontend-vue/tests/color-mode-sync.test.ts +320 -0
- package/frontend-vue/tests/form-actions.test.ts +27 -0
- package/frontend-vue/tests/form-required-fields.test.ts +44 -0
- package/frontend-vue/tests/form-submit-target.test.ts +28 -0
- package/frontend-vue/tests/tree-without-source.test.ts +79 -0
- package/package.json +1 -1
|
@@ -11,10 +11,13 @@ const props = defineProps<GridRowProps>();
|
|
|
11
11
|
|
|
12
12
|
const gridContext = inject<GridContext>(GRID_CONTEXT);
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
/** The identity this row counts under, so its width dies with it. */
|
|
15
|
+
const rowId = Symbol("grid-row");
|
|
16
|
+
|
|
17
|
+
const columnCount = computed(() => props.childCount || 0);
|
|
18
|
+
|
|
19
|
+
watchEffect(() => gridContext?.setRowColumnCount(rowId, columnCount.value));
|
|
20
|
+
onUnmounted(() => gridContext?.dropRow(rowId));
|
|
18
21
|
|
|
19
22
|
const rowStyle = computed(() => {
|
|
20
23
|
const gap = gridContext?.gap.value ?? DEFAULT_GAP;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export const GRID_CONTEXT = Symbol("grid-context");
|
|
2
2
|
|
|
3
3
|
export interface GridContext {
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A row states how many columns it holds, and says so again whenever that
|
|
6
|
+
* changes: a row built by hand never moves, but one an editor fills gains and
|
|
7
|
+
* loses children while the page is open.
|
|
8
|
+
*/
|
|
9
|
+
setRowColumnCount: (row: symbol, count: number) => void;
|
|
10
|
+
/** A row that unmounts stops counting, so a stale width cannot outlive it. */
|
|
11
|
+
dropRow: (row: symbol) => void;
|
|
5
12
|
maxColumns: ComputedRef<number>;
|
|
6
13
|
gap: ComputedRef<string>;
|
|
7
14
|
minColumnWidth: ComputedRef<string>;
|
|
@@ -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[];
|
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
import { onBeforeUnmount, onMounted, ref, type Ref } from "vue";
|
|
2
2
|
import { clearChartColorCache } from "./useChartTheme";
|
|
3
3
|
|
|
4
|
-
interface ColorModeLike {
|
|
5
|
-
preference?: string;
|
|
6
|
-
value?: string;
|
|
7
|
-
$subscribe?: (cb: () => void) => () => void;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
type ColorModeFactory = () => ColorModeLike;
|
|
11
|
-
|
|
12
4
|
const THEME_OBSERVED_ATTRIBUTES = ["class", "data-theme", "style"];
|
|
13
5
|
|
|
14
|
-
function getColorModeFactory(): ColorModeFactory | undefined {
|
|
15
|
-
const fn = (globalThis as unknown as { useColorMode?: ColorModeFactory })
|
|
16
|
-
.useColorMode;
|
|
17
|
-
return typeof fn === "function" ? fn : undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function trySubscribeColorMode(bump: () => void) {
|
|
21
|
-
const factory = getColorModeFactory();
|
|
22
|
-
if (!factory) return;
|
|
23
|
-
const mode = factory();
|
|
24
|
-
mode?.$subscribe?.(bump);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
6
|
function createThemeObserver(bump: () => void): MutationObserver {
|
|
28
7
|
const observer = new MutationObserver(bump);
|
|
29
8
|
observer.observe(document.documentElement, {
|
|
@@ -47,7 +26,6 @@ export function useThemeRevision(): Ref<number> {
|
|
|
47
26
|
|
|
48
27
|
onMounted(() => {
|
|
49
28
|
observer = createThemeObserver(bump);
|
|
50
|
-
trySubscribeColorMode(bump);
|
|
51
29
|
});
|
|
52
30
|
|
|
53
31
|
onBeforeUnmount(() => {
|
|
@@ -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[],
|
|
@@ -258,6 +294,28 @@ export function buildValidationSchema(
|
|
|
258
294
|
return z.object(newShape).passthrough();
|
|
259
295
|
}
|
|
260
296
|
|
|
297
|
+
// A switch always holds a value (off is `false`): it can never be left
|
|
298
|
+
// missing, so marking it required would only suggest an action that does not
|
|
299
|
+
// exist.
|
|
300
|
+
const ALWAYS_FILLED_FIELD_TYPES = new Set(["boolean"]);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Whether the form marks a field as required: declared so, or made so by a
|
|
304
|
+
* watch action, and neither disabled nor hidden, since `buildValidationSchema`
|
|
305
|
+
* does not validate inactive fields, nor a type that always holds a value.
|
|
306
|
+
*/
|
|
307
|
+
export function isFieldMarkedRequired(
|
|
308
|
+
field: Pick<FormField, "id" | "required" | "disabled" | "type">,
|
|
309
|
+
disabled: Set<string> | undefined,
|
|
310
|
+
hidden: Set<string> | undefined,
|
|
311
|
+
required: Set<string> | undefined,
|
|
312
|
+
): boolean {
|
|
313
|
+
if (field.disabled || disabled?.has(field.id) || hidden?.has(field.id))
|
|
314
|
+
return false;
|
|
315
|
+
if (field.type && ALWAYS_FILLED_FIELD_TYPES.has(field.type)) return false;
|
|
316
|
+
return !!field.required || (required?.has(field.id) ?? false);
|
|
317
|
+
}
|
|
318
|
+
|
|
261
319
|
function watchFieldChanges(
|
|
262
320
|
state: Ref<Record<string, unknown>>,
|
|
263
321
|
componentId: string | undefined,
|
|
@@ -474,16 +532,29 @@ export const useForm = (props: FormProps) => {
|
|
|
474
532
|
}
|
|
475
533
|
};
|
|
476
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
|
+
|
|
477
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;
|
|
478
553
|
const plainData = collectSubmitData(
|
|
479
554
|
event,
|
|
480
555
|
allFields.value,
|
|
481
556
|
effectiveSubmitDefaults.value,
|
|
482
557
|
);
|
|
483
|
-
const submitUrl = replaceUrlVariables(
|
|
484
|
-
props.submitUrl || "/",
|
|
485
|
-
buildUrlContext(),
|
|
486
|
-
);
|
|
487
558
|
|
|
488
559
|
loading.value = true;
|
|
489
560
|
try {
|
|
@@ -546,6 +617,7 @@ export const useForm = (props: FormProps) => {
|
|
|
546
617
|
allFields,
|
|
547
618
|
disabledFields,
|
|
548
619
|
hiddenFields,
|
|
620
|
+
requiredFields,
|
|
549
621
|
isFieldGroup,
|
|
550
622
|
submitSucceeded,
|
|
551
623
|
resolvedFetchUrl,
|
|
@@ -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",
|
|
@@ -169,6 +170,7 @@
|
|
|
169
170
|
"row_added_success": "The item has been successfully added",
|
|
170
171
|
"error_missing_fields": "Missing required fields",
|
|
171
172
|
"error_fill_all_fields": "Please fill in all required fields",
|
|
173
|
+
"required_legend": "Required fields",
|
|
172
174
|
"fetch_error_title": "Failed to fetch data",
|
|
173
175
|
"fetch_error_unknown": "An unexpected error occurred. Please try again later.",
|
|
174
176
|
"select_date": "Select date",
|
|
@@ -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",
|
|
@@ -169,6 +170,7 @@
|
|
|
169
170
|
"row_added_success": "L'élément a été ajouté avec succès",
|
|
170
171
|
"error_missing_fields": "Champs requis manquants",
|
|
171
172
|
"error_fill_all_fields": "Veuillez remplir tous les champs requis",
|
|
173
|
+
"required_legend": "Champs requis",
|
|
172
174
|
"error_not_submitted": "Le formulaire n'a pas été soumis",
|
|
173
175
|
"fetch_error_title": "Échec de la récupération des données",
|
|
174
176
|
"fetch_error_unknown": "Une erreur inattendue s'est produite. Veuillez réessayer plus tard.",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@vue-flow/controls": "^1.1.3",
|
|
40
40
|
"@vue-flow/core": "^1.48.2",
|
|
41
41
|
"@vue-flow/minimap": "^1.5.4",
|
|
42
|
-
"@vueuse/core": "^
|
|
42
|
+
"@vueuse/core": "^14.3.0",
|
|
43
43
|
"@vueuse/integrations": "^14.1.0",
|
|
44
44
|
"apexcharts": "^4.4.0",
|
|
45
45
|
"defu": "^6.1.4",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@antelopejs/core": ">=1.9.1 <2",
|
|
62
|
-
"@antelopejs/dms-frontend": ">=0.2.
|
|
62
|
+
"@antelopejs/dms-frontend": ">=0.2.8 <1.0.0",
|
|
63
63
|
"@eslint/js": "^8.57.0",
|
|
64
64
|
"@inertiajs/vue3": "^3.7.0",
|
|
65
65
|
"@nuxt/ui": "4.10.0",
|
|
@@ -66,8 +66,8 @@ importers:
|
|
|
66
66
|
specifier: ^1.5.4
|
|
67
67
|
version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
|
|
68
68
|
'@vueuse/core':
|
|
69
|
-
specifier: ^
|
|
70
|
-
version:
|
|
69
|
+
specifier: ^14.3.0
|
|
70
|
+
version: 14.4.0(vue@3.5.25(typescript@5.9.3))
|
|
71
71
|
'@vueuse/integrations':
|
|
72
72
|
specifier: ^14.1.0
|
|
73
73
|
version: 14.1.0(focus-trap@7.8.0)(fuse.js@7.5.0)(sortablejs@1.14.0)(vue@3.5.25(typescript@5.9.3))
|
|
@@ -124,8 +124,8 @@ importers:
|
|
|
124
124
|
specifier: '>=1.9.1 <2'
|
|
125
125
|
version: 1.9.1(@types/node@24.8.1)
|
|
126
126
|
'@antelopejs/dms-frontend':
|
|
127
|
-
specifier: '>=0.2.
|
|
128
|
-
version: 0.2.
|
|
127
|
+
specifier: '>=0.2.8 <1.0.0'
|
|
128
|
+
version: 0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))
|
|
129
129
|
'@eslint/js':
|
|
130
130
|
specifier: ^8.57.0
|
|
131
131
|
version: 8.57.0
|
|
@@ -200,8 +200,8 @@ packages:
|
|
|
200
200
|
resolution: {integrity: sha512-MPLJak0rPluV4lgsdsO5ba03sgTxIGSncajBcoaoppuppQmksdpaemojIHiVLJ4LiG0iiH21Roaed8XXy1XkYQ==}
|
|
201
201
|
hasBin: true
|
|
202
202
|
|
|
203
|
-
'@antelopejs/dms-frontend@0.2.
|
|
204
|
-
resolution: {integrity: sha512-
|
|
203
|
+
'@antelopejs/dms-frontend@0.2.8':
|
|
204
|
+
resolution: {integrity: sha512-7qrlf2M3J26DZxGZVvM/3qjPDeDfoRMrE7HEPFxWav8Pe1QFct7LtgY7NmKup9XdrRyUw4nqckxvW8UZDSjpeg==}
|
|
205
205
|
engines: {node: '>=20.12.0'}
|
|
206
206
|
hasBin: true
|
|
207
207
|
peerDependencies:
|
|
@@ -1753,11 +1753,6 @@ packages:
|
|
|
1753
1753
|
'@vueuse/core@12.8.2':
|
|
1754
1754
|
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
|
1755
1755
|
|
|
1756
|
-
'@vueuse/core@13.9.0':
|
|
1757
|
-
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
|
|
1758
|
-
peerDependencies:
|
|
1759
|
-
vue: ^3.5.0
|
|
1760
|
-
|
|
1761
1756
|
'@vueuse/core@14.1.0':
|
|
1762
1757
|
resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==}
|
|
1763
1758
|
peerDependencies:
|
|
@@ -1858,9 +1853,6 @@ packages:
|
|
|
1858
1853
|
'@vueuse/metadata@12.8.2':
|
|
1859
1854
|
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
|
1860
1855
|
|
|
1861
|
-
'@vueuse/metadata@13.9.0':
|
|
1862
|
-
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
|
|
1863
|
-
|
|
1864
1856
|
'@vueuse/metadata@14.1.0':
|
|
1865
1857
|
resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
|
|
1866
1858
|
|
|
@@ -1873,11 +1865,6 @@ packages:
|
|
|
1873
1865
|
'@vueuse/shared@12.8.2':
|
|
1874
1866
|
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
|
1875
1867
|
|
|
1876
|
-
'@vueuse/shared@13.9.0':
|
|
1877
|
-
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
|
|
1878
|
-
peerDependencies:
|
|
1879
|
-
vue: ^3.5.0
|
|
1880
|
-
|
|
1881
1868
|
'@vueuse/shared@14.1.0':
|
|
1882
1869
|
resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==}
|
|
1883
1870
|
peerDependencies:
|
|
@@ -4344,7 +4331,7 @@ snapshots:
|
|
|
4344
4331
|
transitivePeerDependencies:
|
|
4345
4332
|
- '@types/node'
|
|
4346
4333
|
|
|
4347
|
-
'@antelopejs/dms-frontend@0.2.
|
|
4334
|
+
'@antelopejs/dms-frontend@0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))':
|
|
4348
4335
|
dependencies:
|
|
4349
4336
|
boxen: 5.1.2
|
|
4350
4337
|
chalk: 4.1.2
|
|
@@ -5944,13 +5931,6 @@ snapshots:
|
|
|
5944
5931
|
transitivePeerDependencies:
|
|
5945
5932
|
- typescript
|
|
5946
5933
|
|
|
5947
|
-
'@vueuse/core@13.9.0(vue@3.5.25(typescript@5.9.3))':
|
|
5948
|
-
dependencies:
|
|
5949
|
-
'@types/web-bluetooth': 0.0.21
|
|
5950
|
-
'@vueuse/metadata': 13.9.0
|
|
5951
|
-
'@vueuse/shared': 13.9.0(vue@3.5.25(typescript@5.9.3))
|
|
5952
|
-
vue: 3.5.25(typescript@5.9.3)
|
|
5953
|
-
|
|
5954
5934
|
'@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3))':
|
|
5955
5935
|
dependencies:
|
|
5956
5936
|
'@types/web-bluetooth': 0.0.21
|
|
@@ -5989,8 +5969,6 @@ snapshots:
|
|
|
5989
5969
|
|
|
5990
5970
|
'@vueuse/metadata@12.8.2': {}
|
|
5991
5971
|
|
|
5992
|
-
'@vueuse/metadata@13.9.0': {}
|
|
5993
|
-
|
|
5994
5972
|
'@vueuse/metadata@14.1.0': {}
|
|
5995
5973
|
|
|
5996
5974
|
'@vueuse/metadata@14.4.0': {}
|
|
@@ -6008,10 +5986,6 @@ snapshots:
|
|
|
6008
5986
|
transitivePeerDependencies:
|
|
6009
5987
|
- typescript
|
|
6010
5988
|
|
|
6011
|
-
'@vueuse/shared@13.9.0(vue@3.5.25(typescript@5.9.3))':
|
|
6012
|
-
dependencies:
|
|
6013
|
-
vue: 3.5.25(typescript@5.9.3)
|
|
6014
|
-
|
|
6015
5989
|
'@vueuse/shared@14.1.0(vue@3.5.25(typescript@5.9.3))':
|
|
6016
5990
|
dependencies:
|
|
6017
5991
|
vue: 3.5.25(typescript@5.9.3)
|
|
@@ -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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The color-mode plugin as the server runs it: the `<html>` tag of the rendered
|
|
3
|
+
* document already carries an explicit preference, so the first paint is right
|
|
4
|
+
* without waiting for JavaScript, and `system` is left to the pre-paint script.
|
|
5
|
+
*/
|
|
6
|
+
import { useHead } from "@unhead/vue";
|
|
7
|
+
import { createHead, renderSSRHead } from "@unhead/vue/server";
|
|
8
|
+
import { createSSRApp, ref } from "vue";
|
|
9
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
interface CookieOptions {
|
|
12
|
+
default: () => unknown;
|
|
13
|
+
maxAge?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COLOR_MODE_COOKIE = "dms-color-mode";
|
|
17
|
+
const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365;
|
|
18
|
+
const requestCookies = new Map<string, unknown>();
|
|
19
|
+
const cookieReads = vi.fn();
|
|
20
|
+
|
|
21
|
+
vi.stubGlobal("defineDmsPlugin", (setup: unknown) => setup);
|
|
22
|
+
vi.stubGlobal("useHead", useHead);
|
|
23
|
+
vi.stubGlobal("useDmsCookie", (name: string, options: CookieOptions) => {
|
|
24
|
+
cookieReads(name, options);
|
|
25
|
+
return ref(
|
|
26
|
+
requestCookies.has(name) ? requestCookies.get(name) : options.default(),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const { default: colorModePlugin } = await import(
|
|
31
|
+
"../layers/dms-layout/app/plugins/color-mode"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
async function renderHead(preference?: string) {
|
|
35
|
+
if (preference !== undefined)
|
|
36
|
+
requestCookies.set(COLOR_MODE_COOKIE, preference);
|
|
37
|
+
const app = createSSRApp({ render: () => null });
|
|
38
|
+
const head = createHead();
|
|
39
|
+
app.use(head);
|
|
40
|
+
await app.runWithContext(() => colorModePlugin({} as never));
|
|
41
|
+
return renderSSRHead(head);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
requestCookies.clear();
|
|
46
|
+
cookieReads.mockClear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each(["light", "dark"])(
|
|
50
|
+
"renders the %s class on <html> for an explicit preference",
|
|
51
|
+
async (preference) => {
|
|
52
|
+
const { htmlAttrs } = await renderHead(preference);
|
|
53
|
+
|
|
54
|
+
expect(htmlAttrs).toContain(` class="${preference}"`);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
it.each([undefined, "system", "sepia"])(
|
|
59
|
+
"leaves the class to the browser for a %s preference",
|
|
60
|
+
async (preference) => {
|
|
61
|
+
const { htmlAttrs } = await renderHead(preference);
|
|
62
|
+
|
|
63
|
+
expect(htmlAttrs).not.toContain("class");
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it("reads the dms-color-mode cookie, kept a year and defaulting to system", async () => {
|
|
68
|
+
await renderHead();
|
|
69
|
+
|
|
70
|
+
expect(cookieReads).toHaveBeenCalledOnce();
|
|
71
|
+
const [name, options] = cookieReads.mock.calls[0] as [string, CookieOptions];
|
|
72
|
+
expect(name).toBe(COLOR_MODE_COOKIE);
|
|
73
|
+
expect(options.maxAge).toBe(ONE_YEAR_IN_SECONDS);
|
|
74
|
+
expect(options.default()).toBe("system");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ships the pre-paint script as a classic inline script in <head>", async () => {
|
|
78
|
+
// No src, type, defer or async: the browser runs it while it parses <head>,
|
|
79
|
+
// before the deferred entry module that installs Nuxt UI.
|
|
80
|
+
const { headTags, bodyTags, bodyTagsOpen } = await renderHead("dark");
|
|
81
|
+
|
|
82
|
+
expect(headTags).toMatch(/<script id="dms-color-mode">\(\(\) => \{/);
|
|
83
|
+
expect(bodyTags + bodyTagsOpen).toBe("");
|
|
84
|
+
});
|