@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
@@ -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 {
@@ -5,7 +5,11 @@ import type { FormProps, FormFetchResponse, FormSubmitResponse } from "./types";
5
5
  import { jsonSchemaToZod } from "json-schema-to-zod";
6
6
  import { FormEvents } from "./types/events";
7
7
  import type { FormData, FormFieldValue } from "./types/value";
8
- import type { FormField, FormFieldOrGroup } from "./types/field";
8
+ import type {
9
+ FieldCondition,
10
+ FormField,
11
+ FormFieldOrGroup,
12
+ } from "./types/field";
9
13
  import { isFieldGroup } from "./types/field";
10
14
  import { resolveResponseToast } from "../../build/utils/responseWarning";
11
15
  import { processFieldI18n } from "../../build/utils/fieldOptionsI18n";
@@ -35,9 +39,11 @@ interface FormSubmitNotice {
35
39
  import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
36
40
  import {
37
41
  type ApiFieldError,
42
+ apiErrorStatus,
38
43
  apiErrorText,
39
44
  resolveFieldErrors,
40
45
  } from "#dms-core/app/composables/useFieldErrors";
46
+ import { HTTP_NOT_FOUND } from "#dms-core/app/utils/http-status";
41
47
  import { isBlankValue } from "#dms-core/app/composables/useFormValidation";
42
48
  import {
43
49
  isPlainObject,
@@ -472,6 +478,34 @@ export function isFieldMarkedRequired(
472
478
  return !!field.required || (required?.has(field.id) ?? false);
473
479
  }
474
480
 
481
+ const meetsCondition = (
482
+ condition: FieldCondition,
483
+ values: Record<string, unknown>,
484
+ ): boolean => condition.in.includes(values[condition.field] ?? null);
485
+
486
+ /**
487
+ * The fields the values hide (`visibleWhen` not met) or disable
488
+ * (`disabledWhen` met), added to those the watches hide or disable: the
489
+ * conditions hold from the values the form opens with, without a watch.
490
+ */
491
+ export function withConditionFields(
492
+ fields: ReadonlyArray<Pick<FormField, "id" | "visibleWhen" | "disabledWhen">>,
493
+ values: Record<string, unknown>,
494
+ watched: { disabled?: Set<string>; hidden?: Set<string> },
495
+ ): { disabled: Set<string>; hidden: Set<string> } {
496
+ const disabled = new Set(watched.disabled);
497
+ const hidden = new Set(watched.hidden);
498
+ for (const field of fields) {
499
+ if (field.visibleWhen && !meetsCondition(field.visibleWhen, values)) {
500
+ hidden.add(field.id);
501
+ }
502
+ if (field.disabledWhen && meetsCondition(field.disabledWhen, values)) {
503
+ disabled.add(field.id);
504
+ }
505
+ }
506
+ return { disabled, hidden };
507
+ }
508
+
475
509
  function watchFieldChanges(
476
510
  state: Ref<Record<string, unknown>>,
477
511
  componentId: string | undefined,
@@ -612,13 +646,16 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
612
646
  { deep: true },
613
647
  );
614
648
 
615
- const disabledFields = computed(
616
- () => state.value.disabledFields as Set<string> | undefined,
649
+ const inactiveFields = computed(() =>
650
+ withConditionFields(allFields.value, state.value, {
651
+ disabled: state.value.disabledFields as Set<string> | undefined,
652
+ hidden: state.value.hiddenFields as Set<string> | undefined,
653
+ }),
617
654
  );
618
655
 
619
- const hiddenFields = computed(
620
- () => state.value.hiddenFields as Set<string> | undefined,
621
- );
656
+ const disabledFields = computed(() => inactiveFields.value.disabled);
657
+
658
+ const hiddenFields = computed(() => inactiveFields.value.hidden);
622
659
 
623
660
  const requiredFields = computed(
624
661
  () => state.value.requiredFields as Set<string> | undefined,
@@ -686,7 +723,10 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
686
723
  // A server render shows the failure in the form (FormLoadError) and
687
724
  // hands it to the browser; a toast it added would show in its HTML
688
725
  // alone, which the browser's toasts never hold.
689
- if (!import.meta.env.SSR) showFetchErrorToast(error);
726
+ // A 404 is no error either: the form says the record is not found.
727
+ if (!import.meta.env.SSR && apiErrorStatus(error) !== HTTP_NOT_FOUND) {
728
+ showFetchErrorToast(error);
729
+ }
690
730
  throw error;
691
731
  }
692
732
  };
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  computed,
3
3
  getCurrentScope,
4
+ onMounted,
4
5
  onScopeDispose,
6
+ shallowRef,
5
7
  type ComputedRef,
6
8
  } from "vue";
7
9
  import {
@@ -75,7 +77,12 @@ export interface PageLiveState {
75
77
  * never paused, and its actions do nothing. A server render reads the page as
76
78
  * live and never paused too, but `isEnabled` says whether it declares `live`:
77
79
  * the browser hydrates the page's blocks with its controller installed, so
78
- * the server draws them as it will.
80
+ * the server draws them as it will. The rest of the state is born in the
81
+ * browser (the realtime stream, the page's reads, a pause): a component
82
+ * hydrating the server's HTML reads it as the server did (live, not polling,
83
+ * never read, nothing missed) until its own `onMounted`, then follows the
84
+ * page. A component mounted afresh, after a client navigation, reads the page
85
+ * at once.
79
86
  *
80
87
  * ```ts
81
88
  * const { isPaused, missedCount, resume, hold, release } = usePageLive();
@@ -86,26 +93,29 @@ export interface PageLiveState {
86
93
  * `realtimeTopic` are.
87
94
  */
88
95
  export function usePageLive(): PageLiveState {
89
- const isPaused = computed(() => currentPageLive()?.isPaused.value ?? false);
96
+ // An async block hydrates after the page has installed its controller and
97
+ // read its data: until mounted, it draws the state the server drew.
98
+ const isHydrating = shallowRef(isDmsHydrating());
99
+ if (isHydrating.value) onMounted(() => (isHydrating.value = false));
100
+ const shown = () => (isHydrating.value ? null : currentPageLive());
101
+ const isPaused = computed(() => shown()?.isPaused.value ?? false);
90
102
  const hold = usePageLiveHold();
91
103
  const isServerLive = isServerPageLive();
92
104
  return {
93
105
  isEnabled: computed(() => currentPageLive() !== null || isServerLive),
94
106
  isLive: computed(() => !isPaused.value),
95
107
  isPaused,
96
- pauseReason: computed(() => currentPageLive()?.pauseReason.value ?? null),
108
+ pauseReason: computed(() => shown()?.pauseReason.value ?? null),
97
109
  pause: () => currentPageLive()?.pause(),
98
110
  resume: () => currentPageLive()?.resume(),
99
111
  toggle: () => currentPageLive()?.toggle(),
100
112
  hold: hold.hold,
101
113
  release: hold.release,
102
- lastUpdatedAt: computed(
103
- () => currentPageLive()?.lastUpdatedAt.value ?? null,
104
- ),
105
- missedCount: computed(() => currentPageLive()?.missedCount.value ?? 0),
106
- missedDetail: computed(() => currentPageLive()?.missedDetail.value ?? null),
114
+ lastUpdatedAt: computed(() => shown()?.lastUpdatedAt.value ?? null),
115
+ missedCount: computed(() => shown()?.missedCount.value ?? 0),
116
+ missedDetail: computed(() => shown()?.missedDetail.value ?? null),
107
117
  setMissedDetail: (detail) => currentPageLive()?.setMissedDetail(detail),
108
- isPolling: computed(() => currentPageLive()?.isPolling.value ?? false),
118
+ isPolling: computed(() => shown()?.isPolling.value ?? false),
109
119
  };
110
120
  }
111
121
 
@@ -27,6 +27,7 @@ export interface CascaderTreeOptions {
27
27
 
28
28
  export const CASCADER_PATH_SEPARATOR = " / ";
29
29
 
30
+ /** How many rows the picker's open menu lists to browse the tree. */
30
31
  export const CASCADER_FETCH_LIMIT = 1000;
31
32
 
32
33
  export const DEFAULT_CASCADER_KEY_MAPPING: CascaderKeyMapping = {
@@ -220,10 +221,100 @@ export function cascaderValuePath(
220
221
  ): string[] {
221
222
  const row = asPickedRow(item);
222
223
  const ownLabel = row ? rowValue(row, keyMapping.label) : undefined;
223
- const id = row
224
- ? rowValue(row, keyMapping.value)
225
- : rowValue({ id: item }, "id");
224
+ const id = cascaderValueId(item, keyMapping);
226
225
  if (!id) return ownLabel ? [ownLabel] : [];
227
226
  const labels = rows ? buildCascaderPathLabels(rows, keyMapping, id) : [];
228
227
  return labels.length > 0 ? labels : [ownLabel ?? id];
229
228
  }
229
+
230
+ /** Reads the rows of the given ids from the cascader's `select` route. */
231
+ export type CascaderRowsReader = (ids: string[]) => Promise<CascaderRow[]>;
232
+
233
+ /** The rows a path is drawn from, and the picked ids none of them answer. */
234
+ export interface CascaderAncestry {
235
+ rows: CascaderRow[];
236
+ notFound: string[];
237
+ }
238
+
239
+ /**
240
+ * The rows of the picked ids and of every ancestor above them, read with
241
+ * `?ids=` one level at a time: the picked rows, then their parents, then
242
+ * theirs, until a level names no row not read yet. A tree is as many reads as
243
+ * it is deep (a level is one request for all its ids), whatever its size, and
244
+ * a parent cycle ends because a row is never asked twice. A picked id no row
245
+ * answers (deleted, out of reach) is returned as not found.
246
+ */
247
+ export async function readCascaderAncestry(
248
+ read: CascaderRowsReader,
249
+ keyMapping: CascaderKeyMapping,
250
+ ids: string[],
251
+ ): Promise<CascaderAncestry> {
252
+ const rowsByValue = new Map<string, CascaderRow>();
253
+ const asked = new Set<string>();
254
+ let pending = [...new Set(ids)];
255
+ while (pending.length > 0) {
256
+ for (const id of pending) asked.add(id);
257
+ const rows = await read(pending);
258
+ for (const row of rows) {
259
+ const value = rowValue(row, keyMapping.value);
260
+ if (value !== undefined) rowsByValue.set(value, row);
261
+ }
262
+ pending = [
263
+ ...new Set(
264
+ rows
265
+ .map((row) => rowValue(row, keyMapping.parent))
266
+ .filter(
267
+ (parent): parent is string =>
268
+ parent !== undefined && !asked.has(parent),
269
+ ),
270
+ ),
271
+ ];
272
+ }
273
+ return {
274
+ rows: [...rowsByValue.values()],
275
+ notFound: ids.filter((id) => !rowsByValue.has(id)),
276
+ };
277
+ }
278
+
279
+ /** The id of a picked value: an id, or a row carrying its id. */
280
+ export function cascaderValueId(
281
+ item: unknown,
282
+ keyMapping: CascaderKeyMapping,
283
+ ): string | undefined {
284
+ const row = asPickedRow(item);
285
+ return row ? rowValue(row, keyMapping.value) : rowValue({ id: item }, "id");
286
+ }
287
+
288
+ /** What a cell already holds of its path, and the ids it still has to read. */
289
+ export interface CascaderValueReads {
290
+ /** The rows the server joined: each one labels itself. */
291
+ joinedRows: CascaderRow[];
292
+ /** The picked ids sent plain, and the parents of the joined rows. */
293
+ idsToRead: string[];
294
+ }
295
+
296
+ /**
297
+ * Splits a cell's picked values into the rows the list joined and the ids it
298
+ * still reads: a plain id is read, a joined row only has its parent read
299
+ * (none for a root), so a list whose route joins the relation reads nothing
300
+ * for a pick at the top of the tree.
301
+ */
302
+ export function cascaderValueReads(
303
+ values: unknown[],
304
+ keyMapping: CascaderKeyMapping,
305
+ ): CascaderValueReads {
306
+ const joinedRows = values
307
+ .map(asPickedRow)
308
+ .filter(
309
+ (row): row is CascaderRow =>
310
+ !!row && rowValue(row, keyMapping.value) !== undefined,
311
+ );
312
+ const plainIds = values
313
+ .filter((item) => !asPickedRow(item))
314
+ .map((item) => cascaderValueId(item, keyMapping))
315
+ .filter((id): id is string => id !== undefined);
316
+ const parents = joinedRows
317
+ .map((row) => rowValue(row, keyMapping.parent))
318
+ .filter((id): id is string => id !== undefined);
319
+ return { joinedRows, idsToRead: [...new Set([...plainIds, ...parents])] };
320
+ }
@@ -290,12 +290,15 @@
290
290
  "forbidden_description": "Your role doesn't let you view this entry, so the form can't open on its current values. Ask an administrator for access.",
291
291
  "missing_title": "No entry selected",
292
292
  "missing_description": "This form shows one entry, and the address names none. Open it from an entry to see its values.",
293
+ "not_found_title": "Not found",
294
+ "not_found_description": "This entry does not exist, or no longer does: it may have been deleted.",
293
295
  "retry": "Try again"
294
296
  },
295
297
  "select_date": "Select date",
296
298
  "relation": {
297
299
  "selected_count": "{count} selected",
298
- "add_new": "Add new entry"
300
+ "add_new": "Add new entry",
301
+ "not_found": "Not found"
299
302
  },
300
303
  "cascader": {
301
304
  "placeholder": "Select an item",
@@ -291,12 +291,15 @@
291
291
  "forbidden_description": "Votre rôle ne vous permet pas de voir cet élément, le formulaire ne peut donc pas s'ouvrir sur ses valeurs actuelles. Demandez l'accès à un administrateur.",
292
292
  "missing_title": "Aucun élément sélectionné",
293
293
  "missing_description": "Ce formulaire affiche un élément, et l'adresse n'en désigne aucun. Ouvrez-le depuis un élément pour voir ses valeurs.",
294
+ "not_found_title": "Introuvable",
295
+ "not_found_description": "Cet élément n'existe pas, ou plus : il a peut-être été supprimé.",
294
296
  "retry": "Réessayer"
295
297
  },
296
298
  "select_date": "Sélectionner une date",
297
299
  "relation": {
298
300
  "selected_count": "{count} sélectionné(s)",
299
- "add_new": "Ajouter une nouvelle entrée"
301
+ "add_new": "Ajouter une nouvelle entrée",
302
+ "not_found": "Introuvable"
300
303
  },
301
304
  "cascader": {
302
305
  "placeholder": "Sélectionner un élément",