@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,90 @@
1
+ // A CodeMirror stream parser (a language written as a line tokenizer, like
2
+ // the shell's) run for read-only highlighting. `StreamLanguage` would do it
3
+ // inside an editor state, and so pull CodeMirror's view and state into every
4
+ // page showing a snippet: this drives the parser the same way, line by line
5
+ // over `StringStream`, and names its tokens' tags the same way.
6
+ import { type StreamParser, StringStream } from "@codemirror/streamparser";
7
+ import { type Highlighter, type Tag, tags } from "@lezer/highlight";
8
+
9
+ /** Receives one styled range of the code: its offsets and its classes. */
10
+ export type HighlightedRange = (
11
+ from: number,
12
+ to: number,
13
+ classes: string,
14
+ ) => void;
15
+
16
+ // CodeMirror's defaults, which `StreamLanguage` hands every stream.
17
+ const TAB_SIZE = 4;
18
+ const INDENT_UNIT = 2;
19
+ // Zero-length tokens a parser may return in a row (each one changing its
20
+ // state) before the stream moves on by itself, as `StreamLanguage` allows.
21
+ const MAX_EMPTY_TOKENS = 10;
22
+
23
+ type TagValue = Tag | readonly Tag[] | ((tag: Tag) => Tag);
24
+ const TAG_TABLE = tags as unknown as Record<string, TagValue | undefined>;
25
+ const tagsByName = new Map<string, Tag[]>();
26
+
27
+ // A token type names tags the way `StreamLanguage` reads them: space
28
+ // separated, each a tag followed by its modifiers (`variableName.special`).
29
+ function tagsOf(type: string): Tag[] {
30
+ const cached = tagsByName.get(type);
31
+ if (cached) return cached;
32
+ const resolved = type.split(" ").flatMap((name) => {
33
+ let found: Tag[] = [];
34
+ for (const part of name.split(".")) {
35
+ const value = TAG_TABLE[part];
36
+ if (typeof value === "function") found = found.map(value);
37
+ else if (value && !found.length)
38
+ found = Array.isArray(value) ? [...value] : [value as Tag];
39
+ }
40
+ return found;
41
+ });
42
+ tagsByName.set(type, resolved);
43
+ return resolved;
44
+ }
45
+
46
+ /**
47
+ * Tokenizes `code` with `parser` and calls `put` for each range its
48
+ * highlighter styles, adjacent ranges of one style merged, as
49
+ * `highlightTree` reports a parsed tree.
50
+ */
51
+ export function highlightStream<State>(
52
+ parser: StreamParser<State>,
53
+ code: string,
54
+ highlighter: Highlighter,
55
+ put: HighlightedRange,
56
+ ): void {
57
+ const state = parser.startState?.(INDENT_UNIT) ?? (true as State);
58
+ let pending: { from: number; to: number; classes: string } | null = null;
59
+ const flush = () => {
60
+ if (pending) put(pending.from, pending.to, pending.classes);
61
+ pending = null;
62
+ };
63
+ const add = (from: number, to: number, classes: string | null) => {
64
+ if (pending && (pending.to !== from || pending.classes !== classes))
65
+ flush();
66
+ if (!classes) return;
67
+ if (pending) pending.to = to;
68
+ else pending = { from, to, classes };
69
+ };
70
+
71
+ let lineStart = 0;
72
+ for (const line of code.split("\n")) {
73
+ if (!line) parser.blankLine?.(state, INDENT_UNIT);
74
+ const stream = new StringStream(line, TAB_SIZE, INDENT_UNIT);
75
+ let emptyTokens = 0;
76
+ while (!stream.eol()) {
77
+ stream.start = stream.pos;
78
+ const type = parser.token(stream, state);
79
+ if (stream.pos === stream.start) {
80
+ if (++emptyTokens < MAX_EMPTY_TOKENS) continue;
81
+ stream.next();
82
+ }
83
+ emptyTokens = 0;
84
+ const classes = type ? highlighter.style(tagsOf(type)) : null;
85
+ add(lineStart + stream.start, lineStart + stream.pos, classes);
86
+ }
87
+ lineStart += line.length + 1;
88
+ }
89
+ flush();
90
+ }
@@ -1,18 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, shallowRef } from "vue";
2
+ import { computed, onMounted, shallowRef, watch } from "vue";
3
3
  import DmsCopyButton from "../copy/CopyButton.vue";
4
4
  import {
5
5
  codeLanguageLabel,
6
6
  PLAIN_CODE_LANGUAGE,
7
7
  resolveCodeLanguage,
8
8
  } from "../../build/utils/codeLanguages";
9
- import type { CodeToken, highlightCode } from "../../build/utils/codeHighlight";
9
+ import type {
10
+ CodeHighlighter,
11
+ CodeToken,
12
+ } from "../../build/utils/codeHighlight";
10
13
 
11
14
  // Read-only code in the editor's look: a head with the title, the language
12
15
  // and a copy button, then the code highlighted by the editor's languages.
13
16
  // Without a title or a language there is no head: the copy button floats in
14
- // the code's corner. Until the highlighter loads (and on the server) the
15
- // code shows as is.
17
+ // the code's corner. Until its language's grammar loads (and on the server)
18
+ // the code shows as is.
16
19
 
17
20
  /**
18
21
  * How long lines break: `false` scrolls them sideways, `true` wraps them
@@ -63,7 +66,7 @@ const WRAP_CLASSES: Record<"false" | "true" | "anywhere", string> = {
63
66
  anywhere: "whitespace-pre-wrap [overflow-wrap:anywhere]",
64
67
  };
65
68
 
66
- const highlight = shallowRef<typeof highlightCode | null>(null);
69
+ const highlight = shallowRef<CodeHighlighter | null>(null);
67
70
 
68
71
  const language = computed(() => resolveCodeLanguage(props.language));
69
72
  // A language given, even `text`, is named in a head; without a title either,
@@ -79,9 +82,7 @@ const wrapClass = computed(
79
82
  () => WRAP_CLASSES[String(props.wrap) as keyof typeof WRAP_CLASSES],
80
83
  );
81
84
  const tokens = computed<CodeToken[]>(() =>
82
- highlight.value
83
- ? highlight.value(props.code, language.value)
84
- : [{ text: props.code }],
85
+ highlight.value ? highlight.value(props.code) : [{ text: props.code }],
85
86
  );
86
87
  const sizeStyle = computed(() => {
87
88
  if (props.maxLines)
@@ -91,10 +92,20 @@ const sizeStyle = computed(() => {
91
92
  return props.maxHeight ? { maxHeight: props.maxHeight } : undefined;
92
93
  });
93
94
 
94
- onMounted(async () => {
95
- const module = await import("../../build/utils/codeHighlight");
96
- highlight.value = module.highlightCode;
97
- });
95
+ // Only the grammar of the language shown loads. A language changed while
96
+ // one loads drops it: the code stays plain until its own grammar is in.
97
+ async function loadHighlighter(): Promise<void> {
98
+ const requested = language.value;
99
+ highlight.value = null;
100
+ const { loadCodeHighlighter } = await import(
101
+ "../../build/utils/codeHighlight"
102
+ );
103
+ const highlighter = await loadCodeHighlighter(requested);
104
+ if (language.value === requested) highlight.value = highlighter;
105
+ }
106
+
107
+ onMounted(loadHighlighter);
108
+ watch(language, loadHighlighter);
98
109
  </script>
99
110
 
100
111
  <template>
@@ -326,6 +326,7 @@ const localizedSchema = computed(() => {
326
326
  id: field.id,
327
327
  type: field.type,
328
328
  localized: field.localized,
329
+ fallbackLocale: field.fallbackLocale,
329
330
  required: isFieldRequired(field),
330
331
  acceptance: isAcceptanceField(field),
331
332
  }));
@@ -6,7 +6,10 @@ import {
6
6
  findCascaderNode,
7
7
  formatCascaderPath,
8
8
  isCascaderNodeSelectable,
9
+ readCascaderAncestry,
9
10
  resolveCascaderKeyMapping,
11
+ buildCascaderPathLabels,
12
+ CASCADER_PATH_SEPARATOR,
10
13
  searchCascaderTree,
11
14
  type CascaderKeyMapping,
12
15
  type CascaderNode,
@@ -18,6 +21,7 @@ import {
18
21
  FIELD_TRIGGER_INVALID_CLASS,
19
22
  FIELD_TRIGGER_UI,
20
23
  } from "../../../build/utils/fieldTrigger";
24
+ import { useRelationRows } from "../../../build/composables/data-types/relationRows";
21
25
 
22
26
  interface CascaderProps {
23
27
  searchUrl: string;
@@ -88,6 +92,36 @@ const { data, status, execute } = await useDmsAsyncData(
88
92
  );
89
93
 
90
94
  const keyMapping = computed(() => resolveCascaderKeyMapping(props.keyMapping));
95
+ const selectedValues = computed<string[]>(() => {
96
+ if (Array.isArray(props.modelValue)) {
97
+ return props.modelValue.map(String);
98
+ }
99
+ return props.modelValue ? [String(props.modelValue)] : [];
100
+ });
101
+
102
+ // The picked rows and their ancestors, read by `?ids=` level by level: the
103
+ // field names its value before the menu is opened, whatever the tree's size.
104
+ const { readRelationRows } = useRelationRows();
105
+ const { data: ancestry } = await useDmsAsyncData(
106
+ () => `cascader-value-${instanceId}-${selectedValues.value.join(",")}`,
107
+ () =>
108
+ selectedValues.value.length > 0
109
+ ? readCascaderAncestry(
110
+ (ids) =>
111
+ readRelationRows(
112
+ {
113
+ searchUrl: props.searchUrl,
114
+ keyMapping: { value: keyMapping.value.value },
115
+ },
116
+ ids,
117
+ ),
118
+ keyMapping.value,
119
+ selectedValues.value,
120
+ )
121
+ : Promise.resolve(undefined),
122
+ // The field's label, not the page's data.
123
+ { lazy: true, refreshOnPageRefresh: false },
124
+ );
91
125
  const rows = computed(() => data.value?.results ?? []);
92
126
  const isPartial = computed(
93
127
  () => (data.value?.total ?? 0) > rows.value.length && rows.value.length > 0,
@@ -100,12 +134,6 @@ const selectionRules = computed(() => ({
100
134
  maxDepth: props.maxDepth,
101
135
  }));
102
136
 
103
- const selectedValues = computed<string[]>(() => {
104
- if (Array.isArray(props.modelValue)) {
105
- return props.modelValue.map(String);
106
- }
107
- return props.modelValue ? [String(props.modelValue)] : [];
108
- });
109
137
  const selectedSet = computed(() => new Set(selectedValues.value));
110
138
 
111
139
  const open = ref(false);
@@ -134,14 +162,11 @@ const columns = computed<CascaderNode[][]>(() => {
134
162
  return result;
135
163
  });
136
164
 
137
- function initialValueLabel(): string | undefined {
138
- const source = Array.isArray(props.initialValue)
139
- ? props.initialValue[0]
140
- : props.initialValue;
141
- if (!source || typeof source !== "object") return undefined;
142
- const label = (source as Record<string, unknown>)[keyMapping.value.label];
143
- return label === undefined || label === null ? undefined : String(label);
144
- }
165
+ /** The picked id no row answers (deleted, out of reach), if it is one. */
166
+ const notFoundValue = computed(() => {
167
+ const value = selectedValues.value[0];
168
+ return value && ancestry.value?.notFound.includes(value) ? value : undefined;
169
+ });
145
170
 
146
171
  const triggerLabel = computed(() => {
147
172
  if (selectedValues.value.length === 0) return "";
@@ -151,9 +176,15 @@ const triggerLabel = computed(() => {
151
176
  });
152
177
  }
153
178
  const value = selectedValues.value[0]!;
179
+ if (notFoundValue.value) return t("dms.form.relation.not_found");
154
180
  const node = findCascaderNode(roots.value, value);
155
181
  if (node) return formatCascaderPath(node);
156
- return initialValueLabel() ?? value;
182
+ const path = buildCascaderPathLabels(
183
+ ancestry.value?.rows ?? [],
184
+ keyMapping.value,
185
+ value,
186
+ );
187
+ return path.length > 0 ? path.join(CASCADER_PATH_SEPARATOR) : value;
157
188
  });
158
189
 
159
190
  const placeholderLabel = computed(() =>
@@ -339,12 +370,6 @@ watch(searchTerm, () => {
339
370
  };
340
371
  });
341
372
 
342
- onMounted(() => {
343
- if (selectedValues.value.length > 0 || props.initialValue) {
344
- void execute();
345
- }
346
- });
347
-
348
373
  function nodeClasses(node: CascaderNode, isHighlighted: boolean): string[] {
349
374
  return [
350
375
  "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
@@ -383,7 +408,13 @@ function nodeClasses(node: CascaderNode, isHighlighted: boolean): string[] {
383
408
  :aria-label="placeholderLabel"
384
409
  v-bind="ariaAttrs"
385
410
  >
386
- <span v-if="triggerLabel" class="truncate">{{ triggerLabel }}</span>
411
+ <span
412
+ v-if="triggerLabel"
413
+ :class="['truncate', notFoundValue && 'text-muted']"
414
+ :title="notFoundValue ?? triggerLabel"
415
+ >
416
+ {{ triggerLabel }}
417
+ </span>
387
418
  <span v-else class="text-dimmed truncate">{{ placeholderLabel }}</span>
388
419
  </UButton>
389
420
 
@@ -19,7 +19,10 @@ import DmsForm from "../Form.vue";
19
19
  import { dmsOwnDrawer } from "../../../build/composables/containers/context";
20
20
  import { FIELD_TRIGGER_ICON } from "../../../build/utils/fieldTrigger";
21
21
  import { unlabeledRelationIds } from "../../../build/composables/data-types/relationValue";
22
- import { useRelationRows } from "../../../build/composables/data-types/relationRows";
22
+ import {
23
+ notFoundRelationIds,
24
+ useRelationRows,
25
+ } from "../../../build/composables/data-types/relationRows";
23
26
  import type { FormProps } from "../../../composables/form/types";
24
27
  import { sameFormValue } from "../../../build/composables/unsaved-changes/formValue";
25
28
  import { FORM_ENTRY_CONTEXT_KEY } from "../../../build/composables/form/formEntryContext";
@@ -385,19 +388,36 @@ const labeledIds = computed(
385
388
  // keeps showing as is rather than being asked again.
386
389
  const requestedIds = new Set<string>();
387
390
 
391
+ // The ids the route did not answer (deleted, out of reach), shown as "Not
392
+ // found" with the id on hover.
393
+ const notFoundIds: Ref<ReadonlySet<string>> = shallowRef(new Set());
394
+
388
395
  async function loadValueItems(ids: string[]) {
389
396
  for (const id of ids) requestedIds.add(id);
390
- const rows = await readRelationRows(
391
- { searchUrl: props.searchUrl, keyMapping: props.keyMapping },
392
- ids,
393
- );
394
- if (!rows.length) return;
397
+ const source = { searchUrl: props.searchUrl, keyMapping: props.keyMapping };
398
+ const rows = await readRelationRows(source, ids);
399
+ const missing = notFoundRelationIds(source, ids, rows);
400
+ if (!rows.length && !missing.length) return;
401
+ notFoundIds.value = new Set([...notFoundIds.value, ...missing]);
395
402
  valueItems.value = [
396
403
  ...valueItems.value,
397
404
  ...rows.map((item) => mapData(item as Record<string, string>)),
405
+ // Kept pickable, so the value can still be deselected.
406
+ ...missing.map((id) => ({
407
+ label: t("dms.form.relation.not_found"),
408
+ value: id,
409
+ })),
398
410
  ];
399
411
  }
400
412
 
413
+ const notFoundTitle = computed(() => {
414
+ const ids = [props.modelValue]
415
+ .flat()
416
+ .map(String)
417
+ .filter((id) => notFoundIds.value.has(id));
418
+ return ids.length ? ids.join(", ") : undefined;
419
+ });
420
+
401
421
  // The picker shows a value through its item: an id no item carries yet is
402
422
  // labelled (and given its avatar) from its row before the picker is opened.
403
423
  onMounted(() => {
@@ -558,6 +578,7 @@ const addFormOptions = computed(() => {
558
578
  :ignore-filter="true"
559
579
  :ui="{ content: 'z-50' }"
560
580
  :aria-label="t('dms.a11y.show_options')"
581
+ :title="notFoundTitle"
561
582
  @update:open="onOpen"
562
583
  @update:model-value="handleUpdateModelValue"
563
584
  >
@@ -11,6 +11,7 @@ import {
11
11
  useColumnValueRenderer,
12
12
  } from "../../../build/composables/data-types/useColumnValueRenderer";
13
13
  import {
14
+ type RelationColumnRows,
14
15
  type RelationLabelSource,
15
16
  type RepeaterTableColumn,
16
17
  joinRelationValue,
@@ -53,16 +54,22 @@ function useColumnRelationRows(columnId: string, source: RelationLabelSource) {
53
54
  );
54
55
  const { data } = useDmsAsyncData(
55
56
  `repeater-table-relation-${instanceId}-${columnId}`,
56
- () => readRelationRows(source, ids.value),
57
+ async () => {
58
+ const readIds = ids.value;
59
+ return { readIds, rows: await readRelationRows(source, readIds) };
60
+ },
57
61
  // Names for the cells, not the page's data.
58
62
  { lazy: true, refreshOnPageRefresh: false, watch: [ids] },
59
63
  );
60
- return computed(
61
- () =>
62
- new Map(
63
- (data.value ?? []).map((row) => [relationRowValue(source, row), row]),
64
- ),
65
- );
64
+ return computed<RelationColumnRows>(() => ({
65
+ rowsByValue: new Map(
66
+ (data.value?.rows ?? []).map((row) => [
67
+ relationRowValue(source, row),
68
+ row,
69
+ ]),
70
+ ),
71
+ readIds: new Set(data.value?.readIds),
72
+ }));
66
73
  }
67
74
 
68
75
  const NO_LABELS: ReadonlySet<string> = new Set();
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- CASCADER_FETCH_LIMIT,
4
3
  CASCADER_PATH_SEPARATOR,
4
+ cascaderValueId,
5
5
  cascaderValuePath,
6
+ cascaderValueReads,
7
+ readCascaderAncestry,
6
8
  resolveCascaderKeyMapping,
7
9
  type CascaderKeyMapping,
8
10
  } from "../../utils/cascader";
9
11
  import { MAX_RELATION_BADGES } from "../../build/composables/data-types/relationBadges";
12
+ import { useRelationRows } from "../../build/composables/data-types/relationRows";
10
13
 
11
14
  const DEFAULT_VALUE_KEY = "_id";
12
15
 
@@ -17,20 +20,20 @@ interface CascaderPathProps {
17
20
  keyMapping?: Partial<CascaderKeyMapping>;
18
21
  }
19
22
 
20
- /** A picked value of a multiple cascader: its name, its path on hover. */
23
+ /**
24
+ * A picked value as the cell names it: its path, or "Not found" with its id
25
+ * on hover when its row is gone or out of reach.
26
+ */
21
27
  interface CascaderBadge {
22
28
  label: string;
23
29
  title: string;
30
+ isNotFound: boolean;
24
31
  }
25
32
 
26
33
  const props = defineProps<CascaderPathProps>();
27
34
 
28
- const { $authFetch } = useAuthFetch();
29
-
30
- interface CascaderResponse {
31
- results: Record<string, unknown>[];
32
- total: number;
33
- }
35
+ const { t } = useI18n();
36
+ const { readRelationRows } = useRelationRows();
34
37
 
35
38
  const keyMapping = computed(() =>
36
39
  resolveCascaderKeyMapping({
@@ -39,33 +42,72 @@ const keyMapping = computed(() =>
39
42
  }),
40
43
  );
41
44
 
45
+ const values = computed<unknown[]>(() =>
46
+ Array.isArray(props.value) ? props.value : [props.value],
47
+ );
48
+ const reads = computed(() =>
49
+ cascaderValueReads(values.value, keyMapping.value),
50
+ );
51
+ const ids = computed(() => reads.value.idsToRead);
52
+
53
+ // The ids a list did not join (and the parents of those it did), with their
54
+ // ancestors, read by `?ids=` level by level.
42
55
  const { data } = await useDmsAsyncData(
43
- `cascader-path-${props.searchUrl}`,
56
+ () => `cascader-path-${props.searchUrl}-${ids.value.join(",")}`,
44
57
  () =>
45
- props.searchUrl
46
- ? $authFetch<CascaderResponse>(props.searchUrl, {
47
- params: { limit: CASCADER_FETCH_LIMIT },
48
- })
58
+ props.searchUrl && ids.value.length > 0
59
+ ? readCascaderAncestry(
60
+ (pending) =>
61
+ readRelationRows(
62
+ {
63
+ searchUrl: props.searchUrl!,
64
+ keyMapping: { value: keyMapping.value.value },
65
+ },
66
+ pending,
67
+ ),
68
+ keyMapping.value,
69
+ ids.value,
70
+ )
49
71
  : Promise.resolve(undefined),
50
72
  // The labels of a path, not the page's data.
51
73
  { lazy: true, refreshOnPageRefresh: false },
52
74
  );
53
75
 
54
- const pathOf = (item: unknown): string[] =>
55
- cascaderValuePath(item, keyMapping.value, data.value?.results);
76
+ const pathRows = computed(() => [
77
+ ...reads.value.joinedRows,
78
+ ...(data.value?.rows ?? []),
79
+ ]);
56
80
 
57
- const pathLabel = computed(() =>
58
- pathOf(props.value).join(CASCADER_PATH_SEPARATOR),
59
- );
81
+ const notFound = computed(() => new Set(data.value?.notFound));
82
+
83
+ function badgeOf(item: unknown): CascaderBadge | undefined {
84
+ const id = cascaderValueId(item, keyMapping.value);
85
+ if (id !== undefined && notFound.value.has(id)) {
86
+ return {
87
+ label: t("dms.form.relation.not_found"),
88
+ title: id,
89
+ isNotFound: true,
90
+ };
91
+ }
92
+ const path = cascaderValuePath(item, keyMapping.value, pathRows.value);
93
+ if (path.length === 0) return undefined;
94
+ return {
95
+ label: path.at(-1) ?? "",
96
+ title: path.join(CASCADER_PATH_SEPARATOR),
97
+ isNotFound: false,
98
+ };
99
+ }
100
+
101
+ const single = computed(() => {
102
+ const badge = badgeOf(props.value);
103
+ if (!badge || badge.isNotFound) return badge;
104
+ return { ...badge, label: badge.title };
105
+ });
60
106
 
61
107
  const badges = computed<CascaderBadge[]>(() =>
62
108
  (Array.isArray(props.value) ? props.value : [])
63
- .map(pathOf)
64
- .filter((path) => path.length > 0)
65
- .map((path) => ({
66
- label: path.at(-1) ?? "",
67
- title: path.join(CASCADER_PATH_SEPARATOR),
68
- })),
109
+ .map(badgeOf)
110
+ .filter((badge): badge is CascaderBadge => !!badge),
69
111
  );
70
112
  const visibleBadges = computed(() =>
71
113
  badges.value.slice(0, MAX_RELATION_BADGES),
@@ -82,6 +124,7 @@ const hiddenBadges = computed(() => badges.value.slice(MAX_RELATION_BADGES));
82
124
  v-for="badge in visibleBadges"
83
125
  :key="badge.title"
84
126
  :label="badge.label"
127
+ :class="badge.isNotFound && 'text-muted'"
85
128
  :title="badge.title"
86
129
  color="neutral"
87
130
  variant="subtle"
@@ -97,5 +140,11 @@ const hiddenBadges = computed(() => badges.value.slice(MAX_RELATION_BADGES));
97
140
  size="sm"
98
141
  />
99
142
  </span>
100
- <span v-else class="truncate" :title="pathLabel">{{ pathLabel }}</span>
143
+ <span
144
+ v-else-if="single"
145
+ :class="['truncate', single.isNotFound && 'text-muted']"
146
+ :title="single.title"
147
+ >
148
+ {{ single.label }}
149
+ </span>
101
150
  </template>
@@ -23,6 +23,7 @@ import {
23
23
  import { readSubline } from "../../build/composables/data-types/cellHelpers";
24
24
  import { useComposedText } from "#dms-core/app/composables/translation/useComposedText";
25
25
  import { useCardFields } from "../../build/composables/table-view/useCardFields";
26
+ import { describeTableWriteError } from "../../build/composables/table-view/utils/writeError";
26
27
 
27
28
  const DEFAULT_COLUMN_PAGE_SIZE = 10;
28
29
  const DEFAULT_COLUMN_MAX_HEIGHT = "60vh";
@@ -83,7 +84,7 @@ const emit = defineEmits<{
83
84
  const groupByField = defineModel<string>("groupByField", { required: true });
84
85
 
85
86
  const { $authFetch } = useAuthFetch();
86
- const { processI18n, processApiMessage } = useTranslation();
87
+ const { processI18n } = useTranslation();
87
88
  const { getDataType } = useDataTypes();
88
89
  const { t } = useI18n();
89
90
  const { processText } = useComposedText();
@@ -321,18 +322,11 @@ const awaitNewerMoves = async (itemId: string, current: Promise<void>) => {
321
322
  }
322
323
  };
323
324
 
324
- interface MoveErrorPayload {
325
- data?: { message?: string };
326
- message?: string;
327
- }
328
-
329
325
  const notifyMoveError = (error: unknown) => {
330
- const err = error as MoveErrorPayload;
331
- const message = err?.data?.message || err?.message;
332
326
  toast.add({
333
327
  color: Color.error,
334
328
  title: t("dms.table.kanban.move_error"),
335
- description: message ? processApiMessage(message) : undefined,
329
+ description: describeTableWriteError(error, t, props.columns, processI18n),
336
330
  });
337
331
  };
338
332
 
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts" generic="T extends Data">
2
2
  import { get } from "@nuxt/ui/runtime/utils/index.js";
3
+ import DmsBlockStatus from "../../build/components/blocks/BlockStatus.vue";
4
+ import { readFetchNotFound } from "../../build/utils/fetchNotFound";
3
5
  import type {
4
6
  ColumnSizingState,
5
7
  ExpandedState,
@@ -53,6 +55,7 @@ import {
53
55
  KANBAN_DISPLAY_BRIDGE_KEY,
54
56
  } from "../../composables/table-view/kanban";
55
57
  import { useTableRowActions } from "../../build/composables/table-view/useTableViewRowActions";
58
+ import { describeTableWriteError } from "../../build/composables/table-view/utils/writeError";
56
59
  import {
57
60
  buildTableQuery,
58
61
  isFilterEffective,
@@ -1064,6 +1067,11 @@ const listLoadError = computed(() => {
1064
1067
  : "error.500";
1065
1068
  });
1066
1069
 
1070
+ // A list answering 404 names a resource that does not exist (the workspace
1071
+ // its rows belong to was deleted): no retry fixes it, so the table gives way
1072
+ // to the not-found state blocks show, with what the 404 said.
1073
+ const listNotFound = computed(() => readFetchNotFound(settledError.value));
1074
+
1067
1075
  // useDmsAsyncData keeps the previous result when a refetch fails, so rows kept
1068
1076
  // on screen would masquerade as the newly requested page/filter. Hide them
1069
1077
  // while the latest query is in error; the error panel takes their place.
@@ -1384,7 +1392,7 @@ const { reorderState } = useTableReorder<T>({
1384
1392
  onError: (failure) =>
1385
1393
  toast.add({
1386
1394
  title: t("dms.table.reorder.error"),
1387
- description: resolveApiErrorMessage(failure),
1395
+ description: describeTableWriteError(failure, t, allColumns, processI18n),
1388
1396
  color: Color.error,
1389
1397
  icon: "i-ph-warning-circle",
1390
1398
  }),
@@ -1569,6 +1577,7 @@ const {
1569
1577
  } = useTableRowActions<T>({
1570
1578
  api: $authFetch,
1571
1579
  location,
1580
+ columns: allColumns,
1572
1581
  caption,
1573
1582
  labelKey,
1574
1583
  rowIdKey: props.rowIdKey,
@@ -2391,7 +2400,13 @@ onMounted(() => {
2391
2400
  </script>
2392
2401
 
2393
2402
  <template>
2403
+ <DmsBlockStatus
2404
+ v-if="listNotFound"
2405
+ state="not-found"
2406
+ :not-found="listNotFound"
2407
+ />
2394
2408
  <UTable
2409
+ v-else
2395
2410
  ref="tableRoot"
2396
2411
  v-bind="tableProps"
2397
2412
  v-model:pagination="paginationState"
@@ -18,6 +18,12 @@ export interface FormFieldReadonly {
18
18
  link?: FormFieldLink;
19
19
  }
20
20
 
21
+ /** Holds while the field `field` holds one of the values `in` lists. */
22
+ export interface FieldCondition {
23
+ field: string;
24
+ in: unknown[];
25
+ }
26
+
21
27
  export interface FormField {
22
28
  id: string;
23
29
  label?: string;
@@ -36,6 +42,12 @@ export interface FormField {
36
42
  /** "Module default · Use default" while it holds something else. */
37
43
  offersDefault?: boolean;
38
44
  localized?: boolean;
45
+ /** Shown only while the condition holds: hidden, neither required nor sent. */
46
+ visibleWhen?: FieldCondition;
47
+ /** Read-only while the condition holds: its value is not sent. */
48
+ disabledWhen?: FieldCondition;
49
+ /** A localized field's fallback language: filled whenever any language is. */
50
+ fallbackLocale?: string;
39
51
  }
40
52
 
41
53
  export interface FieldGroup {