@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
@@ -6,12 +6,16 @@ interface Options {
6
6
 
7
7
  interface ApiError {
8
8
  statusCode?: number;
9
+ status?: number;
9
10
  message?: string;
10
11
  data?: unknown;
11
12
  }
12
13
 
13
- const DEFAULT_TITLE_KEY = "error.500.title";
14
+ const SERVER_ERROR_TITLE_KEY = "error.500.title";
15
+ const CLIENT_ERROR_TITLE_KEY = "error.request_refused.title";
14
16
  const FALLBACK_ERROR_KEY = "error.500.description";
17
+ const CLIENT_ERROR_STATUS_MIN = 400;
18
+ const CLIENT_ERROR_STATUS_MAX = 499;
15
19
 
16
20
  function isApiError(error: unknown): error is ApiError {
17
21
  return typeof error === "object" && error !== null;
@@ -32,6 +36,27 @@ function resolveDescription(error: ApiError): string {
32
36
  return FALLBACK_ERROR_KEY;
33
37
  }
34
38
 
39
+ function isClientErrorStatus(status: number | undefined): boolean {
40
+ return (
41
+ status !== undefined &&
42
+ status >= CLIENT_ERROR_STATUS_MIN &&
43
+ status <= CLIENT_ERROR_STATUS_MAX
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The i18n title key matching a fetch error's HTTP status: a 4xx is a request
49
+ * the backend refused, anything else (5xx, network failure) a server error.
50
+ */
51
+ export function resolveApiErrorTitle(error: unknown): string {
52
+ const status = isApiError(error)
53
+ ? (error.statusCode ?? error.status)
54
+ : undefined;
55
+ return isClientErrorStatus(status)
56
+ ? CLIENT_ERROR_TITLE_KEY
57
+ : SERVER_ERROR_TITLE_KEY;
58
+ }
59
+
35
60
  /**
36
61
  * Extract the backend's error body from a fetch error — an i18n message key
37
62
  * per the API error contract — falling back to the generic 500 key.
@@ -46,7 +71,7 @@ export function useApiError(error: unknown, options?: Options) {
46
71
  const { t } = dmsApp.$i18n;
47
72
  const translate = (message: string) => resolveApiMessage(t, message);
48
73
 
49
- const title = options?.title || DEFAULT_TITLE_KEY;
74
+ const title = options?.title || resolveApiErrorTitle(error);
50
75
  const description = options?.description || resolveApiErrorMessage(error);
51
76
 
52
77
  toast.add({
@@ -1,4 +1,5 @@
1
1
  import type { NavigationMenuItem } from "@nuxt/ui";
2
+ import { parseLinkQuery, stripQueryAndHash } from "./routePath";
2
3
 
3
4
  // Menu items carry the registered page/category's dot-separated `fullId`
4
5
  // (e.g. `pages.erp.orders`) plus the DMS semantic fields; Nuxt UI's
@@ -88,7 +89,9 @@ const matchesRoutePrefix = (currentPath: string, to: string): boolean => {
88
89
  const resolveItemTarget = (
89
90
  to: NavigationMenuItem["to"],
90
91
  ): MenuItemTarget | null => {
91
- if (typeof to === "string") return { path: to };
92
+ if (typeof to === "string") {
93
+ return { path: stripQueryAndHash(to), query: parseLinkQuery(to) };
94
+ }
92
95
  const target = to as MenuItemTarget | undefined;
93
96
  if (target && typeof target.path === "string") {
94
97
  return { path: target.path, query: target.query };
@@ -96,9 +99,11 @@ const resolveItemTarget = (
96
99
  return null;
97
100
  };
98
101
 
99
- // Subset match, mirroring the `exactQuery: "partial"` the entries are built
100
- // with: the entry's own parameters must be the current ones, while unrelated
101
- // parameters the page adds (tabs, filters) are ignored.
102
+ const hasQuery = (target: MenuItemTarget | null): boolean =>
103
+ Object.keys(target?.query ?? {}).length > 0;
104
+
105
+ // Subset match: the entry's own parameters must be the current ones, while
106
+ // unrelated parameters the page adds (tabs, filters) are ignored.
102
107
  const queryMatches = (
103
108
  declared: MenuItemTarget["query"],
104
109
  currentQuery: Record<string, unknown>,
@@ -133,18 +138,29 @@ export const isMenuItemActive = (
133
138
  return matchedFullId !== null && fullId === matchedFullId;
134
139
  };
135
140
 
136
- // Only the boolean `true` is set: Nuxt UI forwards an explicit `active` to the
137
- // underlying link, and a `false` there would suppress the built-in route match.
138
- // Leaving it undefined preserves that native highlighting for every other item.
141
+ // Only the boolean `true` is set on plain entries: Nuxt UI forwards an explicit
142
+ // `active` to the underlying link, and a `false` there would suppress the
143
+ // built-in route match. Entries carrying a query always get an explicit value,
144
+ // because that built-in match is a URL prefix test that would light up
145
+ // `?project=a` on `?project=ab`.
146
+ const resolveActiveState = (
147
+ item: NavigationMenuItem,
148
+ matchedFullId: string | null,
149
+ currentRoute: MenuRouteState,
150
+ ): Partial<Pick<NavigationMenuItem, "active">> => {
151
+ if (isMenuItemActive(item, matchedFullId, currentRoute)) {
152
+ return { active: true };
153
+ }
154
+ return hasQuery(resolveItemTarget(item.to)) ? { active: false } : {};
155
+ };
156
+
139
157
  export const addActiveStateToMenuItem = (
140
158
  item: NavigationMenuItem,
141
159
  matchedFullId: string | null,
142
160
  currentRoute: MenuRouteState,
143
161
  ): NavigationMenuItem => ({
144
162
  ...item,
145
- ...(isMenuItemActive(item, matchedFullId, currentRoute)
146
- ? { active: true }
147
- : {}),
163
+ ...resolveActiveState(item, matchedFullId, currentRoute),
148
164
  children: item.children?.map((child) =>
149
165
  addActiveStateToMenuItem(child, matchedFullId, currentRoute),
150
166
  ),
@@ -9,3 +9,26 @@ export function stripQueryAndHash(link: string): string {
9
9
  const [path] = link.split(QUERY_OR_HASH);
10
10
  return path ?? link;
11
11
  }
12
+
13
+ /**
14
+ * A link to `path` narrowed by `query`, as the single string every link
15
+ * renderer accepts: the Inertia-backed `ULink` only takes a string `to`, and
16
+ * stringifies an object into `[object Object]`.
17
+ */
18
+ export function buildLinkWithQuery(
19
+ path: string,
20
+ query: Record<string, string> | undefined,
21
+ ): string {
22
+ const search = new URLSearchParams(query ?? {}).toString();
23
+ return search === "" ? path : `${path}?${search}`;
24
+ }
25
+
26
+ /** The query parameters a link carries, the inverse of `buildLinkWithQuery`. */
27
+ export function parseLinkQuery(link: string): Record<string, string> {
28
+ const [withoutHash = ""] = link.split("#");
29
+ const queryStart = withoutHash.indexOf("?");
30
+ if (queryStart === -1) return {};
31
+ return Object.fromEntries(
32
+ new URLSearchParams(withoutHash.slice(queryStart + 1)),
33
+ );
34
+ }
@@ -37,6 +37,9 @@
37
37
  "title": "Server error",
38
38
  "description": "An unexpected error occurred. Please try again later."
39
39
  },
40
+ "request_refused": {
41
+ "title": "Request refused"
42
+ },
40
43
  "recovering": {
41
44
  "title": "Restoring your session...",
42
45
  "description": "Your session has expired. Refreshing your access and reloading the page."
@@ -37,6 +37,9 @@
37
37
  "title": "Erreur serveur",
38
38
  "description": "Une erreur inattendue s'est produite. Veuillez réessayer plus tard."
39
39
  },
40
+ "request_refused": {
41
+ "title": "Requête refusée"
42
+ },
40
43
  "recovering": {
41
44
  "title": "Restauration de votre session...",
42
45
  "description": "Votre session a expiré. Renouvellement de l'accès et rechargement de la page."
@@ -43,7 +43,10 @@ interface DmsPrivateConfig {
43
43
  relaySecret: string;
44
44
  /**
45
45
  * Whether a trusted reverse proxy sits immediately in front and appends
46
- * to `x-forwarded-for`; drives the OAuth rate-limit client key.
46
+ * to `x-forwarded-for`.
47
+ *
48
+ * @deprecated Mirrors the deprecated `auth.oauth.trustProxy`. The frontend
49
+ * server keys its OAuth rate limit on its own `DMS_TRUSTED_PROXY_HOPS`.
47
50
  */
48
51
  trustProxy?: boolean;
49
52
  };
@@ -134,7 +134,15 @@ export default {
134
134
  button: {
135
135
  slots: {
136
136
  // Tactile press + full transition (design .btn active:scale(.97)).
137
- base: "transition active:scale-[0.98]",
137
+ // Nuxt UI only fades a disabled button to 75%, which still reads as
138
+ // enabled on a filled primary: fade it further and drop the press and
139
+ // the inset highlight so every variant looks inert.
140
+ base: [
141
+ "transition active:scale-[0.98]",
142
+ "disabled:opacity-50 aria-disabled:opacity-50",
143
+ "disabled:active:scale-100 aria-disabled:active:scale-100",
144
+ "disabled:shadow-none aria-disabled:shadow-none",
145
+ ].join(" "),
138
146
  },
139
147
  compoundVariants: [
140
148
  // Inset top highlight on the solid primary fill (design .btn-primary).
@@ -82,11 +82,6 @@ function onLinkHover(): void {
82
82
  const { to } = props.item;
83
83
  if (typeof to === "string") {
84
84
  prefetchPageLayout(stripQueryAndHash(to));
85
- return;
86
- }
87
- const path = (to as MenuItemTarget | undefined)?.path;
88
- if (path) {
89
- prefetchPageLayout(path);
90
85
  }
91
86
  }
92
87
  </script>
@@ -1,8 +1,7 @@
1
1
  <script setup lang="ts">
2
- const { locale, setLocale } = useI18n();
2
+ const { locale } = useI18n();
3
3
  const { uniqueLocales } = useUniqueLocales();
4
- const { $authFetch } = useAuthFetch();
5
- const { user, fetch: refreshUser } = useUserSession();
4
+ const { changeLanguage } = useUserLanguage();
6
5
 
7
6
  const languageOptions = computed(() =>
8
7
  uniqueLocales.value.map((lang) => ({
@@ -10,25 +9,6 @@ const languageOptions = computed(() =>
10
9
  value: lang.code,
11
10
  })),
12
11
  );
13
-
14
- const handleLanguageChange = async (newLanguage: "en" | "fr") => {
15
- setLocale(newLanguage);
16
-
17
- try {
18
- await $authFetch("/settings/user/profile", {
19
- method: "POST",
20
- body: {
21
- name: user.value?.name,
22
- email: user.value?.email,
23
- language: newLanguage,
24
- },
25
- });
26
-
27
- await refreshUser();
28
- } catch {
29
- /* ignore — silently skip the update on network / backend errors */
30
- }
31
- };
32
12
  </script>
33
13
 
34
14
  <template>
@@ -62,7 +42,7 @@ const handleLanguageChange = async (newLanguage: "en" | "fr") => {
62
42
  class="w-full"
63
43
  :items="languageOptions"
64
44
  :model-value="locale"
65
- @update:model-value="handleLanguageChange"
45
+ @update:model-value="changeLanguage"
66
46
  />
67
47
  </div>
68
48
  </section>
@@ -0,0 +1,33 @@
1
+ const PROFILE_ENDPOINT = "/settings/user/profile";
2
+
3
+ /**
4
+ * Switches the interface language and stores it on the user's profile, so the
5
+ * `language-sync` plugin re-applies it on the next load instead of reverting
6
+ * to the previously stored language. Every language switcher goes through it.
7
+ */
8
+ export function useUserLanguage() {
9
+ const dmsApp = useDmsApp();
10
+ const { $authFetch } = useAuthFetch();
11
+ const { user, fetch: refreshUser } = useUserSession();
12
+
13
+ async function changeLanguage(language: string): Promise<void> {
14
+ await dmsApp.$i18n.setLocale(language);
15
+
16
+ try {
17
+ await $authFetch(PROFILE_ENDPOINT, {
18
+ method: "POST",
19
+ body: {
20
+ name: user.value?.name,
21
+ email: user.value?.email,
22
+ language,
23
+ },
24
+ });
25
+
26
+ await refreshUser();
27
+ } catch {
28
+ /* ignore — silently skip the update on network / backend errors */
29
+ }
30
+ }
31
+
32
+ return { changeLanguage };
33
+ }
@@ -34,6 +34,7 @@ export default defineDmsPlugin(() => {
34
34
  const { i18n, translate } = getPluginI18n();
35
35
  const toast = useToast();
36
36
  const { logout } = useLogout();
37
+ const { changeLanguage } = useUserLanguage();
37
38
  const { copy } = useClipboard({ legacy: true });
38
39
 
39
40
  const localeItems = (): CommandPaletteItem[] =>
@@ -41,7 +42,7 @@ export default defineDmsPlugin(() => {
41
42
  label: locale.name ?? locale.code,
42
43
  active: i18n.locale.value === locale.code,
43
44
  onSelect: () => {
44
- void i18n.setLocale(locale.code);
45
+ void changeLanguage(locale.code);
45
46
  },
46
47
  }));
47
48
 
@@ -1,8 +1,10 @@
1
1
  import { defineAsyncComponent, type VNode } from "vue";
2
2
  import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
3
3
  import UAvatar from "@nuxt/ui/components/Avatar.vue";
4
+ import UBadge from "@nuxt/ui/components/Badge.vue";
4
5
  import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
5
6
  import ULink from "@nuxt/ui/components/Link.vue";
7
+ import { buildRelationBadges } from "./relationBadges";
6
8
 
7
9
  const FilePreview = defineAsyncComponent(
8
10
  () => import("../../../components/table-view/FilePreview.vue"),
@@ -37,6 +39,9 @@ const LINK_CLASS =
37
39
  const LINK_ICON_CLASS = "size-3 text-primary/70 flex-shrink-0";
38
40
  const LINK_ICON_NAME = "i-ph-arrow-up-right-light";
39
41
  const DEFAULT_RELATION_VALUE_KEY = "_id";
42
+ const DEFAULT_RELATION_LABEL_KEY = "name";
43
+ const RELATION_BADGES_CLASS = "inline-flex flex-wrap items-center gap-1";
44
+ const RELATION_BADGE_CLASS = "max-w-40";
40
45
  const PERMISSION_SEPARATOR = ".";
41
46
 
42
47
  interface LinkRendererOptions {
@@ -263,18 +268,41 @@ function countLeafPermissions(value: string[]): number {
263
268
  return value.filter((id) => !parentIds.has(id)).length;
264
269
  }
265
270
 
271
+ function renderRelationBadge(label: string, title?: string) {
272
+ return h(UBadge, {
273
+ label,
274
+ title: title ?? label,
275
+ color: "neutral",
276
+ variant: "subtle",
277
+ size: Size.small,
278
+ class: RELATION_BADGE_CLASS,
279
+ });
280
+ }
281
+
282
+ function renderRelationBadges(values: unknown[], labelKey: string) {
283
+ const { processI18n } = useTranslation();
284
+ const { visible, hidden } = buildRelationBadges(values, labelKey);
285
+ const badges = visible.map((label) =>
286
+ renderRelationBadge(processI18n(label)),
287
+ );
288
+ if (hidden.length > 0) {
289
+ const hiddenLabels = hidden.map((label) => processI18n(label)).join(", ");
290
+ badges.push(renderRelationBadge(`+${hidden.length}`, hiddenLabels));
291
+ }
292
+ return h("span", { class: RELATION_BADGES_CLASS }, badges);
293
+ }
294
+
266
295
  function renderRelation(value: unknown, options: unknown) {
267
- const { t } = useI18n();
268
296
  const opts = options as RelationOptions | undefined;
297
+ const labelKey = opts?.keyMapping?.label || DEFAULT_RELATION_LABEL_KEY;
269
298
 
270
299
  if (opts?.multiple && Array.isArray(value)) {
271
- return t("dms.form.relation.selected_count", { count: value.length });
300
+ return renderRelationBadges(value, labelKey);
272
301
  }
273
302
 
274
303
  if (!value || !isObject(value)) return value;
275
304
 
276
305
  const valueObject = value as Record<string, string>;
277
- const labelKey = opts?.keyMapping?.label || "name";
278
306
  const avatarKey = opts?.keyMapping?.avatar;
279
307
  const label = valueObject[labelKey] || String(value);
280
308
 
@@ -0,0 +1,36 @@
1
+ /** How many labels a multi-value relation cell shows before collapsing. */
2
+ export const MAX_RELATION_BADGES = 3;
3
+
4
+ /** The labels a multi-value cell shows, and how many it folds into `+N`. */
5
+ export interface RelationBadges {
6
+ visible: string[];
7
+ hidden: string[];
8
+ }
9
+
10
+ function readRelationLabel(item: unknown, labelKey: string): string {
11
+ if (typeof item === "object" && item !== null) {
12
+ const label = (item as Record<string, unknown>)[labelKey];
13
+ return label === undefined || label === null ? "" : String(label);
14
+ }
15
+ return item === undefined || item === null ? "" : String(item);
16
+ }
17
+
18
+ /**
19
+ * Split a multi-value relation into the labels rendered as badges and the ones
20
+ * folded into a trailing `+N`. List rows carry the related rows resolved by the
21
+ * relation join, so each item is read through the `label` key mapping; a raw id
22
+ * (join skipped) is shown as is rather than dropped.
23
+ */
24
+ export function buildRelationBadges(
25
+ values: unknown[],
26
+ labelKey: string,
27
+ maxVisible = MAX_RELATION_BADGES,
28
+ ): RelationBadges {
29
+ const labels = values
30
+ .map((item) => readRelationLabel(item, labelKey))
31
+ .filter((label) => label !== "");
32
+ return {
33
+ visible: labels.slice(0, maxVisible),
34
+ hidden: labels.slice(maxVisible),
35
+ };
36
+ }
@@ -1,4 +1,5 @@
1
1
  import type { LinkProps, NavigationMenuItem } from "@nuxt/ui";
2
+ import { buildLinkWithQuery } from "#dms-core/app/utils/routePath";
2
3
 
3
4
  export interface TreeItem extends LinkProps {
4
5
  id: string;
@@ -29,18 +30,13 @@ function isExcludedFromRoot(item: SiteLayoutTree): boolean {
29
30
 
30
31
  // A registered entry links to its own slug, narrowed by the query parameters it
31
32
  // declares so a single page (with `validation.requiredQueryParams`) can back
32
- // several entries. `exactQuery: "partial"` keeps sibling entries of that page
33
- // from all highlighting at once while tolerating unrelated query parameters.
34
- export function buildMenuItemTarget(
35
- item: SiteLayoutTree,
36
- ): MenuItemTarget | string | undefined {
33
+ // several entries. The sidebar tells those sibling entries apart itself (see
34
+ // `isMenuItemActive`), so the link stays a plain string.
35
+ export function buildMenuItemTarget(item: SiteLayoutTree): string | undefined {
37
36
  if (!item.layoutUrl) {
38
37
  return undefined;
39
38
  }
40
- if (!item.query || Object.keys(item.query).length === 0) {
41
- return item.fullSlug;
42
- }
43
- return { path: item.fullSlug, query: item.query };
39
+ return buildLinkWithQuery(item.fullSlug, item.query);
44
40
  }
45
41
 
46
42
  function buildNavigationItem(
@@ -54,7 +50,6 @@ function buildNavigationItem(
54
50
  label: item.displayName,
55
51
  icon: item.icon,
56
52
  to,
57
- ...(typeof to === "object" ? { exactQuery: "partial" } : {}),
58
53
  variant: item.variant,
59
54
  status: item.status,
60
55
  children: children.length > 0 ? children : undefined,
@@ -5,19 +5,31 @@ interface PlaceholderProps {
5
5
  width?: string;
6
6
  }
7
7
 
8
+ /** What a box nobody sized is worth showing at, when nothing stretches it. */
9
+ const MIN_HEIGHT = "120px";
10
+
8
11
  const props = withDefaults(defineProps<PlaceholderProps>(), {
9
12
  label: undefined,
10
- height: "120px",
13
+ height: undefined,
11
14
  width: undefined,
12
15
  });
16
+
17
+ /**
18
+ * A placeholder stands in for content still to come, so it takes the room it is
19
+ * given: a height of its own would keep it short in a row sized by its tallest
20
+ * cell, and a layout laid out with these could not be read for what it will be.
21
+ * A given height still wins, and a floor keeps a lone one visible.
22
+ */
23
+ const box = computed(() => ({
24
+ height: props.height,
25
+ minHeight: props.height ? undefined : MIN_HEIGHT,
26
+ width: props.width,
27
+ }));
13
28
  </script>
14
29
 
15
30
  <template>
16
31
  <div
17
- :style="{
18
- height: props.height,
19
- width: props.width,
20
- }"
32
+ :style="box"
21
33
  class="border-accented relative flex items-center justify-center overflow-hidden rounded-md border border-dashed px-4 opacity-75"
22
34
  >
23
35
  <svg
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, provide, reactive, watchEffect } from "vue";
3
3
  import { useChartFetch } from "../../composables/chart/useChartFetch";
4
- import { formatValue } from "../../composables/chart/formatValue";
4
+ import {
5
+ ABSENT_VALUE_TEXT,
6
+ formatValue,
7
+ } from "../../composables/chart/formatValue";
5
8
  import { resolveChartColor } from "../../composables/chart/useChartTheme";
6
9
  import { useThemeRevision } from "../../composables/chart/useThemeRevision";
7
10
  import {
@@ -64,7 +67,7 @@ const { data, isLoading } = useChartFetch<ChartCardResponse>({
64
67
  watchSource: () => watchKey.value,
65
68
  });
66
69
 
67
- const value = computed(() => data.value?.value ?? 0);
70
+ const value = computed(() => data.value?.value);
68
71
  const previousValue = computed(() => data.value?.previousValue);
69
72
  const delta = computed(() => data.value?.delta ?? null);
70
73
 
@@ -87,13 +90,15 @@ const cardComparisonSeries = computed<ChartSeries[]>(() =>
87
90
  );
88
91
 
89
92
  const formatted = computed(() =>
90
- formatValue(
91
- value.value,
92
- props.valueFormat,
93
- locale.value,
94
- props.currencyCode,
95
- props.valuePrecision,
96
- ),
93
+ value.value === undefined
94
+ ? ABSENT_VALUE_TEXT
95
+ : formatValue(
96
+ value.value,
97
+ props.valueFormat,
98
+ locale.value,
99
+ props.currencyCode,
100
+ props.valuePrecision,
101
+ ),
97
102
  );
98
103
 
99
104
  const previousFormatted = computed(() =>
@@ -3,6 +3,7 @@ import type { FormProps } from "../../composables/form/types";
3
3
  import type { FormFieldValue } from "../../composables/form/types/value";
4
4
  import {
5
5
  cloneFormValue,
6
+ formShowsActions,
6
7
  isFieldMarkedRequired,
7
8
  } from "../../composables/form/useForm";
8
9
  import {
@@ -20,7 +21,12 @@ const REALTIME_ROW_TOPIC_PREFIX = "tableview:row:";
20
21
  const REALTIME_GET_SEGMENT = "/get";
21
22
  const REALTIME_EVENT_UPDATED = "updated";
22
23
 
23
- const props = defineProps<FormProps>();
24
+ // `showActions` left out has to read as left out: Vue casts an absent boolean
25
+ // prop to `false`, which would hide the buttons of every form not asking for
26
+ // them.
27
+ const props = withDefaults(defineProps<FormProps>(), {
28
+ showActions: undefined,
29
+ });
24
30
  const form = useTemplateRef("form");
25
31
 
26
32
  // Inside a DynamicModal/DynamicDrawer the container already provides the
@@ -47,10 +53,7 @@ const {
47
53
  resolvedFetchUrl,
48
54
  } = useForm(props);
49
55
 
50
- const showActions = computed(
51
- () =>
52
- !!props.submitUrl && !toValue(allFields).every((field) => field.disabled),
53
- );
56
+ const showActions = computed(() => formShowsActions(props, toValue(allFields)));
54
57
 
55
58
  const { addGuard } = useLeaveGuard();
56
59
  const { confirm } = useConfirm();
@@ -13,19 +13,29 @@ const props = withDefaults(defineProps<GridProps>(), {
13
13
  minColumnWidth: GRID_DEFAULT_MIN_COLUMN_WIDTH,
14
14
  });
15
15
 
16
- const rowColumnCounts = ref<number[]>([]);
16
+ /**
17
+ * Keyed by row rather than appended to: a list that only grows keeps counting
18
+ * rows that have gone and widths that were never asked for, so the grid ends up
19
+ * reserving columns nothing fills.
20
+ */
21
+ const rowColumnCounts = ref(new Map<symbol, number>());
17
22
 
18
23
  const maxColumns = computed(() => {
19
- if (rowColumnCounts.value.length === 0) return 1;
20
- return Math.max(...rowColumnCounts.value);
24
+ const counts = [...rowColumnCounts.value.values()].filter(
25
+ (count) => count > 0,
26
+ );
27
+ return counts.length === 0 ? 1 : Math.max(...counts);
21
28
  });
22
29
 
23
30
  const gapRef = computed(() => props.gap);
24
31
  const minColumnWidthRef = computed(() => props.minColumnWidth);
25
32
 
26
33
  provide<GridContext>(GRID_CONTEXT, {
27
- registerRowColumnCount: (columnCount: number) => {
28
- rowColumnCounts.value.push(columnCount);
34
+ setRowColumnCount: (row: symbol, columnCount: number) => {
35
+ rowColumnCounts.value.set(row, columnCount);
36
+ },
37
+ dropRow: (row: symbol) => {
38
+ rowColumnCounts.value.delete(row);
29
39
  },
30
40
  maxColumns,
31
41
  gap: gapRef,
@@ -11,10 +11,13 @@ const props = defineProps<GridRowProps>();
11
11
 
12
12
  const gridContext = inject<GridContext>(GRID_CONTEXT);
13
13
 
14
- const columnCount = props.childCount || 0;
15
- if (gridContext) {
16
- gridContext.registerRowColumnCount(columnCount);
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
- registerRowColumnCount: (count: number) => void;
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>;