@antelopejs/dms 0.10.0 → 0.11.0

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 (92) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/config.d.ts +0 -2
  3. package/dist/config.js +0 -4
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/frontend-modules.js +17 -6
  6. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  7. package/dist/implementations/dms/manifest.d.ts +6 -0
  8. package/dist/implementations/dms/manifest.js +10 -7
  9. package/dist/implementations/dms/manifest.js.map +1 -1
  10. package/dist/index.js +4 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/store/listing.js +1 -2
  13. package/dist/store/listing.js.map +1 -1
  14. package/dist/store/operations.js +1 -2
  15. package/dist/store/operations.js.map +1 -1
  16. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  17. package/dist/utils/ensure-default-tenant.js +13 -1
  18. package/dist/utils/ensure-default-tenant.js.map +1 -1
  19. package/dist/utils/export-jobs.js +2 -1
  20. package/dist/utils/export-jobs.js.map +1 -1
  21. package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
  22. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
  23. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
  24. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
  25. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
  26. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  28. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
  29. package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
  30. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
  31. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
  32. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
  33. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
  34. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
  35. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
  36. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
  37. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
  38. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
  39. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
  40. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
  41. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
  42. package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
  43. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
  44. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
  45. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
  46. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
  47. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
  48. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
  49. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
  50. package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
  51. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
  52. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
  53. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
  54. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
  55. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
  56. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
  57. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
  59. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
  60. package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
  61. package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
  62. package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
  63. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
  64. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
  65. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
  66. package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
  67. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
  68. package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
  69. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
  70. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
  71. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
  73. package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
  74. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
  75. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
  76. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
  77. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
  78. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
  79. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
  80. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
  81. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
  82. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
  83. package/frontend-vue/package.json +6 -2
  84. package/package.json +7 -7
  85. package/skills/dms-module/SKILL.md +1 -1
  86. package/skills/dms-pages/REFERENCE.md +7 -3
  87. package/dist/hooks/tenant-instances.d.ts +0 -1
  88. package/dist/hooks/tenant-instances.js +0 -13
  89. package/dist/hooks/tenant-instances.js.map +0 -1
  90. package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
  91. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
  92. package/frontend-vue/layers/dms-layout/app/utils/pageRecord.ts +0 -71
@@ -0,0 +1,162 @@
1
+ import { onScopeDispose, type Ref, watch } from "vue";
2
+ import type { RealtimeTopicHandler } from "#dms-core/app/composables/realtime/useUserRealtime";
3
+ import type { PageLayout } from "../../types/page";
4
+ import {
5
+ fillRouteParams,
6
+ isSamePageQuery,
7
+ isSameRouteParams,
8
+ isViewStateKey,
9
+ layoutUrlWithQuery,
10
+ pageQueryValues,
11
+ } from "../../utils/pageQuery";
12
+
13
+ // One frame: the events of one burst (a bulk delete) read the record once.
14
+ const BURST_WINDOW_MS = 16;
15
+
16
+ /** How the server read the record a page shows (`DefaultLayout({ record })`). */
17
+ type PageRecordStatus = "found" | "missing" | "error";
18
+
19
+ interface RecordLayoutOptions {
20
+ recordStatus?: PageRecordStatus;
21
+ headerActionsQuery?: Record<string, string>;
22
+ headerActionsParams?: Record<string, string>;
23
+ }
24
+
25
+ /** What the page's record deletion listens to. */
26
+ export interface PageRecordDeletionSource {
27
+ /** The page's layout as it was first served. */
28
+ pageLayout: () => PageLayout | null;
29
+ /** The page's realtime topics, once its session is subscribed to it. */
30
+ pageTopics: Ref<string[]>;
31
+ subscribe(topic: string, handler: RealtimeTopicHandler): () => void;
32
+ }
33
+
34
+ const optionsOf = (layout: PageLayout | null | undefined) =>
35
+ layout?.layout.options as RecordLayoutOptions | undefined;
36
+
37
+ /**
38
+ * Leaves a detail page whose record was deleted, in every tab and for every
39
+ * member on it. The layout of a page reading a record (`DefaultLayout({
40
+ * record })`) says how the server read it (`recordStatus`); on each event of
41
+ * one of the page's realtime topics, the page asks its layout again, and a
42
+ * record it had `found` that is now `missing` was deleted: a toast says so,
43
+ * and the page opens its list (`MenuOptions.parent`), or stays on its
44
+ * not-found blocks without one. A page reading no record never asks.
45
+ */
46
+ export function usePageRecordDeletion(source: PageRecordDeletionSource): void {
47
+ if (import.meta.env.SSR) return;
48
+ const route = useDmsRoute();
49
+ const siteLayout = useSiteLayout();
50
+ const toast = useToast();
51
+ const { t } = useI18n();
52
+
53
+ const pageRoute = () => siteLayout.findMatchingRoute(route.path);
54
+ const currentQuery = () =>
55
+ pageQueryValues(route.query as Record<string, unknown>);
56
+ /** The page URL the record is named by, leaving out the view's state. */
57
+ const recordKey = () =>
58
+ JSON.stringify([
59
+ route.path,
60
+ Object.entries(currentQuery()).filter(([name]) => !isViewStateKey(name)),
61
+ ]);
62
+
63
+ let known: { key: string; status: PageRecordStatus } | undefined;
64
+ let latestRead = 0;
65
+
66
+ async function readStatus(): Promise<PageRecordStatus | undefined> {
67
+ const match = pageRoute();
68
+ const layoutUrl = match?.metadata?.layoutUrl;
69
+ if (!layoutUrl) return undefined;
70
+ const { $authFetch } = useAuthFetch();
71
+ try {
72
+ const layout = await $authFetch<PageLayout>(
73
+ layoutUrlWithQuery(
74
+ fillRouteParams(layoutUrl, match.params ?? {}),
75
+ currentQuery(),
76
+ ),
77
+ );
78
+ return optionsOf(layout)?.recordStatus;
79
+ } catch {
80
+ // Unknown: the record is not taken for deleted on a failed read.
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ async function leave(): Promise<void> {
86
+ toast.add({
87
+ title: t("page.record_deleted.title"),
88
+ description: t("page.record_deleted.description"),
89
+ color: "warning",
90
+ icon: "i-ph-trash",
91
+ });
92
+ const match = pageRoute();
93
+ const parentSlug = match?.metadata?.parentSlug;
94
+ if (!parentSlug) return;
95
+ const path = fillRouteParams(parentSlug, match.params ?? {});
96
+ if (path.includes(":")) return;
97
+ const parent = siteLayout.findMatchingRoute(path)?.metadata;
98
+ if (!parent || parent.hasAccess === false) return;
99
+ await navigateDms(path, { replace: true });
100
+ }
101
+
102
+ async function check(): Promise<void> {
103
+ const key = recordKey();
104
+ const read = ++latestRead;
105
+ const status = await readStatus();
106
+ if (read !== latestRead || key !== recordKey() || !status) return;
107
+ const previous = known?.key === key ? known.status : undefined;
108
+ known = { key, status };
109
+ if (previous === "found" && status === "missing") await leave();
110
+ }
111
+
112
+ const declaresRecord = () =>
113
+ optionsOf(source.pageLayout())?.recordStatus !== undefined;
114
+
115
+ // What the page was served with stands for its URL when it was served for
116
+ // it; another record of the same page is read once to know where it stands.
117
+ function follow(): void {
118
+ if (known?.key === recordKey()) return;
119
+ latestRead++;
120
+ known = undefined;
121
+ const options = optionsOf(source.pageLayout());
122
+ if (!options?.recordStatus) return;
123
+ const served =
124
+ isSamePageQuery(options.headerActionsQuery ?? {}, currentQuery()) &&
125
+ isSameRouteParams(
126
+ options.headerActionsParams ?? {},
127
+ pageRoute()?.params ?? {},
128
+ );
129
+ if (served) known = { key: recordKey(), status: options.recordStatus };
130
+ else void check();
131
+ }
132
+
133
+ let timer: ReturnType<typeof setTimeout> | undefined;
134
+ // Presence snapshots ride the same topics: they carry no `type`.
135
+ const onEvent: RealtimeTopicHandler = (event) => {
136
+ if (!("type" in event) || !declaresRecord()) return;
137
+ clearTimeout(timer);
138
+ timer = setTimeout(() => void check(), BURST_WINDOW_MS);
139
+ };
140
+
141
+ let unsubscribes: Array<() => void> = [];
142
+ const unsubscribeAll = () => {
143
+ for (const unsubscribe of unsubscribes) unsubscribe();
144
+ unsubscribes = [];
145
+ };
146
+ watch(
147
+ source.pageTopics,
148
+ (topics) => {
149
+ unsubscribeAll();
150
+ unsubscribes = topics.map((topic) => source.subscribe(topic, onEvent));
151
+ },
152
+ { immediate: true },
153
+ );
154
+ watch([() => route.path, () => route.query, source.pageLayout], follow, {
155
+ immediate: true,
156
+ });
157
+ onScopeDispose(() => {
158
+ unsubscribeAll();
159
+ clearTimeout(timer);
160
+ latestRead++;
161
+ });
162
+ }
@@ -2,7 +2,6 @@
2
2
  import DashboardFrame from "../build/components/layout/DashboardFrame.vue";
3
3
  import DashboardPageBody from "../build/components/layout/DashboardPageBody.vue";
4
4
  import type { LayoutHeaderAction } from "../build/components/layout/PageHeaderActionBar.vue";
5
- import type { PageRecordSource } from "../composables/layout/usePageRecord";
6
5
 
7
6
  interface Props {
8
7
  fullWidth?: boolean;
@@ -14,14 +13,12 @@ interface Props {
14
13
  /** Header buttons declared by the backend page (`headerActions`). */
15
14
  headerActions?: LayoutHeaderAction[];
16
15
  /**
17
- * The page query the server resolved the buttons' availability for, set
18
- * when one declares an `availability` (`headerActionsQuery`).
16
+ * The page query the server served the buttons for, set when one declares
17
+ * an `availability` or the page reads a `record` (`headerActionsQuery`).
19
18
  */
20
19
  headerActionsQuery?: Record<string, string>;
21
20
  /** The route parameters (`:id`) they were resolved for, likewise. */
22
21
  headerActionsParams?: Record<string, string>;
23
- /** The record the page shows, which header buttons may act on (`record`). */
24
- record?: PageRecordSource;
25
22
  }
26
23
 
27
24
  const props = withDefaults(defineProps<Props>(), {
@@ -34,7 +31,6 @@ const props = withDefaults(defineProps<Props>(), {
34
31
  headerActions: () => [],
35
32
  headerActionsQuery: undefined,
36
33
  headerActionsParams: undefined,
37
- record: undefined,
38
34
  });
39
35
  </script>
40
36
 
@@ -48,7 +44,6 @@ const props = withDefaults(defineProps<Props>(), {
48
44
  :header-actions="props.headerActions"
49
45
  :header-actions-query="props.headerActionsQuery"
50
46
  :header-actions-params="props.headerActionsParams"
51
- :record="props.record"
52
47
  >
53
48
  <slot />
54
49
  </DashboardPageBody>
@@ -2,7 +2,6 @@
2
2
  import DashboardFrame from "../build/components/layout/DashboardFrame.vue";
3
3
  import DashboardPageBody from "../build/components/layout/DashboardPageBody.vue";
4
4
  import type { LayoutHeaderAction } from "../build/components/layout/PageHeaderActionBar.vue";
5
- import type { PageRecordSource } from "../composables/layout/usePageRecord";
6
5
  import SettingsShell from "../build/components/pages/settings/shell/SettingsShell.vue";
7
6
 
8
7
  interface Props {
@@ -14,14 +13,12 @@ interface Props {
14
13
  /** Header buttons declared by the backend page (`headerActions`). */
15
14
  headerActions?: LayoutHeaderAction[];
16
15
  /**
17
- * The page query the server resolved the buttons' availability for, set
18
- * when one declares an `availability` (`headerActionsQuery`).
16
+ * The page query the server served the buttons for, set when one declares
17
+ * an `availability` or the page reads a `record` (`headerActionsQuery`).
19
18
  */
20
19
  headerActionsQuery?: Record<string, string>;
21
20
  /** The route parameters (`:id`) they were resolved for, likewise. */
22
21
  headerActionsParams?: Record<string, string>;
23
- /** The record the page shows, which header buttons may act on (`record`). */
24
- record?: PageRecordSource;
25
22
  }
26
23
 
27
24
  const props = withDefaults(defineProps<Props>(), {
@@ -33,7 +30,6 @@ const props = withDefaults(defineProps<Props>(), {
33
30
  headerActions: () => [],
34
31
  headerActionsQuery: undefined,
35
32
  headerActionsParams: undefined,
36
- record: undefined,
37
33
  });
38
34
  </script>
39
35
 
@@ -48,7 +44,6 @@ const props = withDefaults(defineProps<Props>(), {
48
44
  :header-actions="props.headerActions"
49
45
  :header-actions-query="props.headerActionsQuery"
50
46
  :header-actions-params="props.headerActionsParams"
51
- :record="props.record"
52
47
  header-class="pb-5"
53
48
  >
54
49
  <slot />
@@ -4,6 +4,7 @@ import { holdPageHeaderActions } from "../composables/layout/usePageHeaderAction
4
4
  import { useDevReloading } from "../build/composables/dev-reload/useDevReloadHolder";
5
5
  import { usePageLiveHost } from "#dms-ui/app/build/composables/live/usePageLiveHost";
6
6
  import { usePageDataRefresh } from "#dms-ui/app/build/composables/live/usePageDataRefresh";
7
+ import { usePageRecordDeletion } from "../composables/page/usePageRecordDeletion";
7
8
  import { pageValidationRedirect } from "../build/utils/pageValidationRedirect";
8
9
  import { resolveComponentChildren } from "#dms-ui/app/utils/componentTree";
9
10
 
@@ -251,6 +252,12 @@ usePageLiveHost(
251
252
  );
252
253
  // The page's `useApiData` reads follow its refreshes and its realtime events.
253
254
  usePageDataRefresh(realtime);
255
+ // A detail page leaves for its list once its record is deleted.
256
+ usePageRecordDeletion({
257
+ pageLayout: () => pageLayout.value,
258
+ pageTopics: realtime.pageTopics,
259
+ subscribe: realtime.subscribe,
260
+ });
254
261
 
255
262
  function buildPageSetupContext(
256
263
  pageInfo: PageInfo,
@@ -13,14 +13,14 @@ function isHiddenFromNavigation(node: SiteLayoutTree): boolean {
13
13
  function collectNavigationItems(
14
14
  node: SiteLayoutTree,
15
15
  ancestors: string[],
16
- translate: I18nTranslate,
16
+ translation: AppTranslation,
17
17
  items: CommandPaletteItem[],
18
18
  ): void {
19
19
  for (const childId of node.childrenOrders) {
20
20
  const child = node.children[childId];
21
21
  if (!child || isHiddenFromNavigation(child)) continue;
22
22
 
23
- const label = resolveI18nKey(translate, child.displayName);
23
+ const label = translation.processI18n(child.displayName);
24
24
  const to = buildMenuItemTarget(child);
25
25
  // The account entries (Settings, Profile) are the session group's.
26
26
  if (to && child.fullSlug && !isAccountMenuRoute(to)) {
@@ -33,40 +33,40 @@ function collectNavigationItems(
33
33
  icon: child.icon ?? DEFAULT_PAGE_ICON,
34
34
  to,
35
35
  [COMMAND_PALETTE_SEARCH_TEXT_KEY]: resolveOptionalI18nKey(
36
- translate,
36
+ translation.t,
37
37
  child.description,
38
38
  ),
39
39
  });
40
40
  }
41
- collectNavigationItems(child, [...ancestors, label], translate, items);
41
+ collectNavigationItems(child, [...ancestors, label], translation, items);
42
42
  }
43
43
  }
44
44
 
45
45
  function buildNavigationGroups(
46
46
  tree: SiteLayoutTree | undefined,
47
- translate: I18nTranslate,
47
+ translation: AppTranslation,
48
48
  ): CommandPaletteGroup[] {
49
49
  if (!tree) return [];
50
50
 
51
51
  const items: CommandPaletteItem[] = [];
52
- collectNavigationItems(tree, [], translate, items);
52
+ collectNavigationItems(tree, [], translation, items);
53
53
  if (items.length === 0) return [];
54
54
 
55
55
  return [
56
56
  {
57
57
  id: NAVIGATION_GROUP_ID,
58
- label: translate("commandPalette.groups.navigation", {}),
58
+ label: translation.t("commandPalette.groups.navigation"),
59
59
  items,
60
60
  },
61
61
  ];
62
62
  }
63
63
 
64
64
  export default defineDmsPlugin(() => {
65
- const { translate } = getPluginI18n();
65
+ const translation = getAppTranslation();
66
66
  const { siteLayoutTree } = useSiteLayout();
67
67
 
68
68
  const groups = computed<CommandPaletteGroup[]>(() =>
69
- buildNavigationGroups(siteLayoutTree.value, translate),
69
+ buildNavigationGroups(siteLayoutTree.value, translation),
70
70
  );
71
71
 
72
72
  registerCommandPaletteSource({
@@ -11,15 +11,15 @@ const RECENT_MODULES_GROUP_ID = "recent-modules";
11
11
 
12
12
  function buildFavoritesGroup(
13
13
  favorites: FavoritePage[],
14
- translate: I18nTranslate,
14
+ translation: AppTranslation,
15
15
  ): CommandPaletteGroup | null {
16
16
  if (favorites.length === 0) return null;
17
17
 
18
18
  return {
19
19
  id: FAVORITES_GROUP_ID,
20
- label: translate("menu.favorites", {}),
20
+ label: translation.t("menu.favorites"),
21
21
  items: favorites.map((favorite) => ({
22
- label: resolveI18nKey(translate, favorite.title),
22
+ label: translation.processI18n(favorite.title),
23
23
  icon: favorite.icon ?? DEFAULT_PAGE_ICON,
24
24
  to: favorite.path,
25
25
  })),
@@ -28,15 +28,15 @@ function buildFavoritesGroup(
28
28
 
29
29
  function buildRecentModulesGroup(
30
30
  modules: RecentModule[],
31
- translate: I18nTranslate,
31
+ translation: AppTranslation,
32
32
  ): CommandPaletteGroup | null {
33
33
  if (modules.length === 0) return null;
34
34
 
35
35
  return {
36
36
  id: RECENT_MODULES_GROUP_ID,
37
- label: translate("commandPalette.groups.recentModules", {}),
37
+ label: translation.t("commandPalette.groups.recentModules"),
38
38
  items: modules.map((module) => ({
39
- label: resolveI18nKey(translate, module.title),
39
+ label: translation.processI18n(module.title),
40
40
  icon: module.icon,
41
41
  to: module.to,
42
42
  })),
@@ -44,17 +44,17 @@ function buildRecentModulesGroup(
44
44
  }
45
45
 
46
46
  export default defineDmsPlugin(() => {
47
- const { translate } = getPluginI18n();
47
+ const translation = getAppTranslation();
48
48
  const { sortedFavorites } = useFavoritePages();
49
49
  const { entries } = useModuleHistory();
50
50
  const { modules, siteLayoutTree } = useSiteLayout();
51
51
 
52
52
  const groups = computed<CommandPaletteGroup[]>(() =>
53
53
  [
54
- buildFavoritesGroup(sortedFavorites.value, translate),
54
+ buildFavoritesGroup(sortedFavorites.value, translation),
55
55
  buildRecentModulesGroup(
56
56
  recentModules(entries.value, modules.value, siteLayoutTree.value),
57
- translate,
57
+ translation,
58
58
  ),
59
59
  ].filter((group): group is CommandPaletteGroup => group !== null),
60
60
  );
@@ -6,10 +6,10 @@ const QUICK_ACTIONS_GROUP_PREFIX = "quick-actions";
6
6
 
7
7
  function buildActionItem(
8
8
  action: QuickActionInfo,
9
- translate: I18nTranslate,
9
+ translation: AppTranslation,
10
10
  ): CommandPaletteItem {
11
11
  return {
12
- label: resolveI18nKey(translate, action.displayName),
12
+ label: translation.processI18n(action.displayName),
13
13
  icon: action.icon,
14
14
  onSelect: () => {
15
15
  void dispatchQuickActionTarget(action.target);
@@ -19,21 +19,21 @@ function buildActionItem(
19
19
 
20
20
  function buildQuickActionGroups(
21
21
  categoryGroups: QuickActionCategoryGroup[],
22
- translate: I18nTranslate,
22
+ translation: AppTranslation,
23
23
  ): CommandPaletteGroup[] {
24
24
  return categoryGroups.map((group) => ({
25
25
  id: `${QUICK_ACTIONS_GROUP_PREFIX}-${group.category.id}`,
26
- label: resolveI18nKey(translate, group.category.displayName),
27
- items: group.actions.map((action) => buildActionItem(action, translate)),
26
+ label: translation.processI18n(group.category.displayName),
27
+ items: group.actions.map((action) => buildActionItem(action, translation)),
28
28
  }));
29
29
  }
30
30
 
31
31
  export default defineDmsPlugin(() => {
32
- const { translate } = getPluginI18n();
32
+ const translation = getAppTranslation();
33
33
  const { categoryGroups } = useQuickActions();
34
34
 
35
35
  const groups = computed<CommandPaletteGroup[]>(() =>
36
- buildQuickActionGroups(categoryGroups.value, translate),
36
+ buildQuickActionGroups(categoryGroups.value, translation),
37
37
  );
38
38
 
39
39
  registerCommandPaletteSource({
@@ -22,16 +22,17 @@ function uniqueByCode<T extends LocaleOption>(locales: T[]): T[] {
22
22
  });
23
23
  }
24
24
 
25
- function buildAccountItems(translate: I18nTranslate): CommandPaletteItem[] {
25
+ function buildAccountItems(t: AppTranslation["t"]): CommandPaletteItem[] {
26
26
  return ACCOUNT_MENU_ENTRIES.map((entry) => ({
27
- label: translate(entry.labelKey, {}),
27
+ label: t(entry.labelKey),
28
28
  icon: entry.icon,
29
29
  to: entry.to,
30
30
  }));
31
31
  }
32
32
 
33
33
  export default defineDmsPlugin(() => {
34
- const { i18n, translate } = getPluginI18n();
34
+ const { $i18n: i18n } = useDmsApp();
35
+ const { t } = getAppTranslation();
35
36
  const toast = useToast();
36
37
  const { logout } = useLogout();
37
38
  const { changeLanguage } = useUserLanguage();
@@ -49,10 +50,10 @@ export default defineDmsPlugin(() => {
49
50
  async function copyCurrentUrl(): Promise<void> {
50
51
  try {
51
52
  await copy(window.location.href);
52
- toast.add({ title: translate("commandPalette.session.urlCopied", {}) });
53
+ toast.add({ title: t("commandPalette.session.urlCopied") });
53
54
  } catch {
54
55
  toast.add({
55
- title: translate("commandPalette.session.copyFailed", {}),
56
+ title: t("commandPalette.session.copyFailed"),
56
57
  color: "error",
57
58
  });
58
59
  }
@@ -61,23 +62,23 @@ export default defineDmsPlugin(() => {
61
62
  function buildSessionGroup(): CommandPaletteGroup {
62
63
  return {
63
64
  id: SESSION_GROUP_ID,
64
- label: translate("commandPalette.groups.session", {}),
65
+ label: t("commandPalette.groups.session"),
65
66
  items: [
66
- ...buildAccountItems(translate),
67
+ ...buildAccountItems(t),
67
68
  {
68
- label: translate("commandPalette.session.changeLanguage", {}),
69
+ label: t("commandPalette.session.changeLanguage"),
69
70
  icon: LANGUAGE_ICON,
70
71
  children: localeItems(),
71
72
  },
72
73
  {
73
- label: translate("commandPalette.session.copyUrl", {}),
74
+ label: t("commandPalette.session.copyUrl"),
74
75
  icon: COPY_URL_ICON,
75
76
  onSelect: () => {
76
77
  void copyCurrentUrl();
77
78
  },
78
79
  },
79
80
  {
80
- label: translate("button.logout", {}),
81
+ label: t("button.logout"),
81
82
  icon: LOGOUT_ICON,
82
83
  onSelect: () => {
83
84
  void logout();
@@ -330,14 +330,15 @@ const tabsTheme = {
330
330
  trigger: "data-[state=active]:font-semibold",
331
331
  // Counts as mono chips, tinted with the accent on the active tab.
332
332
  trailingBadge:
333
- "font-mono text-[10.5px] h-4 px-[5px] rounded-[4px] ring-0 bg-elevated text-dimmed group-data-[state=active]:bg-(--dms-accent-tint) group-data-[state=active]:text-primary",
333
+ "font-mono text-[10.5px] h-4 px-[5px] rounded-(--dms-radius-tab-badge) ring-0 bg-elevated text-dimmed group-data-[state=active]:bg-(--dms-accent-tint) group-data-[state=active]:text-primary",
334
334
  },
335
335
  variants: {
336
336
  variant: {
337
337
  pill: {
338
- list: "bg-(--dms-bg-muted) ring ring-inset ring-default p-[3px] gap-0.5 rounded-[10px]",
339
- indicator: "ring ring-accented shadow-(--shadow-sm) rounded-[7px]",
340
- trigger: "rounded-[7px] before:rounded-[7px]",
338
+ list: "bg-(--dms-bg-muted) ring ring-inset ring-default p-[3px] gap-0.5 rounded-(--dms-radius-tab-list)",
339
+ indicator:
340
+ "ring ring-accented shadow-(--shadow-sm) rounded-(--dms-radius-tab)",
341
+ trigger: "rounded-(--dms-radius-tab) before:rounded-(--dms-radius-tab)",
341
342
  },
342
343
  },
343
344
  // A vertical list sits at the top of its panel, not centred beside it.
@@ -100,6 +100,10 @@
100
100
  "no_content_title": "No content available",
101
101
  "no_content_message": "This page has not been configured yet. Please contact the administrator."
102
102
  },
103
+ "record_deleted": {
104
+ "title": "Deleted",
105
+ "description": "What this page showed was deleted. It is no longer available."
106
+ },
103
107
  "no_visible_blocks": {
104
108
  "title": "Nothing to show for you here",
105
109
  "description": "Your role doesn’t let you see any part of this page. Ask a workspace owner for access."
@@ -826,7 +830,7 @@
826
830
  },
827
831
  "disabled": {
828
832
  "title": "The store is read-only here",
829
- "description": "Modules are installed from development, which writes them to antelope.config.ts; this platform loads what that file declares. Set store.enabled to true in the DMS configuration to change them from here."
833
+ "description": "Modules are installed from development, which writes them to antelope.config.ts; this platform loads what that file declares. Change them from development."
830
834
  },
831
835
  "load_error": "The module store could not be loaded",
832
836
  "search_placeholder": "Search the store",
@@ -1040,9 +1044,7 @@
1040
1044
  "footer": {
1041
1045
  "documentation": "Documentation",
1042
1046
  "website": "Website",
1043
- "language": "Language",
1044
- "github_star": "Star",
1045
- "github_star_label": "Star AntelopeJS DMS on GitHub"
1047
+ "language": "Language"
1046
1048
  }
1047
1049
  },
1048
1050
  "error": {
@@ -100,6 +100,10 @@
100
100
  "no_content_title": "Aucun contenu disponible",
101
101
  "no_content_message": "Cette page n'a pas encore été configurée. Veuillez contacter l'administrateur."
102
102
  },
103
+ "record_deleted": {
104
+ "title": "Supprimé",
105
+ "description": "Ce que montrait cette page a été supprimé. Il n’est plus disponible."
106
+ },
103
107
  "no_visible_blocks": {
104
108
  "title": "Rien à afficher pour vous ici",
105
109
  "description": "Votre rôle ne vous permet de voir aucune partie de cette page. Demandez l’accès à un propriétaire de l’espace."
@@ -830,7 +834,7 @@
830
834
  },
831
835
  "disabled": {
832
836
  "title": "Le store est en lecture seule ici",
833
- "description": "Les modules s’installent depuis le développement, qui les écrit dans antelope.config.ts ; cette plateforme charge ce que déclare ce fichier. Passez store.enabled à true dans la configuration du DMS pour les modifier d’ici."
837
+ "description": "Les modules s’installent depuis le développement, qui les écrit dans antelope.config.ts ; cette plateforme charge ce que déclare ce fichier. Modifiez-les depuis le développement."
834
838
  },
835
839
  "load_error": "Le store de modules n’a pas pu être chargé",
836
840
  "search_placeholder": "Rechercher dans le store",
@@ -1044,9 +1048,7 @@
1044
1048
  "footer": {
1045
1049
  "documentation": "Documentation",
1046
1050
  "website": "Site web",
1047
- "language": "Langue",
1048
- "github_star": "Star",
1049
- "github_star_label": "Mettre une étoile à AntelopeJS DMS sur GitHub"
1051
+ "language": "Langue"
1050
1052
  }
1051
1053
  },
1052
1054
  "error": {
@@ -27,8 +27,12 @@ const { t } = useI18n();
27
27
  const VARIANTS: Record<FormLoadFailure, EmptyStateVariant> = {
28
28
  forbidden: "no-access",
29
29
  missing: "no-data",
30
+ not_found: "no-result",
30
31
  error: "error",
31
32
  };
33
+ // A record the address names none of, or one that does not exist, is not
34
+ // read again by trying.
35
+ const RETRIES = new Set<FormLoadFailure>(["forbidden", "error"]);
32
36
  </script>
33
37
 
34
38
  <template>
@@ -38,7 +42,7 @@ const VARIANTS: Record<FormLoadFailure, EmptyStateVariant> = {
38
42
  :title="t(`dms.form.load_error.${props.failure}_title`)"
39
43
  :description="t(`dms.form.load_error.${props.failure}_description`)"
40
44
  >
41
- <template v-if="props.failure !== 'missing'" #actions>
45
+ <template v-if="RETRIES.has(props.failure)" #actions>
42
46
  <UButton
43
47
  :label="t('dms.form.load_error.retry')"
44
48
  icon="i-ph-arrows-clockwise"
@@ -207,7 +207,7 @@ const { labelsOf: relationLabels } = useRelationFilterLabels(() =>
207
207
  const displayValue = (filter: TableFilter): string => {
208
208
  const source = relationSource(filter);
209
209
  const names = source ? relationLabels(source) : [];
210
- if (names.length) return names.join(", ");
210
+ if (names.length) return names.map((name) => name.label).join(", ");
211
211
  const labels = itemLabels(filter);
212
212
  if (labels) return labels;
213
213
  const formatted = formatFilterValue(
@@ -219,12 +219,22 @@ const displayValue = (filter: TableFilter): string => {
219
219
  return formatted || EMPTY_FILTER_VALUE;
220
220
  };
221
221
 
222
+ // The ids of a relation filter its route did not answer, named on hover.
223
+ const notFoundIds = (filter: TableFilter): string => {
224
+ const source = relationSource(filter);
225
+ if (!source) return "";
226
+ return relationLabels(source)
227
+ .flatMap((name) => (name.notFoundId ? [name.notFoundId] : []))
228
+ .join(", ");
229
+ };
230
+
222
231
  // The whole filter in words, for a chip shrunk to an ellipsis.
223
232
  const chipTitle = (filter: TableFilter, label?: string): string =>
224
233
  [
225
234
  label ?? getColumn(filter.accessorKey)?.label,
226
235
  t(`dms.table.compare_mode.${filter.mode}`),
227
236
  displayValue(filter),
237
+ notFoundIds(filter),
228
238
  ]
229
239
  .filter(Boolean)
230
240
  .join(" · ");
@@ -2,6 +2,7 @@ import {
2
2
  readTranslationKey,
3
3
  type I18nTranslate,
4
4
  } from "#dms-core/app/composables/translation/useTranslation";
5
+ import { describeFieldErrors } from "#dms-core/app/composables/useFieldErrors";
5
6
 
6
7
  /** Shown when the request never reached the server (offline, DNS, CORS…). */
7
8
  export const ACTION_ERROR_NETWORK_KEY = "error.network.description";
@@ -90,11 +91,14 @@ const translateKey = (translate: I18nTranslate, key: string) =>
90
91
  * threw: the server's own message when it is user-facing (an i18n key, or
91
92
  * plain text on a 4xx), else a translated fallback: the request never got
92
93
  * through, it was refused for lack of permission, or something went wrong.
93
- * Never a stack trace, raw JSON or an ofetch error line.
94
+ * Never a stack trace, raw JSON or an ofetch error line. A body the data API
95
+ * refused for some fields names them by `labelOf` ("Loss reason is
96
+ * required."): a table view's write passes its column labels.
94
97
  */
95
98
  export function resolveActionError(
96
99
  error: unknown,
97
100
  translate: I18nTranslate,
101
+ labelOf: (field: string) => string | undefined = () => undefined,
98
102
  ): string {
99
103
  if (isFetchError(error)) {
100
104
  const status = statusOf(error);
@@ -121,6 +125,7 @@ export function resolveActionError(
121
125
  );
122
126
  }
123
127
  return (
128
+ describeFieldErrors(error, translate, labelOf) ??
124
129
  userFacingText(raw, translate) ??
125
130
  translateKey(translate, ACTION_ERROR_GENERIC_KEY)
126
131
  );