@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
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { SelectItem } from "@nuxt/ui";
|
|
3
|
-
import {
|
|
4
|
-
GITHUB_REPOSITORY,
|
|
5
|
-
GITHUB_REPOSITORY_URL,
|
|
6
|
-
} from "../../utils/github-repository";
|
|
7
3
|
|
|
8
4
|
const EXTERNAL_LINK_PATTERN = /^https?:\/\//;
|
|
9
5
|
const DEFAULT_COPYRIGHT = "Antelope";
|
|
10
|
-
const GITHUB_REPOSITORY_API = `https://api.github.com/repos/${GITHUB_REPOSITORY}`;
|
|
11
|
-
const GITHUB_STARS_CACHE_KEY = "dms-github-stars";
|
|
12
|
-
// GitHub allows 60 anonymous API calls an hour per visitor: the count is
|
|
13
|
-
// cached for the session and refreshed hourly.
|
|
14
|
-
const GITHUB_STARS_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
15
6
|
|
|
16
7
|
const { locale, setLocale, t } = useI18n();
|
|
17
8
|
const { loggedIn: isLoggedIn } = useDmsUserSession();
|
|
@@ -25,7 +16,6 @@ const copyright = computed(() =>
|
|
|
25
16
|
? null
|
|
26
17
|
: processI18n(footer.value.copyright ?? DEFAULT_COPYRIGHT),
|
|
27
18
|
);
|
|
28
|
-
const showsGithubStar = computed(() => footer.value.githubStar !== false);
|
|
29
19
|
const showsLocaleSwitcher = computed(
|
|
30
20
|
() => footer.value.localeSwitcher !== false,
|
|
31
21
|
);
|
|
@@ -39,71 +29,6 @@ function isExternal(to: string): boolean {
|
|
|
39
29
|
return EXTERNAL_LINK_PATTERN.test(to);
|
|
40
30
|
}
|
|
41
31
|
|
|
42
|
-
interface CachedStars {
|
|
43
|
-
count: number;
|
|
44
|
-
fetchedAt: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const githubStars = ref<number | null>(null);
|
|
48
|
-
// The count is fetched after mount: a placeholder holds its place until then,
|
|
49
|
-
// and the segment only goes away if no count could be read.
|
|
50
|
-
const githubStarsSettled = ref(false);
|
|
51
|
-
const formattedGithubStars = computed(() =>
|
|
52
|
-
githubStars.value === null
|
|
53
|
-
? ""
|
|
54
|
-
: formatNumber(githubStars.value, locale.value, { notation: "compact" }),
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
function readCachedStars(): CachedStars | null {
|
|
58
|
-
try {
|
|
59
|
-
const raw = sessionStorage.getItem(GITHUB_STARS_CACHE_KEY);
|
|
60
|
-
return raw ? (JSON.parse(raw) as CachedStars) : null;
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function writeCachedStars(count: number): void {
|
|
67
|
-
try {
|
|
68
|
-
sessionStorage.setItem(
|
|
69
|
-
GITHUB_STARS_CACHE_KEY,
|
|
70
|
-
JSON.stringify({ count, fetchedAt: Date.now() } satisfies CachedStars),
|
|
71
|
-
);
|
|
72
|
-
} catch {
|
|
73
|
-
/* storage unavailable: the count is fetched again next time */
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Starring itself happens on GitHub (it needs the visitor's GitHub session);
|
|
78
|
-
// the button shows the live count and opens the repository.
|
|
79
|
-
async function loadGithubStars(): Promise<void> {
|
|
80
|
-
const cached = readCachedStars();
|
|
81
|
-
if (cached && Date.now() - cached.fetchedAt < GITHUB_STARS_CACHE_TTL_MS) {
|
|
82
|
-
githubStars.value = cached.count;
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
try {
|
|
86
|
-
const response = await fetch(GITHUB_REPOSITORY_API, {
|
|
87
|
-
headers: { Accept: "application/vnd.github+json" },
|
|
88
|
-
});
|
|
89
|
-
if (!response.ok) return;
|
|
90
|
-
const { stargazers_count: count } = (await response.json()) as {
|
|
91
|
-
stargazers_count?: unknown;
|
|
92
|
-
};
|
|
93
|
-
if (typeof count !== "number") return;
|
|
94
|
-
githubStars.value = count;
|
|
95
|
-
writeCachedStars(count);
|
|
96
|
-
} catch {
|
|
97
|
-
/* offline or rate-limited: the button shows without a count */
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
onMounted(async () => {
|
|
102
|
-
if (!showsGithubStar.value) return;
|
|
103
|
-
await loadGithubStars();
|
|
104
|
-
githubStarsSettled.value = true;
|
|
105
|
-
});
|
|
106
|
-
|
|
107
32
|
// A signed-in user on a page of this layout (the account switcher) switches
|
|
108
33
|
// their profile's language too: the session applies that one on every load,
|
|
109
34
|
// and the server writes in it, so switching the screen alone would leave the
|
|
@@ -119,8 +44,8 @@ async function onLocaleChange(code: unknown) {
|
|
|
119
44
|
|
|
120
45
|
<template>
|
|
121
46
|
<!-- v2 .au-foot: one quiet 12.5px row, the locale switcher pushed right.
|
|
122
|
-
On phones the links get a taller invisible hit area
|
|
123
|
-
|
|
47
|
+
On phones the links get a taller invisible hit area, so each stays
|
|
48
|
+
easy to tap. -->
|
|
124
49
|
<footer
|
|
125
50
|
class="border-default text-dimmed flex flex-wrap items-center gap-x-[18px] gap-y-1.5 border-t px-5 py-3 text-[12.5px] max-sm:justify-center"
|
|
126
51
|
>
|
|
@@ -144,35 +69,6 @@ async function onLocaleChange(code: unknown) {
|
|
|
144
69
|
</DmsLink>
|
|
145
70
|
</template>
|
|
146
71
|
|
|
147
|
-
<!-- GitHub star button: icon + "Star" | live count, opens the repository. -->
|
|
148
|
-
<a
|
|
149
|
-
v-if="showsGithubStar"
|
|
150
|
-
:href="GITHUB_REPOSITORY_URL"
|
|
151
|
-
target="_blank"
|
|
152
|
-
rel="noopener noreferrer"
|
|
153
|
-
:aria-label="t('empty_layout.footer.github_star_label')"
|
|
154
|
-
:title="t('empty_layout.footer.github_star_label')"
|
|
155
|
-
class="border-default text-muted hover:text-highlighted hover:border-accented hover:bg-elevated inline-flex h-6 items-stretch overflow-hidden rounded-md border transition-colors max-sm:h-8"
|
|
156
|
-
>
|
|
157
|
-
<span class="flex items-center gap-1.5 px-2 font-medium">
|
|
158
|
-
<UIcon name="i-ph-github-logo" class="size-3.5" :aria-hidden="true" />
|
|
159
|
-
<UIcon name="i-ph-star" class="size-3" :aria-hidden="true" />
|
|
160
|
-
{{ t("empty_layout.footer.github_star") }}
|
|
161
|
-
</span>
|
|
162
|
-
<span
|
|
163
|
-
v-if="githubStars !== null || !githubStarsSettled"
|
|
164
|
-
class="border-default flex items-center border-s px-2 font-mono tabular-nums"
|
|
165
|
-
>
|
|
166
|
-
<USkeleton
|
|
167
|
-
:aria-label="t('dms.a11y.loading')"
|
|
168
|
-
v-if="githubStars === null"
|
|
169
|
-
aria-hidden="true"
|
|
170
|
-
class="h-2.5 w-6"
|
|
171
|
-
/>
|
|
172
|
-
<template v-else>{{ formattedGithubStars }}</template>
|
|
173
|
-
</span>
|
|
174
|
-
</a>
|
|
175
|
-
|
|
176
72
|
<USelect
|
|
177
73
|
v-if="showsLocaleSwitcher"
|
|
178
74
|
:model-value="locale"
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
findServedQuickAction,
|
|
25
25
|
} from "#dms-ui/app/build/utils/dispatchQuickActionTarget";
|
|
26
26
|
import { DEFAULT_PAGE_HEADER_ACTIONS_ORDER } from "../../../composables/layout/usePageHeaderActions";
|
|
27
|
-
import { type PageRecord, withPageRecord } from "../../../utils/pageRecord";
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
29
|
* A button of the page header as the server serves it (`DefaultLayout({
|
|
@@ -44,8 +43,6 @@ export interface LayoutHeaderAction
|
|
|
44
43
|
buttonId?: string;
|
|
45
44
|
/** Place among the header's buttons, smallest first; `0` by default. */
|
|
46
45
|
order?: number;
|
|
47
|
-
/** Acts on the page's record: disabled while it loads, gone without it. */
|
|
48
|
-
requiresRecord?: boolean;
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
interface PageHeaderActionBarProps {
|
|
@@ -55,8 +52,6 @@ interface PageHeaderActionBarProps {
|
|
|
55
52
|
* among the buttons: after every button of an order up to this one.
|
|
56
53
|
*/
|
|
57
54
|
slotOrder?: number;
|
|
58
|
-
/** The record the page shows, for the buttons acting on it. */
|
|
59
|
-
record?: PageRecord;
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
interface ResolvedHeaderAction {
|
|
@@ -71,7 +66,6 @@ interface ResolvedHeaderAction {
|
|
|
71
66
|
|
|
72
67
|
const props = withDefaults(defineProps<PageHeaderActionBarProps>(), {
|
|
73
68
|
slotOrder: DEFAULT_PAGE_HEADER_ACTIONS_ORDER,
|
|
74
|
-
record: undefined,
|
|
75
69
|
});
|
|
76
70
|
|
|
77
71
|
const { processI18n } = useTranslation();
|
|
@@ -279,8 +273,6 @@ function lockForPreview(action: ResolvedHeaderAction): ResolvedHeaderAction {
|
|
|
279
273
|
|
|
280
274
|
const resolved = computed(() =>
|
|
281
275
|
props.actions
|
|
282
|
-
.map((action) => withPageRecord(action, props.record))
|
|
283
|
-
.filter((action): action is LayoutHeaderAction => action !== null)
|
|
284
276
|
.map(resolveAction)
|
|
285
277
|
.filter((action): action is ResolvedHeaderAction => action !== null)
|
|
286
278
|
.map(lockForPreview)
|
package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue
CHANGED
|
@@ -5,10 +5,6 @@ import PageHeaderActionBar, {
|
|
|
5
5
|
} from "./PageHeaderActionBar.vue";
|
|
6
6
|
import { injectPageHeaderActionsHost } from "../../../composables/layout/usePageHeaderActions";
|
|
7
7
|
import { useServedHeaderActions } from "../../../composables/layout/useServedHeaderActions";
|
|
8
|
-
import {
|
|
9
|
-
type PageRecordSource,
|
|
10
|
-
usePageRecord,
|
|
11
|
-
} from "../../../composables/layout/usePageRecord";
|
|
12
8
|
|
|
13
9
|
interface PageHeaderActionsOutletProps {
|
|
14
10
|
/** Header buttons declared by the backend page (`headerActions`). */
|
|
@@ -17,18 +13,13 @@ interface PageHeaderActionsOutletProps {
|
|
|
17
13
|
servedQuery?: Record<string, string>;
|
|
18
14
|
/** The route parameters the server resolved their availability for. */
|
|
19
15
|
servedParams?: Record<string, string>;
|
|
20
|
-
/** The record the page shows (`record` of the layout). */
|
|
21
|
-
record?: PageRecordSource;
|
|
22
16
|
}
|
|
23
17
|
|
|
24
18
|
const props = withDefaults(defineProps<PageHeaderActionsOutletProps>(), {
|
|
25
19
|
servedQuery: undefined,
|
|
26
20
|
servedParams: undefined,
|
|
27
|
-
record: undefined,
|
|
28
21
|
});
|
|
29
22
|
|
|
30
|
-
const pageRecord = usePageRecord(() => props.record);
|
|
31
|
-
|
|
32
23
|
const actions = useServedHeaderActions({
|
|
33
24
|
actions: () => props.actions,
|
|
34
25
|
servedQuery: () => props.servedQuery,
|
|
@@ -82,7 +73,6 @@ const showsServerMarkup = computed(
|
|
|
82
73
|
<PageHeaderActionBar
|
|
83
74
|
:actions="actions"
|
|
84
75
|
:slot-order="host?.actionsOrder.value"
|
|
85
|
-
:record="pageRecord"
|
|
86
76
|
>
|
|
87
77
|
<component :is="pageActions" v-if="pageActions" />
|
|
88
78
|
</PageHeaderActionBar>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The DMS repository on GitHub, as `owner/name`. */
|
|
2
|
-
|
|
2
|
+
const GITHUB_REPOSITORY = "AntelopeJS/dms";
|
|
3
3
|
|
|
4
|
-
/** Opened by the
|
|
4
|
+
/** Opened by the dashboard's star prompt. */
|
|
5
5
|
export const GITHUB_REPOSITORY_URL = `https://github.com/${GITHUB_REPOSITORY}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { AvatarProps, NavigationMenuItem } from "@nuxt/ui";
|
|
3
|
+
import { MENU_STATUS_CHIP_COLORS } from "#dms-core/app/utils/menu";
|
|
3
4
|
import { useNavBadges } from "#dms-ui/app/build/composables/navigation/useNavBadges";
|
|
4
5
|
import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePermissionPreview";
|
|
5
6
|
import { PREVIEW_LOCK_ICON } from "#dms-ui/app/build/utils/permissionPreview";
|
|
@@ -191,9 +192,6 @@ function badgeOf(item: DmsMenuItem): DmsMenuItem["badge"] {
|
|
|
191
192
|
return live.label ? resolveMenuBadge(live.label, live.tone) : undefined;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
// A status dot lands in the trailing slot, which a parent entry already uses for
|
|
195
|
-
// its accordion chevron — so it is rendered on leaf entries only, and a badge
|
|
196
|
-
// takes precedence over it.
|
|
197
195
|
function decorateMenuItem(item: DmsMenuItem): DmsMenuItem {
|
|
198
196
|
const variant = VARIANT_CLASSES[item.variant ?? "default"];
|
|
199
197
|
const hasChildren = (item.children?.length ?? 0) > 0;
|
|
@@ -298,6 +296,23 @@ function onStateChange(id: string, open: boolean): void {
|
|
|
298
296
|
})
|
|
299
297
|
"
|
|
300
298
|
/>
|
|
299
|
+
<UChip
|
|
300
|
+
v-else-if="item.icon && item.status && item.children?.length"
|
|
301
|
+
:color="MENU_STATUS_CHIP_COLORS[item.status as MenuItemStatus]"
|
|
302
|
+
inset
|
|
303
|
+
size="sm"
|
|
304
|
+
>
|
|
305
|
+
<UIcon
|
|
306
|
+
:name="item.icon"
|
|
307
|
+
:class="
|
|
308
|
+
ui.linkLeadingIcon({
|
|
309
|
+
class: item.ui?.linkLeadingIcon,
|
|
310
|
+
active,
|
|
311
|
+
disabled: !!item.disabled,
|
|
312
|
+
})
|
|
313
|
+
"
|
|
314
|
+
/>
|
|
315
|
+
</UChip>
|
|
301
316
|
<UIcon
|
|
302
317
|
v-else-if="item.icon"
|
|
303
318
|
:name="item.icon"
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onScopeDispose,
|
|
4
|
+
shallowRef,
|
|
5
|
+
watch,
|
|
6
|
+
type ComputedRef,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import { onPageBlocksRefresh } from "#dms-ui/app/utils/blockRefresh";
|
|
2
9
|
import type { LayoutHeaderAction } from "../../build/components/layout/PageHeaderActionBar.vue";
|
|
3
10
|
import type { PageLayout } from "../../types/page";
|
|
4
11
|
import {
|
|
@@ -13,8 +20,8 @@ interface ServedHeaderActionsOptions {
|
|
|
13
20
|
/** The buttons the page layout was served with. */
|
|
14
21
|
actions: () => LayoutHeaderAction[];
|
|
15
22
|
/**
|
|
16
|
-
* The page query
|
|
17
|
-
*
|
|
23
|
+
* The page query they were served for, set when one of them declares an
|
|
24
|
+
* `availability` or the page reads a `record` (`headerActionsQuery`).
|
|
18
25
|
*/
|
|
19
26
|
servedQuery: () => Record<string, string> | undefined;
|
|
20
27
|
/** The route parameters (`:id`) their availability was resolved for. */
|
|
@@ -29,7 +36,10 @@ interface ServedHeaderActionsOptions {
|
|
|
29
36
|
* for that query and takes its buttons, keeping the last ones meanwhile. The
|
|
30
37
|
* same goes for the route parameters: the layout of `/projects/project/:id`
|
|
31
38
|
* is cached once for the page, whichever record it was first served for.
|
|
32
|
-
*
|
|
39
|
+
* A write on the page (`refreshPageBlocks()`, after a header button or a
|
|
40
|
+
* form) asks again too: it may have changed the record the buttons read, or
|
|
41
|
+
* what their availability checks. Buttons without an availability, on a page
|
|
42
|
+
* reading no record, never cause a request.
|
|
33
43
|
*/
|
|
34
44
|
export function useServedHeaderActions({
|
|
35
45
|
actions,
|
|
@@ -81,12 +91,22 @@ export function useServedHeaderActions({
|
|
|
81
91
|
void askFor(fillRouteParams(layoutUrl, params), current);
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
function askAgain() {
|
|
95
|
+
const layoutUrl = pageRoute()?.metadata?.layoutUrl;
|
|
96
|
+
if (!servedQuery() || !layoutUrl) return;
|
|
97
|
+
void askFor(
|
|
98
|
+
fillRouteParams(layoutUrl, pageRoute()?.params ?? {}),
|
|
99
|
+
pageQueryValues(route.query as Record<string, unknown>),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
if (!import.meta.env.SSR) {
|
|
85
104
|
watch(
|
|
86
105
|
[() => route.query, () => route.path, servedQuery, servedParams],
|
|
87
106
|
follow,
|
|
88
107
|
{ immediate: true },
|
|
89
108
|
);
|
|
109
|
+
onScopeDispose(onPageBlocksRefresh(askAgain));
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
return computed(() => refreshed.value ?? actions());
|
|
@@ -69,16 +69,22 @@ export const useModuleStore = () => {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
let isStopped = false;
|
|
72
|
+
let stopWaiting: (() => void) | undefined;
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Starts following once mounted: reads again only when the first read
|
|
75
76
|
* failed, or something moves; a listing already on screen stays as is.
|
|
76
|
-
* A
|
|
77
|
+
* A read still in flight (a client navigation, or the background read of a
|
|
78
|
+
* return to the page) is waited for until it settles.
|
|
77
79
|
*/
|
|
78
80
|
function start(): void {
|
|
79
81
|
isStopped = false;
|
|
82
|
+
stopWaiting?.();
|
|
80
83
|
if (initial.pending.value) {
|
|
81
|
-
|
|
84
|
+
stopWaiting = watch(initial.pending, (pending) => {
|
|
85
|
+
if (pending) return;
|
|
86
|
+
stopWaiting?.();
|
|
87
|
+
stopWaiting = undefined;
|
|
82
88
|
if (!isStopped) start();
|
|
83
89
|
});
|
|
84
90
|
return;
|
|
@@ -139,6 +145,8 @@ export const useModuleStore = () => {
|
|
|
139
145
|
|
|
140
146
|
function stop(): void {
|
|
141
147
|
isStopped = true;
|
|
148
|
+
stopWaiting?.();
|
|
149
|
+
stopWaiting = undefined;
|
|
142
150
|
if (followTimer) clearTimeout(followTimer);
|
|
143
151
|
followTimer = undefined;
|
|
144
152
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { onScopeDispose, type Ref, watch } from "vue";
|
|
2
|
+
import type { RealtimeTopicHandler } from "#dms-core/app/composables/realtime/useUserRealtime";
|
|
3
|
+
import type { PageLayout } from "../../types/page";
|
|
4
|
+
import {
|
|
5
|
+
fillRouteParams,
|
|
6
|
+
isSamePageQuery,
|
|
7
|
+
isSameRouteParams,
|
|
8
|
+
isViewStateKey,
|
|
9
|
+
layoutUrlWithQuery,
|
|
10
|
+
pageQueryValues,
|
|
11
|
+
} from "../../utils/pageQuery";
|
|
12
|
+
|
|
13
|
+
// One frame: the events of one burst (a bulk delete) read the record once.
|
|
14
|
+
const BURST_WINDOW_MS = 16;
|
|
15
|
+
|
|
16
|
+
/** How the server read the record a page shows (`DefaultLayout({ record })`). */
|
|
17
|
+
type PageRecordStatus = "found" | "missing" | "error";
|
|
18
|
+
|
|
19
|
+
interface RecordLayoutOptions {
|
|
20
|
+
recordStatus?: PageRecordStatus;
|
|
21
|
+
headerActionsQuery?: Record<string, string>;
|
|
22
|
+
headerActionsParams?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the page's record deletion listens to. */
|
|
26
|
+
export interface PageRecordDeletionSource {
|
|
27
|
+
/** The page's layout as it was first served. */
|
|
28
|
+
pageLayout: () => PageLayout | null;
|
|
29
|
+
/** The page's realtime topics, once its session is subscribed to it. */
|
|
30
|
+
pageTopics: Ref<string[]>;
|
|
31
|
+
subscribe(topic: string, handler: RealtimeTopicHandler): () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const optionsOf = (layout: PageLayout | null | undefined) =>
|
|
35
|
+
layout?.layout.options as RecordLayoutOptions | undefined;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Leaves a detail page whose record was deleted, in every tab and for every
|
|
39
|
+
* member on it. The layout of a page reading a record (`DefaultLayout({
|
|
40
|
+
* record })`) says how the server read it (`recordStatus`); on each event of
|
|
41
|
+
* one of the page's realtime topics, the page asks its layout again, and a
|
|
42
|
+
* record it had `found` that is now `missing` was deleted: a toast says so,
|
|
43
|
+
* and the page opens its list (`MenuOptions.parent`), or stays on its
|
|
44
|
+
* not-found blocks without one. A page reading no record never asks.
|
|
45
|
+
*/
|
|
46
|
+
export function usePageRecordDeletion(source: PageRecordDeletionSource): void {
|
|
47
|
+
if (import.meta.env.SSR) return;
|
|
48
|
+
const route = useDmsRoute();
|
|
49
|
+
const siteLayout = useSiteLayout();
|
|
50
|
+
const toast = useToast();
|
|
51
|
+
const { t } = useI18n();
|
|
52
|
+
|
|
53
|
+
const pageRoute = () => siteLayout.findMatchingRoute(route.path);
|
|
54
|
+
const currentQuery = () =>
|
|
55
|
+
pageQueryValues(route.query as Record<string, unknown>);
|
|
56
|
+
/** The page URL the record is named by, leaving out the view's state. */
|
|
57
|
+
const recordKey = () =>
|
|
58
|
+
JSON.stringify([
|
|
59
|
+
route.path,
|
|
60
|
+
Object.entries(currentQuery()).filter(([name]) => !isViewStateKey(name)),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
let known: { key: string; status: PageRecordStatus } | undefined;
|
|
64
|
+
let latestRead = 0;
|
|
65
|
+
|
|
66
|
+
async function readStatus(): Promise<PageRecordStatus | undefined> {
|
|
67
|
+
const match = pageRoute();
|
|
68
|
+
const layoutUrl = match?.metadata?.layoutUrl;
|
|
69
|
+
if (!layoutUrl) return undefined;
|
|
70
|
+
const { $authFetch } = useAuthFetch();
|
|
71
|
+
try {
|
|
72
|
+
const layout = await $authFetch<PageLayout>(
|
|
73
|
+
layoutUrlWithQuery(
|
|
74
|
+
fillRouteParams(layoutUrl, match.params ?? {}),
|
|
75
|
+
currentQuery(),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
return optionsOf(layout)?.recordStatus;
|
|
79
|
+
} catch {
|
|
80
|
+
// Unknown: the record is not taken for deleted on a failed read.
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function leave(): Promise<void> {
|
|
86
|
+
toast.add({
|
|
87
|
+
title: t("page.record_deleted.title"),
|
|
88
|
+
description: t("page.record_deleted.description"),
|
|
89
|
+
color: "warning",
|
|
90
|
+
icon: "i-ph-trash",
|
|
91
|
+
});
|
|
92
|
+
const match = pageRoute();
|
|
93
|
+
const parentSlug = match?.metadata?.parentSlug;
|
|
94
|
+
if (!parentSlug) return;
|
|
95
|
+
const path = fillRouteParams(parentSlug, match.params ?? {});
|
|
96
|
+
if (path.includes(":")) return;
|
|
97
|
+
const parent = siteLayout.findMatchingRoute(path)?.metadata;
|
|
98
|
+
if (!parent || parent.hasAccess === false) return;
|
|
99
|
+
await navigateDms(path, { replace: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function check(): Promise<void> {
|
|
103
|
+
const key = recordKey();
|
|
104
|
+
const read = ++latestRead;
|
|
105
|
+
const status = await readStatus();
|
|
106
|
+
if (read !== latestRead || key !== recordKey() || !status) return;
|
|
107
|
+
const previous = known?.key === key ? known.status : undefined;
|
|
108
|
+
known = { key, status };
|
|
109
|
+
if (previous === "found" && status === "missing") await leave();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const declaresRecord = () =>
|
|
113
|
+
optionsOf(source.pageLayout())?.recordStatus !== undefined;
|
|
114
|
+
|
|
115
|
+
// What the page was served with stands for its URL when it was served for
|
|
116
|
+
// it; another record of the same page is read once to know where it stands.
|
|
117
|
+
function follow(): void {
|
|
118
|
+
if (known?.key === recordKey()) return;
|
|
119
|
+
latestRead++;
|
|
120
|
+
known = undefined;
|
|
121
|
+
const options = optionsOf(source.pageLayout());
|
|
122
|
+
if (!options?.recordStatus) return;
|
|
123
|
+
const served =
|
|
124
|
+
isSamePageQuery(options.headerActionsQuery ?? {}, currentQuery()) &&
|
|
125
|
+
isSameRouteParams(
|
|
126
|
+
options.headerActionsParams ?? {},
|
|
127
|
+
pageRoute()?.params ?? {},
|
|
128
|
+
);
|
|
129
|
+
if (served) known = { key: recordKey(), status: options.recordStatus };
|
|
130
|
+
else void check();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
// Presence snapshots ride the same topics: they carry no `type`.
|
|
135
|
+
const onEvent: RealtimeTopicHandler = (event) => {
|
|
136
|
+
if (!("type" in event) || !declaresRecord()) return;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
timer = setTimeout(() => void check(), BURST_WINDOW_MS);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let unsubscribes: Array<() => void> = [];
|
|
142
|
+
const unsubscribeAll = () => {
|
|
143
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
144
|
+
unsubscribes = [];
|
|
145
|
+
};
|
|
146
|
+
watch(
|
|
147
|
+
source.pageTopics,
|
|
148
|
+
(topics) => {
|
|
149
|
+
unsubscribeAll();
|
|
150
|
+
unsubscribes = topics.map((topic) => source.subscribe(topic, onEvent));
|
|
151
|
+
},
|
|
152
|
+
{ immediate: true },
|
|
153
|
+
);
|
|
154
|
+
watch([() => route.path, () => route.query, source.pageLayout], follow, {
|
|
155
|
+
immediate: true,
|
|
156
|
+
});
|
|
157
|
+
onScopeDispose(() => {
|
|
158
|
+
unsubscribeAll();
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
latestRead++;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import DashboardFrame from "../build/components/layout/DashboardFrame.vue";
|
|
3
3
|
import DashboardPageBody from "../build/components/layout/DashboardPageBody.vue";
|
|
4
4
|
import type { LayoutHeaderAction } from "../build/components/layout/PageHeaderActionBar.vue";
|
|
5
|
-
import type { PageRecordSource } from "../composables/layout/usePageRecord";
|
|
6
5
|
|
|
7
6
|
interface Props {
|
|
8
7
|
fullWidth?: boolean;
|
|
@@ -14,14 +13,12 @@ interface Props {
|
|
|
14
13
|
/** Header buttons declared by the backend page (`headerActions`). */
|
|
15
14
|
headerActions?: LayoutHeaderAction[];
|
|
16
15
|
/**
|
|
17
|
-
* The page query the server
|
|
18
|
-
*
|
|
16
|
+
* The page query the server served the buttons for, set when one declares
|
|
17
|
+
* an `availability` or the page reads a `record` (`headerActionsQuery`).
|
|
19
18
|
*/
|
|
20
19
|
headerActionsQuery?: Record<string, string>;
|
|
21
20
|
/** The route parameters (`:id`) they were resolved for, likewise. */
|
|
22
21
|
headerActionsParams?: Record<string, string>;
|
|
23
|
-
/** The record the page shows, which header buttons may act on (`record`). */
|
|
24
|
-
record?: PageRecordSource;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -34,7 +31,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
34
31
|
headerActions: () => [],
|
|
35
32
|
headerActionsQuery: undefined,
|
|
36
33
|
headerActionsParams: undefined,
|
|
37
|
-
record: undefined,
|
|
38
34
|
});
|
|
39
35
|
</script>
|
|
40
36
|
|
|
@@ -48,7 +44,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
48
44
|
:header-actions="props.headerActions"
|
|
49
45
|
:header-actions-query="props.headerActionsQuery"
|
|
50
46
|
:header-actions-params="props.headerActionsParams"
|
|
51
|
-
:record="props.record"
|
|
52
47
|
>
|
|
53
48
|
<slot />
|
|
54
49
|
</DashboardPageBody>
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import DashboardFrame from "../build/components/layout/DashboardFrame.vue";
|
|
3
3
|
import DashboardPageBody from "../build/components/layout/DashboardPageBody.vue";
|
|
4
4
|
import type { LayoutHeaderAction } from "../build/components/layout/PageHeaderActionBar.vue";
|
|
5
|
-
import type { PageRecordSource } from "../composables/layout/usePageRecord";
|
|
6
5
|
import SettingsShell from "../build/components/pages/settings/shell/SettingsShell.vue";
|
|
7
6
|
|
|
8
7
|
interface Props {
|
|
@@ -14,14 +13,12 @@ interface Props {
|
|
|
14
13
|
/** Header buttons declared by the backend page (`headerActions`). */
|
|
15
14
|
headerActions?: LayoutHeaderAction[];
|
|
16
15
|
/**
|
|
17
|
-
* The page query the server
|
|
18
|
-
*
|
|
16
|
+
* The page query the server served the buttons for, set when one declares
|
|
17
|
+
* an `availability` or the page reads a `record` (`headerActionsQuery`).
|
|
19
18
|
*/
|
|
20
19
|
headerActionsQuery?: Record<string, string>;
|
|
21
20
|
/** The route parameters (`:id`) they were resolved for, likewise. */
|
|
22
21
|
headerActionsParams?: Record<string, string>;
|
|
23
|
-
/** The record the page shows, which header buttons may act on (`record`). */
|
|
24
|
-
record?: PageRecordSource;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -33,7 +30,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
33
30
|
headerActions: () => [],
|
|
34
31
|
headerActionsQuery: undefined,
|
|
35
32
|
headerActionsParams: undefined,
|
|
36
|
-
record: undefined,
|
|
37
33
|
});
|
|
38
34
|
</script>
|
|
39
35
|
|
|
@@ -48,7 +44,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
48
44
|
:header-actions="props.headerActions"
|
|
49
45
|
:header-actions-query="props.headerActionsQuery"
|
|
50
46
|
:header-actions-params="props.headerActionsParams"
|
|
51
|
-
:record="props.record"
|
|
52
47
|
header-class="pb-5"
|
|
53
48
|
>
|
|
54
49
|
<slot />
|
|
@@ -4,6 +4,7 @@ import { holdPageHeaderActions } from "../composables/layout/usePageHeaderAction
|
|
|
4
4
|
import { useDevReloading } from "../build/composables/dev-reload/useDevReloadHolder";
|
|
5
5
|
import { usePageLiveHost } from "#dms-ui/app/build/composables/live/usePageLiveHost";
|
|
6
6
|
import { usePageDataRefresh } from "#dms-ui/app/build/composables/live/usePageDataRefresh";
|
|
7
|
+
import { usePageRecordDeletion } from "../composables/page/usePageRecordDeletion";
|
|
7
8
|
import { pageValidationRedirect } from "../build/utils/pageValidationRedirect";
|
|
8
9
|
import { resolveComponentChildren } from "#dms-ui/app/utils/componentTree";
|
|
9
10
|
|
|
@@ -251,6 +252,12 @@ usePageLiveHost(
|
|
|
251
252
|
);
|
|
252
253
|
// The page's `useApiData` reads follow its refreshes and its realtime events.
|
|
253
254
|
usePageDataRefresh(realtime);
|
|
255
|
+
// A detail page leaves for its list once its record is deleted.
|
|
256
|
+
usePageRecordDeletion({
|
|
257
|
+
pageLayout: () => pageLayout.value,
|
|
258
|
+
pageTopics: realtime.pageTopics,
|
|
259
|
+
subscribe: realtime.subscribe,
|
|
260
|
+
});
|
|
254
261
|
|
|
255
262
|
function buildPageSetupContext(
|
|
256
263
|
pageInfo: PageInfo,
|