@antelopejs/dms 0.4.4 → 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 (55) hide show
  1. package/CHANGELOG.md +16 -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/table-view-tab-count-warning.test.d.ts +1 -0
  32. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js +96 -0
  33. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js.map +1 -0
  34. package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -2
  35. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +27 -2
  36. package/frontend-vue/layers/dms-core/app/utils/menu.ts +26 -10
  37. package/frontend-vue/layers/dms-core/app/utils/routePath.ts +23 -0
  38. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +3 -0
  39. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +3 -0
  40. package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +4 -1
  41. package/frontend-vue/layers/dms-layout/app/app.config.ts +9 -1
  42. package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -5
  43. package/frontend-vue/layers/dms-layout/app/components/profile/ProfileLanguage.vue +3 -23
  44. package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +33 -0
  45. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +2 -1
  46. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +31 -3
  47. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationBadges.ts +36 -0
  48. package/frontend-vue/layers/dms-ui/app/build/types/tree.ts +5 -10
  49. package/frontend-vue/tests/api-error-title.test.ts +24 -0
  50. package/frontend-vue/tests/auth-middleware-error.test.ts +58 -0
  51. package/frontend-vue/tests/button-disabled-theme.test.ts +21 -0
  52. package/frontend-vue/tests/menu-navigation.test.ts +39 -10
  53. package/frontend-vue/tests/relation-badges.test.ts +32 -0
  54. package/frontend-vue/tests/user-language.test.ts +58 -0
  55. package/package.json +1 -1
@@ -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,
@@ -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
+ });
@@ -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,58 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { useUserLanguage } from "../layers/dms-layout/app/composables/profile/useUserLanguage";
3
+
4
+ interface LanguageHarness {
5
+ setLocale: ReturnType<typeof vi.fn>;
6
+ authFetch: ReturnType<typeof vi.fn>;
7
+ refreshUser: ReturnType<typeof vi.fn>;
8
+ }
9
+
10
+ function stubComposables(authFetch = vi.fn(async () => ({}))): LanguageHarness {
11
+ const harness: LanguageHarness = {
12
+ setLocale: vi.fn(async () => {}),
13
+ authFetch,
14
+ refreshUser: vi.fn(async () => {}),
15
+ };
16
+ vi.stubGlobal("useDmsApp", () => ({
17
+ $i18n: { setLocale: harness.setLocale },
18
+ }));
19
+ vi.stubGlobal("useAuthFetch", () => ({ $authFetch: harness.authFetch }));
20
+ vi.stubGlobal("useUserSession", () => ({
21
+ user: { value: { name: "Ada", email: "ada@example.com", language: "en" } },
22
+ fetch: harness.refreshUser,
23
+ }));
24
+ return harness;
25
+ }
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ describe("useUserLanguage", () => {
32
+ it("applies the language and stores it on the user's profile", async () => {
33
+ const harness = stubComposables();
34
+
35
+ await useUserLanguage().changeLanguage("fr");
36
+
37
+ expect(harness.setLocale).toHaveBeenCalledWith("fr");
38
+ expect(harness.authFetch).toHaveBeenCalledWith("/settings/user/profile", {
39
+ method: "POST",
40
+ body: { name: "Ada", email: "ada@example.com", language: "fr" },
41
+ });
42
+ expect(harness.refreshUser).toHaveBeenCalled();
43
+ });
44
+
45
+ it("keeps the applied language when the profile update fails", async () => {
46
+ const harness = stubComposables(
47
+ vi.fn(async () => {
48
+ throw new Error("offline");
49
+ }),
50
+ );
51
+
52
+ await expect(useUserLanguage().changeLanguage("fr")).resolves.toBe(
53
+ undefined,
54
+ );
55
+ expect(harness.setLocale).toHaveBeenCalledWith("fr");
56
+ expect(harness.refreshUser).not.toHaveBeenCalled();
57
+ });
58
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"