@antelopejs/dms 0.7.14 → 0.7.15
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 +24 -0
- package/dist/db/models/userNotifications.model.js +3 -27
- package/dist/db/models/userNotifications.model.js.map +1 -1
- package/dist/implementations/dms/frontend-modules.d.ts +4 -2
- package/dist/implementations/dms/frontend-modules.js +4 -0
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
- package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
- package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +1 -0
- package/dist/implementations/dms/manifest.js +3 -1
- package/dist/implementations/dms/manifest.js.map +1 -1
- package/dist/test/integration/notifications.test.js +6 -3
- package/dist/test/integration/notifications.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
- package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +61 -2
- package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
- package/frontend-vue/tests/control-error-path.test.ts +11 -0
- package/frontend-vue/tests/email-layout.test.ts +124 -0
- package/frontend-vue/tests/input-number-range.test.ts +1 -1
- package/frontend-vue/tests/input-tags.test.ts +1 -1
- package/frontend-vue/tests/row-side-panel.test.ts +2 -2
- package/frontend-vue/tests/table-url-filters.test.ts +158 -0
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { emailStyles } from "./index";
|
|
2
|
+
import { emailLogoUrl, emailStyles } from "./index";
|
|
3
3
|
|
|
4
4
|
interface EmailAppConfig {
|
|
5
5
|
branding?: {
|
|
@@ -11,38 +11,61 @@ interface EmailAppConfig {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
/** Shown as the email's heading; no heading when omitted. */
|
|
17
|
+
title?: string;
|
|
18
|
+
lang?: string;
|
|
19
|
+
/** Inbox preview text, hidden in the message body. */
|
|
20
|
+
preheader?: string;
|
|
21
|
+
/** Draws the separator between the content and the `footer` slot. */
|
|
22
|
+
divider?: boolean;
|
|
23
|
+
logoAlt?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Leaves the logo out unless a mail client can load it: an absolute
|
|
26
|
+
* public http(s) URL, not an SVG (see `emailLogoUrl`).
|
|
27
|
+
*/
|
|
28
|
+
publicLogoOnly?: boolean;
|
|
29
|
+
}>(),
|
|
30
|
+
{ divider: true, logoAlt: "Logo", publicLogoOnly: false },
|
|
31
|
+
);
|
|
18
32
|
|
|
19
33
|
const appConfig = useDmsAppConfig() as EmailAppConfig;
|
|
20
34
|
const runtimeConfig = useDmsRuntimeConfig();
|
|
21
35
|
|
|
22
36
|
const logoPath = appConfig.branding?.logo?.default?.light;
|
|
23
37
|
const clientBaseUrl = runtimeConfig.public.dms?.clientBaseUrl ?? "";
|
|
24
|
-
const logoUrl =
|
|
25
|
-
?
|
|
26
|
-
: logoPath
|
|
38
|
+
const logoUrl = props.publicLogoOnly
|
|
39
|
+
? emailLogoUrl(logoPath, clientBaseUrl)
|
|
40
|
+
: logoPath?.startsWith("/")
|
|
41
|
+
? `${clientBaseUrl}${logoPath}`
|
|
42
|
+
: logoPath;
|
|
27
43
|
</script>
|
|
28
44
|
|
|
29
45
|
<template>
|
|
30
46
|
<EHtml :lang="lang ?? 'en'">
|
|
31
|
-
<EHead
|
|
47
|
+
<EHead>
|
|
48
|
+
<slot name="head" />
|
|
49
|
+
</EHead>
|
|
32
50
|
<EBody :style="emailStyles.main">
|
|
51
|
+
<EPreview v-if="preheader">{{ preheader }}</EPreview>
|
|
33
52
|
<EContainer :style="emailStyles.container">
|
|
34
53
|
<ESection :style="emailStyles.card">
|
|
35
54
|
<ESection v-if="logoUrl" :style="emailStyles.logo">
|
|
36
|
-
<EImg
|
|
55
|
+
<EImg
|
|
56
|
+
:src="logoUrl"
|
|
57
|
+
:alt="logoAlt"
|
|
58
|
+
:style="emailStyles.logoImage"
|
|
59
|
+
/>
|
|
37
60
|
</ESection>
|
|
38
61
|
|
|
39
|
-
<EHeading as="h1" :style="emailStyles.heading">
|
|
62
|
+
<EHeading v-if="title" as="h1" :style="emailStyles.heading">
|
|
40
63
|
{{ title }}
|
|
41
64
|
</EHeading>
|
|
42
65
|
|
|
43
66
|
<slot />
|
|
44
67
|
|
|
45
|
-
<EHr :style="emailStyles.divider" />
|
|
68
|
+
<EHr v-if="divider" :style="emailStyles.divider" />
|
|
46
69
|
|
|
47
70
|
<slot name="footer" />
|
|
48
71
|
</ESection>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which logo URL an email may carry. A recipient's mail client fetches the
|
|
3
|
+
* image from the internet, so it needs an absolute public http(s) URL; Gmail
|
|
4
|
+
* and Outlook also refuse SVG. A logo that fails either test is left out
|
|
5
|
+
* rather than drawn as a broken image.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ROOT_PATH = "/";
|
|
9
|
+
const WEB_PROTOCOLS = ["http:", "https:"];
|
|
10
|
+
const SVG_PATH = /\.svg(?:[?#]|$)/i;
|
|
11
|
+
const LOCAL_HOSTS = ["localhost", "0.0.0.0", "[::]", "[::1]"];
|
|
12
|
+
const LOCAL_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
13
|
+
const PRIVATE_ADDRESSES = [
|
|
14
|
+
/^127\./,
|
|
15
|
+
/^10\./,
|
|
16
|
+
/^192\.168\./,
|
|
17
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
18
|
+
/^169\.254\./,
|
|
19
|
+
/^\[f[cd][0-9a-f]{0,2}:/i,
|
|
20
|
+
/^\[fe[89ab][0-9a-f]:/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function absoluteUrl(path: string, clientBaseUrl: string): URL | null {
|
|
24
|
+
const source = path.startsWith(ROOT_PATH) ? `${clientBaseUrl}${path}` : path;
|
|
25
|
+
try {
|
|
26
|
+
return new URL(source);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a mail client out on the internet can reach `hostname`. */
|
|
33
|
+
export function isPublicEmailHost(hostname: string): boolean {
|
|
34
|
+
const name = hostname.toLowerCase();
|
|
35
|
+
if (LOCAL_HOSTS.includes(name)) return false;
|
|
36
|
+
if (LOCAL_SUFFIXES.some((suffix) => name.endsWith(suffix))) return false;
|
|
37
|
+
return !PRIVATE_ADDRESSES.some((pattern) => pattern.test(name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The logo URL an email may use: `logoPath` made absolute against
|
|
42
|
+
* `clientBaseUrl` when it starts with `/`, or `""` when it is an SVG, not
|
|
43
|
+
* http(s), or served from a host a mail client cannot reach (localhost, a
|
|
44
|
+
* private address).
|
|
45
|
+
*/
|
|
46
|
+
export function emailLogoUrl(
|
|
47
|
+
logoPath: string | undefined,
|
|
48
|
+
clientBaseUrl: string,
|
|
49
|
+
): string {
|
|
50
|
+
if (!logoPath || SVG_PATH.test(logoPath)) return "";
|
|
51
|
+
const url = absoluteUrl(logoPath, clientBaseUrl);
|
|
52
|
+
if (!url || !WEB_PROTOCOLS.includes(url.protocol)) return "";
|
|
53
|
+
return isPublicEmailHost(url.hostname) ? url.href : "";
|
|
54
|
+
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TableColumn,
|
|
10
10
|
TableFilter,
|
|
11
11
|
TableSharedData,
|
|
12
|
+
TableUrlFilter,
|
|
12
13
|
} from "./Table.vue";
|
|
13
14
|
import TableMenuFilter from "./MenuFilter.vue";
|
|
14
15
|
import { formatFilterValue } from "../../composables/table/utils/formatFilterValue";
|
|
@@ -126,6 +127,17 @@ const deleteFilter = (index: number) => {
|
|
|
126
127
|
tableSharedData?.value?.deleteFilter(index);
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
const urlFilters = computed(() => tableSharedData?.value?.urlFilters ?? []);
|
|
131
|
+
|
|
132
|
+
const urlFilterLabel = (filter: TableUrlFilter): string =>
|
|
133
|
+
(filter.label && processI18n(filter.label)) ||
|
|
134
|
+
getColumn(filter.accessorKey)?.label ||
|
|
135
|
+
filter.param;
|
|
136
|
+
|
|
137
|
+
const removeUrlFilter = (filter: TableUrlFilter) => {
|
|
138
|
+
tableSharedData?.value?.emits("removeUrlFilter", filter.param);
|
|
139
|
+
};
|
|
140
|
+
|
|
129
141
|
// The "clear all" action: offered while something narrows the rows, named
|
|
130
142
|
// after what it clears (filters, search or both).
|
|
131
143
|
const clearable = computed(
|
|
@@ -171,9 +183,9 @@ const displayValue = (filter: TableFilter): string => {
|
|
|
171
183
|
};
|
|
172
184
|
|
|
173
185
|
// The whole filter in words, for a chip shrunk to an ellipsis.
|
|
174
|
-
const chipTitle = (filter: TableFilter): string =>
|
|
186
|
+
const chipTitle = (filter: TableFilter, label?: string): string =>
|
|
175
187
|
[
|
|
176
|
-
getColumn(filter.accessorKey)?.label,
|
|
188
|
+
label ?? getColumn(filter.accessorKey)?.label,
|
|
177
189
|
t(`dms.table.compare_mode.${filter.mode}`),
|
|
178
190
|
displayValue(filter),
|
|
179
191
|
]
|
|
@@ -184,6 +196,31 @@ const chipTitle = (filter: TableFilter): string =>
|
|
|
184
196
|
<template>
|
|
185
197
|
<div :class="ui.root()">
|
|
186
198
|
<ul :class="ui.list()">
|
|
199
|
+
<li
|
|
200
|
+
v-for="filter in urlFilters"
|
|
201
|
+
:key="`url-${filter.param}`"
|
|
202
|
+
:class="ui.item()"
|
|
203
|
+
data-dms-url-filter
|
|
204
|
+
>
|
|
205
|
+
<span
|
|
206
|
+
:class="ui.chip()"
|
|
207
|
+
:title="chipTitle(filter, urlFilterLabel(filter))"
|
|
208
|
+
>
|
|
209
|
+
<span :class="ui.label()">{{ urlFilterLabel(filter) }}</span>
|
|
210
|
+
<span :class="ui.mode()">
|
|
211
|
+
{{ t(`dms.table.compare_mode.${filter.mode}`) }}
|
|
212
|
+
</span>
|
|
213
|
+
<span :class="ui.value()">{{ displayValue(filter) }}</span>
|
|
214
|
+
</span>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
:class="ui.remove()"
|
|
218
|
+
:aria-label="t('dms.button.delete')"
|
|
219
|
+
@click="removeUrlFilter(filter)"
|
|
220
|
+
>
|
|
221
|
+
<UIcon :name="appConfig.ui.icons.close" />
|
|
222
|
+
</button>
|
|
223
|
+
</li>
|
|
187
224
|
<li
|
|
188
225
|
v-for="(filter, index) in filters"
|
|
189
226
|
:key="`${filter.accessorKey}-${index}`"
|
|
@@ -247,6 +247,11 @@ export interface TableProps<T> {
|
|
|
247
247
|
* says no row matches rather than that there are none yet.
|
|
248
248
|
*/
|
|
249
249
|
hiddenFiltersApply?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* The page URL's filters declared `visible`: drawn as chips of the filters
|
|
252
|
+
* row, each removable (`removeUrlFilter`).
|
|
253
|
+
*/
|
|
254
|
+
urlFilters?: TableUrlFilter[];
|
|
250
255
|
|
|
251
256
|
data?: T[] | null;
|
|
252
257
|
columns?: TableColumn<T>[];
|
|
@@ -307,6 +312,7 @@ export interface TableEmits<T> {
|
|
|
307
312
|
(e: "details" | "edit", item: T): void;
|
|
308
313
|
(e: "delete" | "archive" | "restore" | "export", itemIds: string[]): void;
|
|
309
314
|
(e: "duplicate", itemId: string): void;
|
|
315
|
+
(e: "removeUrlFilter", param: string): void;
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
export interface TableFilter {
|
|
@@ -317,6 +323,14 @@ export interface TableFilter {
|
|
|
317
323
|
initialValue?: unknown | unknown[];
|
|
318
324
|
}
|
|
319
325
|
|
|
326
|
+
/** A filter the page URL applies and shows (see `TableProps.urlFilters`). */
|
|
327
|
+
export interface TableUrlFilter extends TableFilter {
|
|
328
|
+
/** The query parameter carrying it, removed with the chip. */
|
|
329
|
+
param: string;
|
|
330
|
+
/** The chip's name, before the column's label. */
|
|
331
|
+
label?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
320
334
|
export interface TableSharedData<T> {
|
|
321
335
|
table: Table<T>;
|
|
322
336
|
emits: TableEmits<T>;
|
|
@@ -343,6 +357,8 @@ export interface TableSharedData<T> {
|
|
|
343
357
|
resetSorting: () => void;
|
|
344
358
|
hasCustomColumns: ComputedRef<boolean>;
|
|
345
359
|
deleteFilter: (index: number) => void;
|
|
360
|
+
/** The URL filters shown as chips (see `TableProps.urlFilters`). */
|
|
361
|
+
urlFilters: TableUrlFilter[];
|
|
346
362
|
/**
|
|
347
363
|
* Clears everything that narrows the rows — filter chips (quick filters
|
|
348
364
|
* included) and the toolbar search — and goes back to the first page.
|
|
@@ -1183,6 +1199,7 @@ watchEffect(() => {
|
|
|
1183
1199
|
resetSorting,
|
|
1184
1200
|
hasCustomColumns,
|
|
1185
1201
|
deleteFilter,
|
|
1202
|
+
urlFilters: props.urlFilters ?? [],
|
|
1186
1203
|
resetFilters,
|
|
1187
1204
|
clearSearch,
|
|
1188
1205
|
clearFilters,
|
|
@@ -1348,7 +1365,7 @@ defineShortcuts({
|
|
|
1348
1365
|
|
|
1349
1366
|
<TableFiltersRow
|
|
1350
1367
|
v-if="
|
|
1351
|
-
filtersRowOpen &&
|
|
1368
|
+
(filtersRowOpen || !!urlFilters?.length) &&
|
|
1352
1369
|
resolvedCapabilities.filters &&
|
|
1353
1370
|
resolvedChrome.filters
|
|
1354
1371
|
"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InjectionKey } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Where a form collects the errors its controls report, by field id. */
|
|
4
|
+
export interface FormControlErrors {
|
|
5
|
+
report: (fieldId: string, message: string | undefined) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
|
|
9
|
+
"dms-form-control-errors",
|
|
10
|
+
);
|
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
|
|
2
|
-
import { type InjectionKey, inject, onBeforeUnmount, ref } from "vue";
|
|
3
|
-
|
|
4
|
-
/** Where a form collects the errors its controls report, by field id. */
|
|
5
|
-
export interface FormControlErrors {
|
|
6
|
-
report: (fieldId: string, message: string | undefined) => void;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
|
|
10
|
-
"dms-form-control-errors",
|
|
11
|
-
);
|
|
12
|
-
|
|
13
1
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
2
|
+
* Former home of `useControlError`, kept importable for the modules that
|
|
3
|
+
* reached it here.
|
|
4
|
+
*
|
|
5
|
+
* @deprecated Import `useControlError` from
|
|
6
|
+
* `#dms-ui/app/composables/form/useControlError` (or rely on its auto-import).
|
|
7
|
+
* This path is removed in the next breaking release.
|
|
18
8
|
*/
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
|
|
22
|
-
const message = ref<string>();
|
|
23
|
-
|
|
24
|
-
function report(next: string | undefined): void {
|
|
25
|
-
if (message.value === next) return;
|
|
26
|
-
message.value = next;
|
|
27
|
-
const name = field?.value?.name;
|
|
28
|
-
if (name) errors?.report(name, next);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
onBeforeUnmount(() => report(undefined));
|
|
32
|
-
|
|
33
|
-
return { message, report };
|
|
34
|
-
}
|
|
9
|
+
export * from "../../../composables/form/useControlError";
|
|
10
|
+
export * from "./formControlErrors";
|
|
@@ -82,12 +82,18 @@ export interface DeepLinkedRow<T> {
|
|
|
82
82
|
row?: T;
|
|
83
83
|
/** The route answered 404: the row the link names no longer exists. */
|
|
84
84
|
isMissing: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The read failed for another reason (a refusal, the network, a server
|
|
87
|
+
* error): the row may still exist, the link just could not be followed.
|
|
88
|
+
*/
|
|
89
|
+
isFailed?: boolean;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/**
|
|
88
93
|
* The row a deep link names: the listed one, else the one read by id — a row
|
|
89
|
-
* beyond the first page. A route answering 404 marks it missing
|
|
90
|
-
* failure (a refusal, the network)
|
|
94
|
+
* beyond the first page. A route answering 404 marks it missing, any other
|
|
95
|
+
* failure (a refusal, the network) marks it failed, and no route at all finds
|
|
96
|
+
* nothing.
|
|
91
97
|
*/
|
|
92
98
|
export async function resolveDeepLinkedRow<T>(
|
|
93
99
|
id: string,
|
|
@@ -99,6 +105,31 @@ export async function resolveDeepLinkedRow<T>(
|
|
|
99
105
|
const row = await source.getRow(id);
|
|
100
106
|
return { row: row ?? undefined, isMissing: false };
|
|
101
107
|
} catch (error: unknown) {
|
|
102
|
-
|
|
108
|
+
if (readFetchNotFound(error) !== null) return { isMissing: true };
|
|
109
|
+
return { isMissing: false, isFailed: true };
|
|
103
110
|
}
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What a table does with the row a deep link resolved to: open it, let a side
|
|
115
|
+
* panel show its own "no longer available" state, tell the user the link led
|
|
116
|
+
* nowhere (a drawer or a modal has no such state, and a table with no route to
|
|
117
|
+
* read one row finds nothing either), or tell them the row could not be read
|
|
118
|
+
* (a refusal, the network) without claiming it is gone.
|
|
119
|
+
*/
|
|
120
|
+
export type DeepLinkOutcome =
|
|
121
|
+
| "open"
|
|
122
|
+
| "missingPanel"
|
|
123
|
+
| "notify"
|
|
124
|
+
| "notifyFailed";
|
|
125
|
+
|
|
126
|
+
export function deepLinkOutcome(
|
|
127
|
+
found: DeepLinkedRow<unknown>,
|
|
128
|
+
action: CustomRowAction,
|
|
129
|
+
): DeepLinkOutcome {
|
|
130
|
+
if (found.row) return "open";
|
|
131
|
+
if (found.isFailed) return "notifyFailed";
|
|
132
|
+
if (found.isMissing && action.target.type === "sidePanel")
|
|
133
|
+
return "missingPanel";
|
|
134
|
+
return "notify";
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TableUrlFilter } from "../../../components/table/Table.vue";
|
|
2
|
+
import type { QueryParamFilters } from "../../../../composables/table-view/types/config";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FILTER_MODE = "is";
|
|
5
|
+
|
|
6
|
+
/** The `queryParamFilters` declared `visible` that the URL applies now. */
|
|
7
|
+
export function visibleUrlFilters(
|
|
8
|
+
filters: QueryParamFilters | undefined,
|
|
9
|
+
query: Record<string, unknown>,
|
|
10
|
+
): TableUrlFilter[] {
|
|
11
|
+
return Object.entries(filters ?? {})
|
|
12
|
+
.filter(([param, config]) => config.visible && query[param] !== undefined)
|
|
13
|
+
.map(([param, config]) => ({
|
|
14
|
+
param,
|
|
15
|
+
label: config.label,
|
|
16
|
+
accessorKey: config.field,
|
|
17
|
+
mode: config.mode || DEFAULT_FILTER_MODE,
|
|
18
|
+
value: query[param],
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The URL query without `param`, for a chip the user removed. */
|
|
23
|
+
export function withoutQueryParam<Query extends Record<string, unknown>>(
|
|
24
|
+
query: Query,
|
|
25
|
+
param: string,
|
|
26
|
+
): Query {
|
|
27
|
+
const { [param]: _removed, ...rest } = query;
|
|
28
|
+
return rest as Query;
|
|
29
|
+
}
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
resolveSectionNav,
|
|
57
57
|
sectionState,
|
|
58
58
|
} from "../../build/composables/form/formSections";
|
|
59
|
-
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/
|
|
59
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
|
|
60
60
|
import { useInstantForm } from "../../build/composables/form/useInstantForm";
|
|
61
61
|
import { useInstantSaveHeader } from "#dms-layout/app/composables/layout/useInstantSaveHeader";
|
|
62
62
|
import DmsFormEntries from "../../build/components/form/FormEntries.vue";
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@internationalized/date";
|
|
15
15
|
import { reactivePick } from "@vueuse/core";
|
|
16
16
|
import { useFormField } from "@nuxt/ui/composables/useFormField";
|
|
17
|
-
import { useControlError } from "../../../
|
|
17
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
18
18
|
import {
|
|
19
19
|
FIELD_TRIGGER_CLASS,
|
|
20
20
|
FIELD_TRIGGER_INVALID_CLASS,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getCurrentInstance, h, render } from "vue";
|
|
3
3
|
import { useFormField } from "@nuxt/ui/composables/useFormField";
|
|
4
4
|
import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
|
|
5
|
-
import { useControlError } from "../../../
|
|
5
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
6
6
|
import { codeLanguageLabel } from "../../../build/utils/codeLanguages";
|
|
7
7
|
import type {
|
|
8
8
|
CodeEditor,
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from "@nuxt/ui/components/InputNumber.vue";
|
|
7
7
|
import { computed, nextTick, watch } from "vue";
|
|
8
8
|
import { useForwardPropsEmits } from "reka-ui";
|
|
9
|
-
import { useControlError } from "../../../
|
|
9
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
10
10
|
import {
|
|
11
11
|
BELOW_MIN_MESSAGE,
|
|
12
12
|
numberRangeError,
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
formErrorsInjectionKey,
|
|
4
4
|
formFieldInjectionKey,
|
|
5
5
|
} from "@nuxt/ui/composables/useFormField";
|
|
6
|
-
import { useControlError } from "../../../
|
|
6
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
7
7
|
import {
|
|
8
8
|
addTags,
|
|
9
9
|
invalidTags,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { InputProps } from "@nuxt/ui/components/Input.vue";
|
|
3
|
-
import { useControlError } from "../../../
|
|
3
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
4
4
|
|
|
5
5
|
interface InputTimeProps extends Omit<InputProps, "modelValue" | "type"> {
|
|
6
6
|
modelValue?: number | null;
|
|
@@ -78,9 +78,12 @@ import { selectedRowIds } from "../../build/composables/table-view/utils/bulkAct
|
|
|
78
78
|
import { isBulkAction } from "../../build/composables/actions/bulkSelection";
|
|
79
79
|
import { selectionScopeKey } from "../../build/utils/selectionScope";
|
|
80
80
|
import {
|
|
81
|
+
type DeepLinkOutcome,
|
|
82
|
+
deepLinkOutcome,
|
|
81
83
|
isDeepLinkAction,
|
|
82
84
|
readRecordId,
|
|
83
85
|
resolveDeepLinkedRow,
|
|
86
|
+
writeRecordId,
|
|
84
87
|
} from "../../build/composables/table-view/utils/recordLink";
|
|
85
88
|
import {
|
|
86
89
|
rowSidePanelOwnerKey,
|
|
@@ -112,8 +115,13 @@ import {
|
|
|
112
115
|
} from "../../build/composables/table-view/useExpandedRowDetails";
|
|
113
116
|
import type {
|
|
114
117
|
TableAccumulation,
|
|
118
|
+
TableUrlFilter,
|
|
115
119
|
TableViewSwitcherItem,
|
|
116
120
|
} from "../../build/components/table/Table.vue";
|
|
121
|
+
import {
|
|
122
|
+
visibleUrlFilters,
|
|
123
|
+
withoutQueryParam,
|
|
124
|
+
} from "../../build/composables/table-view/utils/urlFilters";
|
|
117
125
|
import {
|
|
118
126
|
defineAsyncComponent,
|
|
119
127
|
useTemplateRef,
|
|
@@ -396,6 +404,15 @@ const queryParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
|
396
404
|
}));
|
|
397
405
|
});
|
|
398
406
|
|
|
407
|
+
const shownUrlFilters = computed<TableUrlFilter[]>(() =>
|
|
408
|
+
visibleUrlFilters(queryParamFilters, route.query as Record<string, unknown>),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const removeUrlFilter = (param: string) =>
|
|
412
|
+
router.replace({
|
|
413
|
+
query: withoutQueryParam(route.query, param),
|
|
414
|
+
});
|
|
415
|
+
|
|
399
416
|
const routeParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
400
417
|
if (!routeParamFilters || !routeParams) return [];
|
|
401
418
|
|
|
@@ -1686,10 +1703,50 @@ const openRecordFromUrl = async () => {
|
|
|
1686
1703
|
idOf: getRowId,
|
|
1687
1704
|
getRow: tableRows.getRow,
|
|
1688
1705
|
});
|
|
1689
|
-
|
|
1690
|
-
|
|
1706
|
+
const outcome = deepLinkOutcome({ row, isMissing }, deepLinkAction);
|
|
1707
|
+
deepLinkOutcomes[outcome](deepLinkAction, id, row as Data);
|
|
1691
1708
|
};
|
|
1692
1709
|
|
|
1710
|
+
const notifyUnresolvedRecordLink = () => {
|
|
1711
|
+
writeRecordId(urlScope);
|
|
1712
|
+
toast.add({
|
|
1713
|
+
title: t("dms.record_link.not_found_title"),
|
|
1714
|
+
description: t("dms.record_link.not_found_description"),
|
|
1715
|
+
color: Color.warning,
|
|
1716
|
+
icon: "i-ph-link-break",
|
|
1717
|
+
});
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
const notifyFailedRecordLink = () => {
|
|
1721
|
+
writeRecordId(urlScope);
|
|
1722
|
+
toast.add({
|
|
1723
|
+
title: t("dms.record_link.failed_title"),
|
|
1724
|
+
description: t("dms.record_link.failed_description"),
|
|
1725
|
+
color: Color.error,
|
|
1726
|
+
icon: "i-ph-warning-circle",
|
|
1727
|
+
});
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
const deepLinkOutcomes: Record<
|
|
1731
|
+
DeepLinkOutcome,
|
|
1732
|
+
(action: CustomRowAction, id: string, row: Data) => void
|
|
1733
|
+
> = {
|
|
1734
|
+
open: (action, _id, row) => handleCustomRowAction(action, row),
|
|
1735
|
+
missingPanel: (action, id) => handleMissingRow(action, id),
|
|
1736
|
+
notify: notifyUnresolvedRecordLink,
|
|
1737
|
+
notifyFailed: notifyFailedRecordLink,
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
// A visit that keeps the page mounted (a link to the same page with another
|
|
1741
|
+
// `?record=`) opens the row it names, as a fresh load would. The table's own
|
|
1742
|
+
// writes replace the URL in place and never reach the route.
|
|
1743
|
+
watch(
|
|
1744
|
+
() => readRecordId(route.query, urlScope),
|
|
1745
|
+
(id, previous) => {
|
|
1746
|
+
if (id && id !== previous) void openRecordFromUrl();
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1693
1750
|
const selectedIds = computed(() => selectedRowIds(rowSelect.value));
|
|
1694
1751
|
|
|
1695
1752
|
const isRowActionEnabled = (
|
|
@@ -2065,6 +2122,7 @@ onMounted(() => {
|
|
|
2065
2122
|
:footer="resolvedFooter"
|
|
2066
2123
|
:empty-states="props.emptyStates"
|
|
2067
2124
|
:hidden-filters-apply="queryParamHiddenFilters.length > 0"
|
|
2125
|
+
:url-filters="shownUrlFilters"
|
|
2068
2126
|
:default-page-size="props.pageSize"
|
|
2069
2127
|
:archive-toggle="canToggleArchived"
|
|
2070
2128
|
:displays="availableDisplays"
|
|
@@ -2102,6 +2160,7 @@ onMounted(() => {
|
|
|
2102
2160
|
@export-all="handleExportTable"
|
|
2103
2161
|
@bulk-action="runBulkAction"
|
|
2104
2162
|
@duplicate="handleRowDuplicate"
|
|
2163
|
+
@remove-url-filter="removeUrlFilter"
|
|
2105
2164
|
@edit="handleRowEdit"
|
|
2106
2165
|
>
|
|
2107
2166
|
<template
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
|
|
2
|
+
import { inject, onBeforeUnmount, ref } from "vue";
|
|
3
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lets a control refuse what was typed into it (a duration or JSON it cannot
|
|
7
|
+
* read): inside a form, the message becomes the field's error, which blocks
|
|
8
|
+
* the submit and joins the form's summary like any other. `message` holds it
|
|
9
|
+
* for a control used outside a form.
|
|
10
|
+
*
|
|
11
|
+
* Call it in the setup of a field's input component. `report(message)` sets
|
|
12
|
+
* the error, `report(undefined)` clears it; the error is cleared when the
|
|
13
|
+
* component unmounts.
|
|
14
|
+
*/
|
|
15
|
+
export function useControlError() {
|
|
16
|
+
const field = inject(formFieldInjectionKey, undefined);
|
|
17
|
+
const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
|
|
18
|
+
const message = ref<string>();
|
|
19
|
+
|
|
20
|
+
function report(next: string | undefined): void {
|
|
21
|
+
if (message.value === next) return;
|
|
22
|
+
message.value = next;
|
|
23
|
+
const name = field?.value?.name;
|
|
24
|
+
if (name) errors?.report(name, next);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onBeforeUnmount(() => report(undefined));
|
|
28
|
+
|
|
29
|
+
return { message, report };
|
|
30
|
+
}
|
|
@@ -14,6 +14,10 @@ import type { BlockAction } from "../../../build/components/blocks/BlockActions.
|
|
|
14
14
|
export interface QueryParamFilter {
|
|
15
15
|
field: string;
|
|
16
16
|
mode?: string;
|
|
17
|
+
/** Shown as a removable chip while the URL applies it. */
|
|
18
|
+
visible?: boolean;
|
|
19
|
+
/** The chip's name; the field's column label by default. */
|
|
20
|
+
label?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export type QueryParamFilters = Record<string, QueryParamFilter>;
|
|
@@ -753,6 +753,12 @@
|
|
|
753
753
|
"held_searching": "search active",
|
|
754
754
|
"held_sorted": "sorted otherwise than by default",
|
|
755
755
|
"held_scrolled": "scrolled down"
|
|
756
|
+
},
|
|
757
|
+
"record_link": {
|
|
758
|
+
"not_found_title": "This entry is no longer available",
|
|
759
|
+
"not_found_description": "The link points to an entry that was deleted, or that this list cannot show.",
|
|
760
|
+
"failed_title": "This entry could not be opened",
|
|
761
|
+
"failed_description": "Loading it failed. Try again in a moment."
|
|
756
762
|
}
|
|
757
763
|
}
|
|
758
764
|
}
|
|
@@ -754,6 +754,12 @@
|
|
|
754
754
|
"held_searching": "recherche active",
|
|
755
755
|
"held_sorted": "tri autre que celui par défaut",
|
|
756
756
|
"held_scrolled": "défilé vers le bas"
|
|
757
|
+
},
|
|
758
|
+
"record_link": {
|
|
759
|
+
"not_found_title": "Cette entrée n’est plus disponible",
|
|
760
|
+
"not_found_description": "Le lien mène à une entrée supprimée, ou que cette liste ne peut pas afficher.",
|
|
761
|
+
"failed_title": "Impossible d’ouvrir cette entrée",
|
|
762
|
+
"failed_description": "Son chargement a échoué. Réessayez dans un instant."
|
|
757
763
|
}
|
|
758
764
|
}
|
|
759
765
|
}
|