@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.
- package/CHANGELOG.md +113 -0
- package/dist/config.d.ts +0 -2
- package/dist/config.js +0 -4
- package/dist/config.js.map +1 -1
- package/dist/implementations/dms/frontend-modules.js +17 -6
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/manifest.d.ts +6 -0
- package/dist/implementations/dms/manifest.js +10 -7
- package/dist/implementations/dms/manifest.js.map +1 -1
- package/dist/implementations/dms/page.js +45 -16
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/store/listing.js +1 -2
- package/dist/store/listing.js.map +1 -1
- package/dist/store/operations.js +1 -2
- package/dist/store/operations.js.map +1 -1
- package/dist/utils/ensure-default-tenant.d.ts +2 -1
- package/dist/utils/ensure-default-tenant.js +13 -1
- package/dist/utils/ensure-default-tenant.js.map +1 -1
- package/dist/utils/export-jobs.js +2 -1
- package/dist/utils/export-jobs.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/build/composables/renderNow.ts +1 -2
- package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +3 -0
- package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -5
- package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +61 -26
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +5 -37
- package/frontend-vue/layers/dms-core/app/utils/menu.ts +66 -70
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
- package/frontend-vue/layers/dms-core/shared/types/app-config.ts +0 -2
- package/frontend-vue/layers/dms-layout/app/app.config.ts +0 -1
- package/frontend-vue/layers/dms-layout/app/assets/css/main.css +11 -1
- package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Italic-Variable.woff2 +0 -0
- package/frontend-vue/layers/dms-layout/app/assets/fonts/Geist-Variable.woff2 +0 -0
- package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Italic-Variable.woff2 +0 -0
- package/frontend-vue/layers/dms-layout/app/assets/fonts/GeistMono-Variable.woff2 +0 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +2 -7
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +3 -11
- package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +2 -106
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +0 -8
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +0 -10
- package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +2 -2
- package/frontend-vue/layers/dms-layout/app/components/navigation/NavigationMenu.vue +18 -3
- package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +24 -4
- package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +10 -2
- package/frontend-vue/layers/dms-layout/app/composables/page/usePageRecordDeletion.ts +162 -0
- package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -7
- package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +2 -7
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -0
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-navigation.client.ts +9 -9
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-personal-pages.client.ts +9 -9
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-quick-actions.client.ts +7 -7
- package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +11 -10
- package/frontend-vue/layers/dms-layout/app/theme/actions.ts +5 -4
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +6 -4
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +6 -4
- package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +11 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +6 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/numberSuffix.ts +2 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +26 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationRows.ts +22 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +11 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formValidation.ts +36 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/form/repeaterTable.ts +17 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/table/useRelationFilterLabels.ts +30 -6
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +13 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +21 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +67 -29
- package/frontend-vue/layers/dms-ui/app/build/utils/htmlGrammar.ts +79 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/utils/streamHighlight.ts +90 -0
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +23 -12
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +53 -22
- package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +27 -6
- package/frontend-vue/layers/dms-ui/app/components/form/components/RepeaterTable.vue +14 -7
- package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +74 -25
- package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +3 -9
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +16 -1
- package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +12 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +47 -7
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +19 -9
- package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +94 -3
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -1
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -1
- package/frontend-vue/package.json +6 -2
- package/package.json +8 -11
- package/skills/dms-module/SKILL.md +1 -1
- package/skills/dms-pages/REFERENCE.md +7 -3
- package/dist/hooks/tenant-instances.d.ts +0 -1
- package/dist/hooks/tenant-instances.js +0 -13
- package/dist/hooks/tenant-instances.js.map +0 -1
- package/frontend-vue/layers/dms-core/app/composables/hydration/isHydratingComponent.ts +0 -29
- package/frontend-vue/layers/dms-layout/app/composables/layout/usePageRecord.ts +0 -50
- 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 {
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
);
|
|
394
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
?
|
|
47
|
-
|
|
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
|
|
55
|
-
|
|
76
|
+
const pathRows = computed(() => [
|
|
77
|
+
...reads.value.joinedRows,
|
|
78
|
+
...(data.value?.rows ?? []),
|
|
79
|
+
]);
|
|
56
80
|
|
|
57
|
-
const
|
|
58
|
-
|
|
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(
|
|
64
|
-
.filter((
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
|
616
|
-
(
|
|
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
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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(() =>
|
|
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 =
|
|
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",
|