@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/implementations/dms/page.d.ts +1 -0
  5. package/dist/implementations/dms/page.js +8 -1
  6. package/dist/implementations/dms/page.js.map +1 -1
  7. package/dist/implementations/dms/stale-session.d.ts +2 -0
  8. package/dist/implementations/dms/stale-session.js +16 -0
  9. package/dist/implementations/dms/stale-session.js.map +1 -0
  10. package/dist/implementations/dms-auth/index.d.ts +1 -0
  11. package/dist/implementations/dms-auth/index.js +19 -0
  12. package/dist/implementations/dms-auth/index.js.map +1 -1
  13. package/dist/routes/auth/client-ip.d.ts +7 -0
  14. package/dist/routes/auth/client-ip.js +42 -0
  15. package/dist/routes/auth/client-ip.js.map +1 -0
  16. package/dist/routes/auth/index.d.ts +1 -0
  17. package/dist/routes/auth/index.js +1 -0
  18. package/dist/routes/auth/index.js.map +1 -1
  19. package/dist/routes/auth.d.ts +3 -0
  20. package/dist/routes/auth.js +15 -4
  21. package/dist/routes/auth.js.map +1 -1
  22. package/dist/test/integration/auth/client-ip.test.d.ts +1 -0
  23. package/dist/test/integration/auth/client-ip.test.js +14 -0
  24. package/dist/test/integration/auth/client-ip.test.js.map +1 -0
  25. package/dist/test/integration/page-session.test.d.ts +1 -0
  26. package/dist/test/integration/page-session.test.js +52 -0
  27. package/dist/test/integration/page-session.test.js.map +1 -0
  28. package/dist/test/unit/client-ip.test.d.ts +1 -0
  29. package/dist/test/unit/client-ip.test.js +77 -0
  30. package/dist/test/unit/client-ip.test.js.map +1 -0
  31. package/dist/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
  32. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
  33. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
  34. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
  35. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
  36. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
  37. package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
  38. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
  39. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
  40. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
  41. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
  42. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
  43. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.d.ts +1 -0
  44. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js +96 -0
  45. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js.map +1 -0
  46. package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -2
  47. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +27 -2
  48. package/frontend-vue/layers/dms-core/app/utils/menu.ts +26 -10
  49. package/frontend-vue/layers/dms-core/app/utils/routePath.ts +23 -0
  50. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +3 -0
  51. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +3 -0
  52. package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +4 -1
  53. package/frontend-vue/layers/dms-layout/app/app.config.ts +9 -1
  54. package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -5
  55. package/frontend-vue/layers/dms-layout/app/components/profile/ProfileLanguage.vue +3 -23
  56. package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +33 -0
  57. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +2 -1
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +31 -3
  59. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationBadges.ts +36 -0
  60. package/frontend-vue/layers/dms-ui/app/build/types/tree.ts +5 -10
  61. package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
  62. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
  63. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +8 -5
  64. package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
  65. package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
  66. package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
  67. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
  68. package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +53 -4
  72. package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
  73. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  74. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  75. package/frontend-vue/tests/api-error-title.test.ts +24 -0
  76. package/frontend-vue/tests/auth-middleware-error.test.ts +58 -0
  77. package/frontend-vue/tests/button-disabled-theme.test.ts +21 -0
  78. package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
  79. package/frontend-vue/tests/form-actions.test.ts +27 -0
  80. package/frontend-vue/tests/form-submit-target.test.ts +28 -0
  81. package/frontend-vue/tests/menu-navigation.test.ts +39 -10
  82. package/frontend-vue/tests/relation-badges.test.ts +32 -0
  83. package/frontend-vue/tests/tree-without-source.test.ts +79 -0
  84. package/frontend-vue/tests/user-language.test.ts +58 -0
  85. 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 { formatValueParts } from "../../composables/chart/formatValue";
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 ?? 0);
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
- formatValueParts(
78
- value.value,
79
- props.valueFormat,
80
- locale.value,
81
- props.currencyCode,
82
- props.valuePrecision,
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
- value: number;
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
- value: number;
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 path/query target when the entry declares a query", () => {
32
+ it("returns a string link carrying the query the entry declares", () => {
33
33
  expect(
34
34
  buildMenuItemTarget(node({ query: { project: "invoicer" } })),
35
- ).to.deep.equal({ path: "/project", query: { project: "invoicer" } });
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: { path: "/project", query: { project: "invoicer" } },
54
+ to: "/project?project=invoicer",
49
55
  };
50
56
  const resto = {
51
57
  fullId: "cloud.projects.resto",
52
- to: { path: "/project", query: { project: "resto" } },
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
- id: "invoicer",
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(undefined);
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
+ });