@antelopejs/dms 0.10.0 → 0.11.1

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 (97) hide show
  1. package/CHANGELOG.md +113 -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/implementations/dms/page.js +45 -16
  11. package/dist/implementations/dms/page.js.map +1 -1
  12. package/dist/index.js +4 -7
  13. package/dist/index.js.map +1 -1
  14. package/dist/store/listing.js +1 -2
  15. package/dist/store/listing.js.map +1 -1
  16. package/dist/store/operations.js +1 -2
  17. package/dist/store/operations.js.map +1 -1
  18. package/dist/utils/ensure-default-tenant.d.ts +2 -1
  19. package/dist/utils/ensure-default-tenant.js +13 -1
  20. package/dist/utils/ensure-default-tenant.js.map +1 -1
  21. package/dist/utils/export-jobs.js +2 -1
  22. package/dist/utils/export-jobs.js.map +1 -1
  23. package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
  24. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
  25. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
  26. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
  27. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
  28. package/frontend-vue/layers/dms-core/app/utils/menu.ts +66 -70
  29. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  30. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  31. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
  32. package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
  33. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
  34. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
  35. package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
  36. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
  37. package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
  38. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
  39. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +3 -11
  40. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
  41. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
  42. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
  43. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
  44. package/frontend-vue/layers/dms-layout/app/components/navigation/NavigationMenu.vue +18 -3
  45. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
  46. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
  47. package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
  48. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
  49. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
  50. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
  51. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
  52. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
  53. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
  54. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
  55. package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
  56. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
  57. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
  58. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
  59. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
  60. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
  61. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
  62. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
  63. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
  64. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
  65. package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
  66. package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
  67. package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
  68. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
  69. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
  70. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
  71. package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
  72. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
  73. package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
  74. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
  75. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
  76. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
  77. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
  78. package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
  79. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
  80. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
  81. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
  82. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
  83. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
  84. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
  85. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
  86. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
  87. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
  88. package/frontend-vue/package.json +6 -2
  89. package/package.json +8 -11
  90. package/skills/dms-module/SKILL.md +1 -1
  91. package/skills/dms-pages/REFERENCE.md +7 -3
  92. package/dist/hooks/tenant-instances.d.ts +0 -1
  93. package/dist/hooks/tenant-instances.js +0 -13
  94. package/dist/hooks/tenant-instances.js.map +0 -1
  95. package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
  96. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
  97. package/frontend-vue/layers/dms-layout/app/utils/pageRecord.ts +0 -71
@@ -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
  );
@@ -1,8 +1,5 @@
1
1
  import type { DataTypeFormatter } from "#dms-core/app/composables/data-types/useDataType";
2
- import {
3
- getPluginI18n,
4
- resolveI18nKey,
5
- } from "../../../../../dms-core/app/composables/translation/useTranslation";
2
+ import { getAppTranslation } from "../../../../../dms-core/app/composables/translation/useTranslation";
6
3
 
7
4
  /** A formatter writing a number for a locale, with Intl's options. */
8
5
  type NumberFormatter = (
@@ -41,7 +38,7 @@ export function withNumberSuffix(format: NumberFormatter): DataTypeFormatter {
41
38
  if (!suffix || typeof value !== "number" || typeof formatted !== "string") {
42
39
  return formatted;
43
40
  }
44
- const unit = resolveI18nKey(getPluginI18n().translate, suffix);
41
+ const unit = getAppTranslation().processI18n(suffix);
45
42
  return `${formatted}${UNIT_SEPARATOR}${unit}`;
46
43
  };
47
44
  }
@@ -7,7 +7,7 @@ import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
7
7
  import ULink from "@nuxt/ui/components/Link.vue";
8
8
  import { buildRelationBadges } from "./relationBadges";
9
9
  import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
10
- import { relationSelectSource } from "./relationRows";
10
+ import { NotFoundRelation, relationSelectSource } from "./relationRows";
11
11
  import {
12
12
  isSet,
13
13
  readRowField,
@@ -338,12 +338,31 @@ function renderRelationBadge(label: string, title?: string) {
338
338
  });
339
339
  }
340
340
 
341
+ // An id its `select` route did not answer: "Not found", the id on hover.
342
+ function renderNotFoundRelation(relation: NotFoundRelation) {
343
+ const { t } = getAppTranslation();
344
+ return h(
345
+ "span",
346
+ { class: "text-muted", title: relation.id },
347
+ t("dms.form.relation.not_found"),
348
+ );
349
+ }
350
+
341
351
  function renderRelationBadges(values: unknown[], labelKey: string) {
342
- const { processI18n } = getAppTranslation();
343
- const { visible, hidden } = buildRelationBadges(values, labelKey);
344
- const badges = visible.map((label) =>
345
- renderRelationBadge(processI18n(label)),
352
+ const { processI18n, t } = getAppTranslation();
353
+ const notFound = values.filter(
354
+ (item): item is NotFoundRelation => item instanceof NotFoundRelation,
355
+ );
356
+ const { visible, hidden } = buildRelationBadges(
357
+ values.filter((item) => !(item instanceof NotFoundRelation)),
358
+ labelKey,
346
359
  );
360
+ const badges = [
361
+ ...visible.map((label) => renderRelationBadge(processI18n(label))),
362
+ ...notFound.map((relation) =>
363
+ renderRelationBadge(t("dms.form.relation.not_found"), relation.id),
364
+ ),
365
+ ];
347
366
  if (hidden.length > 0) {
348
367
  const hiddenLabels = hidden.map((label) => processI18n(label)).join(", ");
349
368
  badges.push(renderRelationBadge(`+${hidden.length}`, hiddenLabels));
@@ -359,6 +378,7 @@ function renderRelation(value: unknown, options: unknown) {
359
378
  return renderRelationBadges(value, labelKey);
360
379
  }
361
380
 
381
+ if (value instanceof NotFoundRelation) return renderNotFoundRelation(value);
362
382
  if (!value || !isObject(value)) return value;
363
383
 
364
384
  const valueObject = value as Record<string, string>;
@@ -981,6 +1001,7 @@ const registerCascaderRelationType = (
981
1001
  },
982
1002
  beforeStateMapper: mapRelationBeforeState,
983
1003
  isSameValue: sameRelationValue,
1004
+ selectSource: relationSelectSource,
984
1005
  });
985
1006
  };
986
1007
 
@@ -45,12 +45,33 @@ export function relationRowLabel(
45
45
  return row[source.keyMapping?.label || RELATION_LABEL_KEY];
46
46
  }
47
47
 
48
+ /**
49
+ * An id a relation's `select` route did not answer: a deleted row, or one out
50
+ * of the reader's reach — it cannot tell which, so it is shown as "Not
51
+ * found", never as "Deleted", with the id itself in its tooltip.
52
+ */
53
+ export class NotFoundRelation {
54
+ constructor(readonly id: string) {}
55
+ }
56
+
57
+ /** The ids of a read that its rows do not answer. */
58
+ export function notFoundRelationIds(
59
+ source: DataTypeSelectSource,
60
+ ids: string[],
61
+ rows: RelationRow[],
62
+ ): string[] {
63
+ const answered = new Set(
64
+ rows.map((row) => String(relationRowValue(source, row))),
65
+ );
66
+ return ids.filter((id) => !answered.has(id));
67
+ }
68
+
48
69
  /**
49
70
  * Reads the rows of a relation's ids from its `select` route, narrowed with
50
71
  * `?ids=` to those rows alone (matched on the mapped value key), whatever the
51
72
  * route's page size. The one way the picker, the filter chips and the
52
73
  * read-only lists name the ids they hold. A failed read answers no rows: the
53
- * ids then show as they are.
74
+ * ids then show as not found, as a row out of the reader's reach does.
54
75
  */
55
76
  export function useRelationRows() {
56
77
  const { $authFetch } = useAuthFetch();
@@ -1,15 +1,19 @@
1
1
  import { type Ref, ref, watch } from "vue";
2
2
  import { apiErrorStatus } from "#dms-core/app/composables/useFieldErrors";
3
- import { HTTP_FORBIDDEN } from "#dms-core/app/utils/http-status";
3
+ import {
4
+ HTTP_FORBIDDEN,
5
+ HTTP_NOT_FOUND,
6
+ } from "#dms-core/app/utils/http-status";
4
7
 
5
8
  /**
6
9
  * Why a form could not load the record it edits: `forbidden` when the server
7
10
  * refused the caller the read (a role that may edit rows but not view them),
8
11
  * `missing` when its address lacks the record's id (a page reached without
9
- * its `?id=`), `error` for anything else (a network failure, a server error,
10
- * a row gone).
12
+ * its `?id=`), `not_found` when the server answered 404 (the record was
13
+ * deleted, or never existed), `error` for anything else (a network failure,
14
+ * a server error).
11
15
  */
12
- export type FormLoadFailure = "forbidden" | "missing" | "error";
16
+ export type FormLoadFailure = "forbidden" | "missing" | "not_found" | "error";
13
17
 
14
18
  /** What a form knows of the record it is about, as far as `missing` goes. */
15
19
  export interface FormRecordAddress {
@@ -51,7 +55,9 @@ export interface FormRecordLoad {
51
55
  /** The failure a load error stands for; `null` when there is none. */
52
56
  export function formLoadFailure(error: unknown): FormLoadFailure | null {
53
57
  if (!error) return null;
54
- return apiErrorStatus(error) === HTTP_FORBIDDEN ? "forbidden" : "error";
58
+ const status = apiErrorStatus(error);
59
+ if (status === HTTP_FORBIDDEN) return "forbidden";
60
+ return status === HTTP_NOT_FOUND ? "not_found" : "error";
55
61
  }
56
62
 
57
63
  /** How a form takes the answers of its record's later reads. */
@@ -22,6 +22,11 @@ export interface FormValidationField {
22
22
  type?: string;
23
23
  /** Holds one value per content language. */
24
24
  localized?: boolean;
25
+ /**
26
+ * The language a localized field falls back to: required as soon as the
27
+ * field holds any language, and where its required error goes.
28
+ */
29
+ fallbackLocale?: string;
25
30
  /** A box to tick: unticked, it is missing. */
26
31
  acceptance?: boolean;
27
32
  /**
@@ -63,6 +68,32 @@ function issueField(issue: { path: PropertyKey[] }): PropertyKey | undefined {
63
68
  return issue.path[0];
64
69
  }
65
70
 
71
+ /** Whether a localized value fills some language but not its fallback one. */
72
+ function lacksFallbackLocale(
73
+ field: FormValidationField,
74
+ value: unknown,
75
+ ): boolean {
76
+ if (!field.localized || !field.fallbackLocale) return false;
77
+ if (typeof value !== "object" || value === null) return false;
78
+ const languages = value as Record<string, unknown>;
79
+ const isAnyFilled = Object.values(languages).some(
80
+ (text) => !isBlankValue(text, field.type),
81
+ );
82
+ return (
83
+ isAnyFilled && isBlankValue(languages[field.fallbackLocale], field.type)
84
+ );
85
+ }
86
+
87
+ /** Where a localized field's required error goes: the language to fill. */
88
+ function requiredPath(
89
+ field: FormValidationField,
90
+ locale: string | undefined,
91
+ ): PropertyKey[] {
92
+ if (!field.localized) return [field.id];
93
+ const language = field.fallbackLocale ?? locale;
94
+ return language ? [field.id, language] : [field.id];
95
+ }
96
+
66
97
  /** An issue saying the value is not of a branch's type at all. */
67
98
  function isTypeMismatch(issue: z.ZodIssue, path: PropertyKey[]): boolean {
68
99
  return issue.code === "invalid_type" && issue.path.length === path.length;
@@ -117,6 +148,10 @@ export async function validateFormState(
117
148
  for (const field of context.fields) {
118
149
  const value = state[field.id];
119
150
  if (reported.has(field.id)) continue;
151
+ if (lacksFallbackLocale(field, value)) {
152
+ missing.add(field.id);
153
+ continue;
154
+ }
120
155
  const isBlank = field.acceptance
121
156
  ? value !== true
122
157
  : isBlankValue(value, field.type);
@@ -159,10 +194,7 @@ export async function validateFormState(
159
194
  if (!missing.has(field.id)) continue;
160
195
  issues.push({
161
196
  message: context.requiredMessage,
162
- path:
163
- field.localized && context.locale
164
- ? [field.id, context.locale]
165
- : [field.id],
197
+ path: requiredPath(field, context.locale),
166
198
  });
167
199
  }
168
200
 
@@ -3,6 +3,7 @@ import type {
3
3
  DataTypeSelectSource,
4
4
  } from "#dms-core/app/composables/data-types/useDataType";
5
5
  import type { RenderableColumn } from "../data-types/useColumnValueRenderer";
6
+ import { NotFoundRelation } from "../data-types/relationRows";
6
7
 
7
8
  /** A column of a read-only list: a field of its rows. */
8
9
  export interface RepeaterTableColumn {
@@ -49,17 +50,29 @@ export function relationLabelSource(
49
50
  return source ? { ...source, multiple: !!options?.multiple } : undefined;
50
51
  }
51
52
 
53
+ /** The rows a relation column's read answered, by value, and the ids it asked. */
54
+ export interface RelationColumnRows {
55
+ rowsByValue: ReadonlyMap<unknown, Record<string, unknown>>;
56
+ readIds: ReadonlySet<string>;
57
+ }
58
+
52
59
  /**
53
60
  * A relation value as its formatter draws it: the related row in place of
54
- * each id it holds. An id no row matches (a deleted one) stays as it is; a
55
- * value the server already joined is kept.
61
+ * each id it holds. An id the read asked for and no row answers (deleted, out
62
+ * of reach) is not found; one not asked yet stays as it is, as does a value
63
+ * the server already joined.
56
64
  */
57
65
  export function joinRelationValue(
58
66
  value: unknown,
59
- rowsByValue: ReadonlyMap<unknown, Record<string, unknown>>,
67
+ { rowsByValue, readIds }: RelationColumnRows,
60
68
  multiple: boolean,
61
69
  ): unknown {
62
- const join = (item: unknown) => rowsByValue.get(item) ?? item;
70
+ const join = (item: unknown) => {
71
+ const row = rowsByValue.get(item);
72
+ if (row) return row;
73
+ const id = String(item);
74
+ return readIds.has(id) ? new NotFoundRelation(id) : item;
75
+ };
63
76
  if (multiple && Array.isArray(value)) return value.map(join);
64
77
  return join(value);
65
78
  }