@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/db/models/userNotifications.model.js +3 -27
  3. package/dist/db/models/userNotifications.model.js.map +1 -1
  4. package/dist/implementations/dms/frontend-modules.d.ts +4 -2
  5. package/dist/implementations/dms/frontend-modules.js +4 -0
  6. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  7. package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
  8. package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
  9. package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
  10. package/dist/implementations/dms/manifest.d.ts +1 -0
  11. package/dist/implementations/dms/manifest.js +3 -1
  12. package/dist/implementations/dms/manifest.js.map +1 -1
  13. package/dist/test/integration/notifications.test.js +6 -3
  14. package/dist/test/integration/notifications.test.js.map +1 -1
  15. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
  16. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
  17. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
  18. package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
  19. package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
  20. package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
  21. package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
  22. package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
  23. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
  24. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
  25. package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
  26. package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
  27. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
  28. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
  29. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
  30. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
  31. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
  32. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +1 -1
  33. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
  35. package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
  36. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +61 -2
  37. package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  39. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
  40. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
  41. package/frontend-vue/tests/control-error-path.test.ts +11 -0
  42. package/frontend-vue/tests/email-layout.test.ts +124 -0
  43. package/frontend-vue/tests/input-number-range.test.ts +1 -1
  44. package/frontend-vue/tests/input-tags.test.ts +1 -1
  45. package/frontend-vue/tests/row-side-panel.test.ts +2 -2
  46. package/frontend-vue/tests/table-url-filters.test.ts +158 -0
  47. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
  48. 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
- defineProps<{
15
- title: string;
16
- lang?: string;
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 = logoPath?.startsWith("/")
25
- ? `${clientBaseUrl}${logoPath}`
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 :src="logoUrl" alt="Logo" :style="emailStyles.logoImage" />
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
+ }
@@ -1,3 +1,5 @@
1
+ export * from "./email-logo";
2
+
1
3
  export const emailFontFamily =
2
4
  '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
3
5
 
@@ -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
- * Lets a control refuse what was typed into it (a duration or JSON it cannot
15
- * read): inside a form, the message becomes the field's error, which blocks
16
- * the submit and joins the form's summary like any other. `message` holds it
17
- * for a control used outside a form.
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 function useControlError() {
20
- const field = inject(formFieldInjectionKey, undefined);
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; any other
90
- * failure (a refusal, the network) or no route at all finds nothing.
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
- return { isMissing: readFetchNotFound(error) !== null };
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/useControlError";
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 "../../../build/composables/form/useControlError";
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 "../../../build/composables/form/useControlError";
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 "../../../build/composables/form/useControlError";
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 "../../../build/composables/form/useControlError";
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 "../../../build/composables/form/useControlError";
3
+ import { useControlError } from "../../../composables/form/useControlError";
4
4
 
5
5
  interface InputTimeProps extends Omit<InputProps, "modelValue" | "type"> {
6
6
  modelValue?: number | null;
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useControlError } from "../../../build/composables/form/useControlError";
2
+ import { useControlError } from "../../../composables/form/useControlError";
3
3
  import {
4
4
  type KeyValueRow,
5
5
  keyValueProblem,
@@ -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
- if (row) handleCustomRowAction(deepLinkAction, row as Data);
1690
- else if (isMissing) handleMissingRow(deepLinkAction, id);
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
  }