@antelopejs/dms 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js.map +1 -1
- package/dist/implementations/dms/page.d.ts +1 -0
- package/dist/implementations/dms/page.js +8 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/implementations/dms/stale-session.d.ts +2 -0
- package/dist/implementations/dms/stale-session.js +16 -0
- package/dist/implementations/dms/stale-session.js.map +1 -0
- package/dist/implementations/dms-auth/index.d.ts +1 -0
- package/dist/implementations/dms-auth/index.js +19 -0
- package/dist/implementations/dms-auth/index.js.map +1 -1
- package/dist/routes/auth/client-ip.d.ts +7 -0
- package/dist/routes/auth/client-ip.js +42 -0
- package/dist/routes/auth/client-ip.js.map +1 -0
- package/dist/routes/auth/index.d.ts +1 -0
- package/dist/routes/auth/index.js +1 -0
- package/dist/routes/auth/index.js.map +1 -1
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +15 -4
- package/dist/routes/auth.js.map +1 -1
- package/dist/test/integration/auth/client-ip.test.d.ts +1 -0
- package/dist/test/integration/auth/client-ip.test.js +14 -0
- package/dist/test/integration/auth/client-ip.test.js.map +1 -0
- package/dist/test/integration/page-session.test.d.ts +1 -0
- package/dist/test/integration/page-session.test.js +52 -0
- package/dist/test/integration/page-session.test.js.map +1 -0
- package/dist/test/unit/client-ip.test.d.ts +1 -0
- package/dist/test/unit/client-ip.test.js +77 -0
- package/dist/test/unit/client-ip.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
- package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js +96 -0
- package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js.map +1 -0
- package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -2
- package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +27 -2
- package/frontend-vue/layers/dms-core/app/utils/menu.ts +26 -10
- package/frontend-vue/layers/dms-core/app/utils/routePath.ts +23 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +3 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +3 -0
- package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +4 -1
- package/frontend-vue/layers/dms-layout/app/app.config.ts +9 -1
- package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -5
- package/frontend-vue/layers/dms-layout/app/components/profile/ProfileLanguage.vue +3 -23
- package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +33 -0
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +2 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +31 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationBadges.ts +36 -0
- package/frontend-vue/layers/dms-ui/app/build/types/tree.ts +5 -10
- package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +8 -5
- package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
- package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
- package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
- package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
- package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +53 -4
- package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
- package/frontend-vue/tests/api-error-title.test.ts +24 -0
- package/frontend-vue/tests/auth-middleware-error.test.ts +58 -0
- package/frontend-vue/tests/button-disabled-theme.test.ts +21 -0
- package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
- package/frontend-vue/tests/form-actions.test.ts +27 -0
- package/frontend-vue/tests/form-submit-target.test.ts +28 -0
- package/frontend-vue/tests/menu-navigation.test.ts +39 -10
- package/frontend-vue/tests/relation-badges.test.ts +32 -0
- package/frontend-vue/tests/tree-without-source.test.ts +79 -0
- package/frontend-vue/tests/user-language.test.ts +58 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 ||
|
|
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")
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
137
|
-
// underlying link, and a `false` there would suppress the
|
|
138
|
-
//
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
+
const { locale } = useI18n();
|
|
3
3
|
const { uniqueLocales } = useUniqueLocales();
|
|
4
|
-
const {
|
|
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="
|
|
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
|
|
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
|
|
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.
|
|
33
|
-
//
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
rowColumnCounts.value.
|
|
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
|
-
|
|
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>;
|