@antelopejs/dms 0.7.20 → 0.8.0

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 (172) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/implementations/dms-auth/account-emails.d.ts +4 -0
  3. package/dist/implementations/dms-auth/account-emails.js +62 -0
  4. package/dist/implementations/dms-auth/account-emails.js.map +1 -0
  5. package/dist/implementations/dms-auth/index.d.ts +1 -3
  6. package/dist/implementations/dms-auth/index.js +15 -61
  7. package/dist/implementations/dms-auth/index.js.map +1 -1
  8. package/dist/pages/settings/users/email-change.js +3 -4
  9. package/dist/pages/settings/users/email-change.js.map +1 -1
  10. package/dist/pages/settings/users/invites.js +1 -1
  11. package/dist/pages/settings/users/invites.js.map +1 -1
  12. package/dist/pages/settings/users/members.js +1 -1
  13. package/dist/pages/settings/users/members.js.map +1 -1
  14. package/dist/pages/settings/users/notifications.js +1 -0
  15. package/dist/pages/settings/users/notifications.js.map +1 -1
  16. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.d.ts +1 -0
  17. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js +34 -0
  18. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms/hook-handler-results.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js +49 -0
  21. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-topics.test.js +17 -0
  23. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  24. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +2 -2
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -1
  26. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.d.ts +1 -0
  27. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js +29 -0
  28. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js.map +1 -0
  29. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +5 -0
  30. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
  31. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js +12 -2
  32. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js.map +1 -1
  33. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +29 -4
  34. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
  37. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +18 -4
  39. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js +8 -5
  41. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js.map +1 -1
  42. package/dist/test/unit/utils/email-subjects.test.d.ts +1 -0
  43. package/dist/test/unit/utils/email-subjects.test.js +27 -0
  44. package/dist/test/unit/utils/email-subjects.test.js.map +1 -0
  45. package/dist/utils/admin-invite-email.js +2 -4
  46. package/dist/utils/admin-invite-email.js.map +1 -1
  47. package/dist/utils/email-change-mail.d.ts +1 -1
  48. package/dist/utils/email-change-mail.js +11 -6
  49. package/dist/utils/email-change-mail.js.map +1 -1
  50. package/dist/utils/email-subjects.d.ts +3 -0
  51. package/dist/utils/email-subjects.js +32 -0
  52. package/dist/utils/email-subjects.js.map +1 -0
  53. package/dist/utils/export-jobs.js +15 -10
  54. package/dist/utils/export-jobs.js.map +1 -1
  55. package/frontend-vue/layers/dms-core/app/utils/url-interpolation.ts +29 -11
  56. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +17 -0
  57. package/frontend-vue/layers/dms-layout/app/app.config.ts +5 -0
  58. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +18 -2
  59. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +16 -1
  60. package/frontend-vue/layers/dms-layout/app/build/composables/layout/useHeaderActionControls.ts +14 -0
  61. package/frontend-vue/layers/dms-layout/app/components/layout/HStack.vue +16 -0
  62. package/frontend-vue/layers/dms-layout/app/components/layout/VStack.vue +2 -0
  63. package/frontend-vue/layers/dms-layout/app/composables/useFooterLinks.ts +11 -1
  64. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +1 -1
  65. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +1 -1
  66. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeNotice.vue +13 -7
  67. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeVerification.vue +18 -7
  68. package/frontend-vue/layers/dms-layout/app/emails/EmailExportReady.vue +23 -12
  69. package/frontend-vue/layers/dms-layout/app/emails/EmailResetPassword.vue +18 -7
  70. package/frontend-vue/layers/dms-layout/app/emails/EmailTwoFactor.vue +23 -4
  71. package/frontend-vue/layers/dms-layout/app/emails/EmailUserValidation.vue +18 -6
  72. package/frontend-vue/layers/dms-layout/app/emails/components/EmailOTP.vue +8 -2
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +40 -0
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +40 -0
  75. package/frontend-vue/layers/dms-ui/app/build/components/form/FormFieldControl.vue +2 -1
  76. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +11 -3
  77. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
  78. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
  79. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +90 -43
  81. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +59 -2
  82. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +27 -4
  83. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +10 -2
  84. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +0 -5
  85. package/frontend-vue/layers/dms-ui/app/build/composables/form/formEntryOrder.ts +23 -0
  86. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +19 -15
  87. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +26 -2
  88. package/frontend-vue/layers/dms-ui/app/build/composables/page/pageUrlValues.ts +41 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +2 -2
  90. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +3 -5
  91. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
  92. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +7 -11
  93. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -3
  94. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +10 -11
  95. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
  96. package/frontend-vue/layers/dms-ui/app/build/utils/link.ts +7 -3
  97. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +32 -9
  98. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +8 -1
  99. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +9 -4
  100. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +5 -11
  101. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +2 -1
  102. package/frontend-vue/layers/dms-ui/app/components/chart/ApexChartHost.vue +32 -1
  103. package/frontend-vue/layers/dms-ui/app/components/check-list/CheckList.vue +1 -1
  104. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +12 -2
  105. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +7 -3
  106. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +7 -0
  107. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +4 -1
  108. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +4 -2
  109. package/frontend-vue/layers/dms-ui/app/components/form/components/InputSecret.vue +3 -2
  110. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -1
  111. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +15 -3
  112. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +2 -1
  113. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +9 -2
  114. package/frontend-vue/layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue +13 -5
  115. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +1 -1
  116. package/frontend-vue/layers/dms-ui/app/components/stat-group/StatGroup.vue +3 -2
  117. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +53 -31
  118. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +14 -6
  119. package/frontend-vue/layers/dms-ui/app/composables/chart/apexPlotOptions.ts +15 -4
  120. package/frontend-vue/layers/dms-ui/app/composables/chart/apexYRange.ts +123 -0
  121. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.ts +49 -49
  122. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartTheme.ts +20 -1
  123. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +1 -1
  124. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  125. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +8 -6
  126. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +1 -1
  127. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +3 -3
  128. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
  129. package/frontend-vue/layers/dms-ui/app/utils/bannerToneIcons.ts +13 -0
  130. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +26 -0
  131. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -0
  132. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -0
  133. package/frontend-vue/tests/activity-feed-fill-height.test.ts +7 -0
  134. package/frontend-vue/tests/banner-tone-icon.test.ts +55 -0
  135. package/frontend-vue/tests/builtin-emails.test.ts +141 -0
  136. package/frontend-vue/tests/cascader.test.ts +27 -0
  137. package/frontend-vue/tests/chart-auto-y-range.test.ts +25 -9
  138. package/frontend-vue/tests/chart-colors.test.ts +8 -0
  139. package/frontend-vue/tests/chart-resize-after-animation.test.ts +100 -0
  140. package/frontend-vue/tests/chart-type-defaults.test.ts +128 -0
  141. package/frontend-vue/tests/check-list-marker.test.ts +58 -0
  142. package/frontend-vue/tests/confirm-from.test.ts +1 -1
  143. package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -2
  144. package/frontend-vue/tests/context-links.test.ts +2 -2
  145. package/frontend-vue/tests/custom-action-confirm-from.test.ts +2 -2
  146. package/frontend-vue/tests/download-action-target.test.ts +2 -2
  147. package/frontend-vue/tests/file-drop-prompt.test.ts +72 -0
  148. package/frontend-vue/tests/footer-config.test.ts +135 -0
  149. package/frontend-vue/tests/form-actions.test.ts +7 -0
  150. package/frontend-vue/tests/form-entry-order.test.ts +45 -0
  151. package/frontend-vue/tests/form-footer-kind.test.ts +11 -6
  152. package/frontend-vue/tests/form-load-error.test.ts +38 -1
  153. package/frontend-vue/tests/grid-responsive-columns.test.ts +9 -0
  154. package/frontend-vue/tests/header-actions.test.ts +55 -9
  155. package/frontend-vue/tests/instant-save-badge.test.ts +11 -4
  156. package/frontend-vue/tests/list-row-truncate.test.ts +46 -0
  157. package/frontend-vue/tests/roles-name-errors.test.ts +2 -3
  158. package/frontend-vue/tests/row-side-panel.test.ts +93 -2
  159. package/frontend-vue/tests/save-bar.test.ts +15 -3
  160. package/frontend-vue/tests/save-status-error.test.ts +2 -2
  161. package/frontend-vue/tests/table-row-action-errors.test.ts +5 -5
  162. package/frontend-vue/tests/table-row-hover.test.ts +2 -2
  163. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +114 -0
  164. package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
  165. package/frontend-vue/tests/table-view-sources.test.ts +1 -1
  166. package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
  167. package/frontend-vue/tests/template-tokens.test.ts +235 -0
  168. package/frontend-vue/tests/url-interpolation.test.ts +6 -6
  169. package/package.json +2 -2
  170. package/skills/dms/SKILL.md +2 -1
  171. package/skills/dms-pages/REFERENCE.md +2 -2
  172. package/skills/dms-project/SKILL.md +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { LocationQueryValue } from "#dms/frontend-module";
2
- import { replaceUrlVariables } from "../../utils/urlVariables";
2
+ import { resolveLinkVariables } from "../../utils/urlVariables";
3
3
 
4
4
  type RouteQuery = Record<string, LocationQueryValue | LocationQueryValue[]>;
5
5
 
@@ -23,7 +23,7 @@ export function resolveFormSuccessTarget(
23
23
  { url, preserveQuery }: FormSuccessParams,
24
24
  route: FormSuccessRoute,
25
25
  ): { path: string; query?: RouteQuery } {
26
- const path = replaceUrlVariables(url, {
26
+ const path = resolveLinkVariables(url, {
27
27
  routeParams: route.params,
28
28
  routeQuery: route.query,
29
29
  });
@@ -1,5 +1,6 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
2
  import { computed } from "vue";
3
+ import { interpolateUrl } from "#dms-core/app/utils/url-interpolation";
3
4
  import type {
4
5
  TableViewColumn,
5
6
  TableViewListResponse,
@@ -54,7 +55,6 @@ const DESCENDING = "desc";
54
55
  */
55
56
  export const LIVE_HELD_AT_QUERY_KEY = "liveHeldAt";
56
57
  const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
57
- const ROW_ID_TOKEN = "{id}";
58
58
 
59
59
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
60
60
 
@@ -251,10 +251,8 @@ export function useTableRows<T extends Record<string, unknown>>(
251
251
  id: string,
252
252
  ): Promise<T | undefined> => {
253
253
  const url = resolveUrlVariables(
254
- sourceConfig.fetchRowUrl?.replaceAll(
255
- ROW_ID_TOKEN,
256
- encodeURIComponent(id),
257
- ),
254
+ sourceConfig.fetchRowUrl &&
255
+ interpolateUrl(sourceConfig.fetchRowUrl, { id }),
258
256
  options.route?.() ?? NO_ROUTE_VALUES,
259
257
  );
260
258
  return url ? api<T>(url) : Promise.resolve(undefined);
@@ -1,5 +1,11 @@
1
- import { watch, type Ref } from "vue";
2
- import { readTableUrlKey, type TableUrlScope } from "./utils/views";
1
+ import { router as inertiaRouter } from "@inertiajs/vue3";
2
+ import { onBeforeUnmount, onMounted, watch, type Ref } from "vue";
3
+ import { queryOfUrl } from "./utils/urlFilters";
4
+ import {
5
+ readTableUrlKey,
6
+ type TableUrlScope,
7
+ writeTableUrlKey,
8
+ } from "./utils/views";
3
9
 
4
10
  const TAB_URL_KEY = "tab";
5
11
 
@@ -30,18 +36,42 @@ export function readTableUrlTab(
30
36
  }
31
37
 
32
38
  /**
33
- * Follows the URL's tab after arrival: a link or a `router.replace` naming
34
- * another tab of the same page switches to it without remounting the page.
35
- * A URL dropping the key, or naming a tab the table cannot open in place (an
36
- * unknown id, a link tab), leaves the open tab alone.
39
+ * Keeps the open tab and the URL in step after arrival. A tab the user opens
40
+ * is written to the URL in place (no visit, no history entry), under the key
41
+ * it is read from, so a reload or a shared link opens it again. A link or a
42
+ * `router.replace` naming another tab of the same page switches to it without
43
+ * remounting the page. A URL dropping the key, or naming a tab the table
44
+ * cannot open in place (an unknown id, a link tab), leaves the open tab alone.
37
45
  */
38
46
  export function useTableUrlTab(options: TableUrlTabOptions): void {
47
+ const { activeTabId, urlScope } = options;
48
+
49
+ const follow = (query: Record<string, unknown>) => {
50
+ const tabId = readTableUrlTab(query, urlScope);
51
+ if (!tabId || tabId === activeTabId.value) return;
52
+ if (!options.isOpenable(tabId)) return;
53
+ activeTabId.value = tabId;
54
+ };
55
+
56
+ // The tab is written in place, so the route keeps the value of the last
57
+ // visit: a link back to that tab leaves it unchanged, and only the visit
58
+ // tells it was followed. A `router.replace`, which Inertia does not
59
+ // announce, still moves the route.
39
60
  watch(
40
- () => readTableUrlTab(options.route.query, options.urlScope),
41
- (tabId) => {
42
- if (!tabId || tabId === options.activeTabId.value) return;
43
- if (!options.isOpenable(tabId)) return;
44
- options.activeTabId.value = tabId;
45
- },
61
+ () => readTableUrlTab(options.route.query, urlScope),
62
+ () => follow(options.route.query),
46
63
  );
64
+ let stopFollowingVisits: (() => void) | undefined;
65
+ onMounted(() => {
66
+ stopFollowingVisits = inertiaRouter.on("navigate", (event) =>
67
+ follow(queryOfUrl(event.detail.page.url)),
68
+ );
69
+ });
70
+ onBeforeUnmount(() => stopFollowingVisits?.());
71
+
72
+ watch(activeTabId, (tabId) => {
73
+ if (options.isOpenable(tabId)) {
74
+ writeTableUrlKey(urlScope, TAB_URL_KEY, tabId);
75
+ }
76
+ });
47
77
  }
@@ -102,19 +102,15 @@ export const useTableViewConfig = <T extends Data>(
102
102
  .map((col) => ({ ...col, header: processI18n(col.header) })),
103
103
  );
104
104
 
105
- // Config-derived (SSR-safe; the registry is client-only): the implicit `table`
106
- // display plus explicit `displays`. Built-in defaults
105
+ // Config-derived (SSR-safe; the registry is client-only): the listed
106
+ // `displays`, else the built-in `table` alone. Built-in defaults
107
107
  // (selfManagedData/capabilities) are merged per id.
108
- const resolvedDisplays: TableViewDisplayConfig[] = (() => {
109
- const list: TableViewDisplayConfig[] = [{ id: TABLE_DISPLAY_ID }];
110
- for (const display of config.displays ?? []) {
111
- if (display.id === TABLE_DISPLAY_ID) continue;
112
- list.push(display);
113
- }
114
- return list.map(withBuiltinDefaults);
115
- })();
108
+ const resolvedDisplays: TableViewDisplayConfig[] = (
109
+ config.displays ?? [{ id: TABLE_DISPLAY_ID }]
110
+ ).map(withBuiltinDefaults);
116
111
 
117
- const defaultDisplay = config.defaultDisplay ?? TABLE_DISPLAY_ID;
112
+ const defaultDisplay =
113
+ config.defaultDisplay ?? resolvedDisplays[0]?.id ?? TABLE_DISPLAY_ID;
118
114
 
119
115
  const initialVisibility = buildInitialColumnVisibility(config.columns ?? []);
120
116
 
@@ -1,3 +1,4 @@
1
+ import { namesRowField } from "#dms-core/app/utils/url-interpolation";
1
2
  import {
2
3
  fillFormPageUrl,
3
4
  type FormContainer,
@@ -91,7 +92,6 @@ interface BulkActionConfig {
91
92
  /** Runs a confirmed action from inside its dialog (see `onConfirm`). */
92
93
  type ConfirmedRun = NonNullable<ConfirmOptions["onConfirm"]>;
93
94
 
94
- const ROW_URL_PLACEHOLDER = /\{[^}]+\}/;
95
95
  /** Options of a delete: from the archive, it is a permanent one. */
96
96
  interface DeleteRowsOptions {
97
97
  permanently?: boolean;
@@ -634,7 +634,7 @@ export const useTableRowActions = <T extends Data>(
634
634
  * reaches: a `from` dialog is worded for one row, so several rows keep the
635
635
  * generic confirmation. Undefined when none applies.
636
636
  */
637
- // A `from` URL naming a row (`{id}`, `{_id}`…) is worded for that row
637
+ // A `from` URL naming a row (`{{row.id}}`, `{{row._id}}`…) is worded for that row
638
638
  // alone; one naming none receives the rows as `?ids=`, one or several, like
639
639
  // the `from` of a bulk custom action.
640
640
  const declaredBulkConfirm = (
@@ -643,7 +643,7 @@ export const useTableRowActions = <T extends Data>(
643
643
  ): ActionConfirm | undefined => {
644
644
  const declared = normalizeActionConfig(actionConfig).confirm;
645
645
  if (!declared || !isConfirmFrom(declared)) return declared;
646
- if (ROW_URL_PLACEHOLDER.test(declared.from)) {
646
+ if (namesRowField(declared.from)) {
647
647
  return ids.length === 1 ? declared : undefined;
648
648
  }
649
649
  return {
@@ -1,5 +1,10 @@
1
1
  import type { CustomRowAction } from "../../../../types/row-action";
2
- import { readTableUrlKey, type TableUrlScope } from "./views";
2
+ import {
3
+ readTableUrlKey,
4
+ tableUrlKey,
5
+ type TableUrlScope,
6
+ writeTableUrlKey,
7
+ } from "./views";
3
8
  import { readFetchNotFound } from "../../../utils/fetchNotFound";
4
9
 
5
10
  /** Query key of the row a `deepLink` action has open. */
@@ -7,9 +12,7 @@ export const RECORD_URL_KEY = "record";
7
12
 
8
13
  /** The key this table writes the open row under: short on a sole table. */
9
14
  export function recordUrlKey(scope: TableUrlScope): string {
10
- return scope.isSoleTableView || !scope.tableId
11
- ? RECORD_URL_KEY
12
- : `${scope.tableId}.${RECORD_URL_KEY}`;
15
+ return tableUrlKey(scope, RECORD_URL_KEY);
13
16
  }
14
17
 
15
18
  /** The row the URL names for this table, if any. */
@@ -29,13 +32,9 @@ export function writeRecordId(
29
32
  scope: TableUrlScope,
30
33
  id?: string,
31
34
  ): WrittenRecord | undefined {
32
- if (typeof window === "undefined") return undefined;
33
- const url = new URL(window.location.href);
34
- const key = recordUrlKey(scope);
35
- if (id) url.searchParams.set(key, id);
36
- else url.searchParams.delete(key);
37
- window.history.replaceState(window.history.state, "", url);
38
- return id ? { pathname: url.pathname, key, id } : undefined;
35
+ const url = writeTableUrlKey(scope, RECORD_URL_KEY, id);
36
+ if (!url || !id) return undefined;
37
+ return { pathname: url.pathname, key: recordUrlKey(scope), id };
39
38
  }
40
39
 
41
40
  /**
@@ -183,6 +183,35 @@ export interface UrlKeyReading {
183
183
  ignoredShortKey?: boolean;
184
184
  }
185
185
 
186
+ /**
187
+ * The key this table writes `key` (`tab`, `record`) under: the short one on
188
+ * its page's only table, else prefixed with its id (`content.tab`).
189
+ */
190
+ export function tableUrlKey(scope: TableUrlScope, key: string): string {
191
+ return scope.isSoleTableView || !scope.tableId
192
+ ? key
193
+ : `${scope.tableId}.${key}`;
194
+ }
195
+
196
+ /**
197
+ * Writes (or, without a value, removes) `key` for this table in the browser's
198
+ * URL in place: no visit, no history entry. Returns the URL written, or
199
+ * `undefined` on a server render.
200
+ */
201
+ export function writeTableUrlKey(
202
+ scope: TableUrlScope,
203
+ key: string,
204
+ value?: string,
205
+ ): URL | undefined {
206
+ if (typeof window === "undefined") return undefined;
207
+ const url = new URL(window.location.href);
208
+ const scopedKey = tableUrlKey(scope, key);
209
+ if (value) url.searchParams.set(scopedKey, value);
210
+ else url.searchParams.delete(scopedKey);
211
+ window.history.replaceState(window.history.state, "", url);
212
+ return url;
213
+ }
214
+
186
215
  /**
187
216
  * The value the URL gives `key` (`view`, `tab`) for this table: under its
188
217
  * prefixed key (`content.view`) or, when it is its page's only table, the
@@ -3,6 +3,8 @@
3
3
  // bare `<a href="#…">` (an Inertia visit would reload the page to scroll), an
4
4
  // absolute URL opens in a new tab, and everything else is a DMS route.
5
5
 
6
+ import { pageLink } from "../composables/page/pageUrlValues";
7
+
6
8
  export type LinkKind = "internal" | "anchor" | "external";
7
9
 
8
10
  const EXTERNAL_LINK_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
@@ -20,12 +22,14 @@ export const EXTERNAL_LINK_ATTRIBUTES = {
20
22
  } as const;
21
23
 
22
24
  /**
23
- * Props for a `UButton` (or `ULink`) following `to`: an external target opens
24
- * in a new tab, the others navigate in place.
25
+ * Props for a `UButton` (or `ULink`) following `to`, its page tokens
26
+ * (`{{params.X}}`, `{{query.X}}`) filled: an external target opens in a new
27
+ * tab, the others navigate in place.
25
28
  */
26
29
  export function buttonLinkProps(
27
- to: string | undefined,
30
+ declared: string | undefined,
28
31
  ): Record<string, string> {
32
+ const to = pageLink(declared);
29
33
  if (!to) return {};
30
34
  return linkKind(to) === "external"
31
35
  ? { to, ...EXTERNAL_LINK_ATTRIBUTES }
@@ -1,11 +1,24 @@
1
+ import { get } from "@nuxt/ui/runtime/utils/index.js";
2
+
3
+ // Every token is `{{<scope>.<key>}}`, spaces inside the braces allowed, as the
4
+ // server's filter tokens (`{{ user.id }}`) are.
5
+ //
1
6
  // `{{params.id}}` takes the bare name, which on a route repeating a placeholder
2
7
  // is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
3
8
  // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
4
9
  // table view (see extractRouteParams).
5
- const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
10
+ const PARAM_KEY = String.raw`params\.(\w+(?::\d+)?)`;
6
11
  // A query key may carry dots and dashes: a period selector writes `<id>.period`.
7
- const QUERY_TOKEN = /\{\{query\.([\w.-]+)\}\}/g;
8
- const RESPONSE_TOKEN = /\{\{response\.(\w+)\}\}/g;
12
+ const QUERY_KEY = String.raw`query\.([\w.-]+)`;
13
+ // A response key is a path into the answered body: `{{response.data.id}}`.
14
+ const RESPONSE_KEY = String.raw`response\.([\w.-]+)`;
15
+
16
+ const tokenPattern = (key: string, flags = "g"): RegExp =>
17
+ new RegExp(String.raw`\{\{\s*${key}\s*\}\}`, flags);
18
+
19
+ const PARAM_TOKEN = tokenPattern(PARAM_KEY);
20
+ const QUERY_TOKEN = tokenPattern(QUERY_KEY);
21
+ const RESPONSE_TOKEN = tokenPattern(RESPONSE_KEY);
9
22
  const TOKEN_OPENING = "{{";
10
23
 
11
24
  export interface ReplaceUrlVariablesContext {
@@ -63,8 +76,8 @@ function fillResponseTokens(
63
76
  response: Record<string, unknown> | undefined,
64
77
  ): string {
65
78
  if (!response) return text;
66
- return text.replace(RESPONSE_TOKEN, (match, key) => {
67
- const value = response[key];
79
+ return text.replace(RESPONSE_TOKEN, (match, key: string) => {
80
+ const value: unknown = get(response, key);
68
81
  return typeof value === "string" || typeof value === "number"
69
82
  ? String(value)
70
83
  : match;
@@ -122,10 +135,20 @@ export function resolveTextVariables(
122
135
 
123
136
  // A route token left without a value: `{{params.id}}`, `{{query.workspace}}`,
124
137
  // `{{query.sales.period}}` (a query key may carry dots and dashes, as above).
125
- const UNRESOLVED_ROUTE_TOKEN =
126
- /\{\{(?:params\.\w+(?::\d+)?|query\.[\w.-]+)\}\}/g;
127
- const UNRESOLVED_ROUTE_VALUE =
128
- /^\{\{(?:params\.\w+(?::\d+)?|query\.[\w.-]+)\}\}$/;
138
+ const ROUTE_KEY = `(?:${PARAM_KEY}|${QUERY_KEY})`;
139
+ const UNRESOLVED_ROUTE_TOKEN = tokenPattern(ROUTE_KEY);
140
+ const UNRESOLVED_ROUTE_VALUE = new RegExp(
141
+ `^${tokenPattern(ROUTE_KEY, "").source}$`,
142
+ );
143
+
144
+ /**
145
+ * Whether a text names a value of the page (`{{params.X}}`, `{{query.X}}`):
146
+ * the route is only read for one that does.
147
+ */
148
+ export function namesPageValue(text: string): boolean {
149
+ return new RegExp(UNRESOLVED_ROUTE_TOKEN.source).test(text);
150
+ }
151
+
129
152
  const QUERY_SEPARATOR = "?";
130
153
  const FRAGMENT_SEPARATOR = "#";
131
154
  const PAIR_SEPARATOR = "&";
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import { computed, ref, resolveComponent } from "vue";
3
4
  import DmsActivityItem from "../activity/ActivityItem.vue";
4
5
  import DmsBlockActions, {
@@ -236,6 +237,11 @@ const scrollArea = computed(() =>
236
237
  const dayHeadClass = computed(
237
238
  () => props.fillHeight && "sticky top-0 z-[1] bg-(--dms-surface-card)",
238
239
  );
240
+ // …and the "Show N more" button at its foot, where entries taller than the
241
+ // row would otherwise push it out of sight.
242
+ const foldClass = computed(
243
+ () => props.fillHeight && "sticky bottom-0 z-[1] bg-(--dms-surface-card)",
244
+ );
239
245
 
240
246
  const Wrapper = props.card ? resolveComponent("DmsCard") : "div";
241
247
  const wrapperProps = computed(() =>
@@ -365,13 +371,14 @@ const wrapperProps = computed(() =>
365
371
  :unread="item.unread"
366
372
  :mono="props.mono"
367
373
  :wrap="props.wrap"
368
- :to="item.to"
374
+ :to="pageLink(item.to)"
369
375
  :interactive="!!item.to"
370
376
  />
371
377
  </section>
372
378
  <div
373
379
  v-if="foldedCount > 0"
374
380
  class="dms-activity-feed__fold border-t border-(--ui-border-muted) px-[18px] pt-2 pb-1"
381
+ :class="foldClass"
375
382
  >
376
383
  <UButton
377
384
  :label="foldLabel"
@@ -1,19 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
3
  import DmsIconWell, { type IconWellSize } from "../icon-well/IconWell.vue";
4
- import type { SemanticColor } from "../../utils/semanticTint";
4
+ import {
5
+ BANNER_TONE_ICONS,
6
+ type BannerTone,
7
+ } from "../../utils/bannerToneIcons";
5
8
 
6
9
  // DMS-styled notice banner (design .banner): a left-to-right tinted wash over
7
10
  // the card surface with a boxed leading icon, used for module beta / info
8
11
  // notices. Wraps UAlert so we keep its a11y + slots while pinning the exact
9
12
  // reference look. The `cli` slot hosts the optional inset command box
10
13
  // (compose with DmsCopyButton), `actions` the right-aligned buttons.
11
- type BannerTone = Exclude<SemanticColor, "neutral">;
12
14
  type BannerSize = "sm" | "md";
13
15
 
14
16
  interface BannerProps {
15
17
  title?: string;
16
18
  description?: string;
19
+ /** Defaults to the tone's icon. */
17
20
  icon?: string;
18
21
  /** Colour of the wash and the icon well. */
19
22
  tone?: BannerTone;
@@ -39,7 +42,7 @@ interface BannerSlots {
39
42
  const props = withDefaults(defineProps<BannerProps>(), {
40
43
  title: undefined,
41
44
  description: undefined,
42
- icon: "i-ph-warning",
45
+ icon: undefined,
43
46
  tone: "warning",
44
47
  size: "md",
45
48
  dismissible: false,
@@ -99,6 +102,8 @@ const alertUi = computed(() => ({
99
102
  actions: `items-center gap-2 mt-0 ${slots.actions ? "max-sm:basis-full max-sm:flex-wrap" : ""}`,
100
103
  }));
101
104
 
105
+ const leadingIcon = computed(() => props.icon ?? BANNER_TONE_ICONS[props.tone]);
106
+
102
107
  function onOpenChange(open: boolean) {
103
108
  if (!open) emit("dismiss");
104
109
  }
@@ -116,7 +121,7 @@ function onOpenChange(open: boolean) {
116
121
  @update:open="onOpenChange"
117
122
  >
118
123
  <template #leading>
119
- <DmsIconWell :icon="icon" :tone="tone" :size="SIZE_WELL[size]" />
124
+ <DmsIconWell :icon="leadingIcon" :tone="tone" :size="SIZE_WELL[size]" />
120
125
  </template>
121
126
 
122
127
  <template v-if="slots.description || slots.cli" #description>
@@ -8,14 +8,16 @@ import DmsBlockActions, {
8
8
  import { useActionTargets } from "../../build/composables/actions/useActionTargets";
9
9
  import { useChartFetch } from "../../composables/chart/useChartFetch";
10
10
  import { refreshPageBlocks } from "../../utils/blockRefresh";
11
+ import {
12
+ BANNER_TONE_ICONS,
13
+ type BannerTone,
14
+ } from "../../utils/bannerToneIcons";
11
15
  import type { CustomButton } from "../../composables/table-view/types/custom-button";
12
16
  import { useWatch } from "../../../../dms-core/app/composables/watch/useWatch";
13
17
  import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
14
18
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
15
19
  import type { BlockText } from "../../../../dms-core/app/types/composed-text";
16
20
 
17
- type BannerTone = "info" | "success" | "warning" | "error" | "primary";
18
-
19
21
  /** What a banner shows (interface-dms `BannerContent`). */
20
22
  interface BannerContent {
21
23
  /** A string (`$` for an i18n key) or a composed text. */
@@ -83,14 +85,6 @@ const DISMISSED_MAX_KEYS = 40;
83
85
  const FALLBACK_COMPONENT_ID = "banner";
84
86
  const FALLBACK_PAGE_ID = "page";
85
87
 
86
- const TONE_ICONS: Record<BannerTone, string> = {
87
- info: "i-ph-info",
88
- success: "i-ph-check-circle",
89
- warning: "i-ph-warning",
90
- error: "i-ph-warning-circle",
91
- primary: "i-ph-sparkle",
92
- };
93
-
94
88
  const { state: watchState } = useWatch(
95
89
  props.watchActions || [],
96
90
  props.componentId,
@@ -183,7 +177,7 @@ function press(action: BlockButtonAction): void {
183
177
  :description="
184
178
  banner.description ? processText(banner.description) : undefined
185
179
  "
186
- :icon="banner.icon ?? TONE_ICONS[banner.tone]"
180
+ :icon="banner.icon ?? BANNER_TONE_ICONS[banner.tone]"
187
181
  :tone="banner.tone"
188
182
  :size="banner.size"
189
183
  :dismissible="banner.dismissible"
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import { gridColumnsTemplate } from "../grid/columns";
3
4
  import {
4
5
  useCategoryNavCards,
@@ -217,7 +218,7 @@ const { t } = useI18n();
217
218
  >
218
219
  <DmsNavCard
219
220
  class="h-full"
220
- :to="card.to"
221
+ :to="pageLink(card.to)"
221
222
  :icon="card.icon"
222
223
  :icon-tone="card.iconTone"
223
224
  :title="card.title"
@@ -24,7 +24,14 @@ interface Emits {
24
24
  (event: "drawn"): void;
25
25
  }
26
26
 
27
+ /** The ApexCharts instance the wrapper exposes as `chart`. */
28
+ interface ApexCore {
29
+ /** Apex's own (debounced) window resize handler: redraws at the container width. */
30
+ windowResizeHandler: () => void;
31
+ }
32
+
27
33
  interface ApexChartInstance {
34
+ chart: ApexCore | null;
28
35
  updateOptions: (
29
36
  options: Record<string, unknown>,
30
37
  redrawPaths?: boolean,
@@ -39,6 +46,7 @@ const emit = defineEmits<Emits>();
39
46
  const REDRAW_PATHS = false;
40
47
  const ANIMATE = true;
41
48
  const UPDATE_SYNCED_CHARTS = false;
49
+ const WIDTH_TOLERANCE_PX = 1;
42
50
 
43
51
  const { locale } = useI18n();
44
52
 
@@ -57,6 +65,7 @@ onMounted(() => {
57
65
  void ensureApexLocale(locale.value).catch(() => undefined);
58
66
  });
59
67
 
68
+ const hostRef = ref<HTMLElement | null>(null);
60
69
  const chartRef = ref<ApexChartInstance | null>(null);
61
70
 
62
71
  /**
@@ -123,10 +132,31 @@ watch(
123
132
  function onDrawn() {
124
133
  emit("drawn");
125
134
  }
135
+
136
+ function isDrawnAtStaleWidth(host: HTMLElement): boolean {
137
+ const svg = host.querySelector("svg.apexcharts-svg");
138
+ if (!svg) return false;
139
+ const drawnWidth = svg.getBoundingClientRect().width;
140
+ const hostWidth = host.getBoundingClientRect().width;
141
+ return Math.abs(drawnWidth - hostWidth) > WIDTH_TOLERANCE_PX;
142
+ }
143
+
144
+ /**
145
+ * Apex drops its container's resizes while an animation runs, so a sidebar
146
+ * collapsed or expanded during one leaves the chart at its old width, past
147
+ * its card when the container shrank, until the next window resize. Once the
148
+ * animation is over, a chart drawn at another width than its host redraws.
149
+ */
150
+ function redrawIfResizedWhileAnimating() {
151
+ const host = hostRef.value;
152
+ const chart = chartRef.value?.chart;
153
+ if (!host || !chart || !isDrawnAtStaleWidth(host)) return;
154
+ chart.windowResizeHandler();
155
+ }
126
156
  </script>
127
157
 
128
158
  <template>
129
- <div class="dms-apex">
159
+ <div ref="hostRef" class="dms-apex">
130
160
  <ApexChart
131
161
  ref="chartRef"
132
162
  :type="apexType"
@@ -135,6 +165,7 @@ function onDrawn() {
135
165
  :series="apexSeries"
136
166
  @mounted="onDrawn"
137
167
  @updated="onDrawn"
168
+ @animation-end="redrawIfResizedWhileAnimating"
138
169
  />
139
170
  </div>
140
171
  </template>
@@ -75,7 +75,7 @@ const ICONS: Record<
75
75
  ok: "i-ph-check",
76
76
  warn: "i-ph-warning",
77
77
  error: "i-ph-x",
78
- pending: "i-ph-x",
78
+ pending: "i-ph-circle",
79
79
  info: "i-ph-arrow-right",
80
80
  },
81
81
  };
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import type { FormProps } from "../../composables/form/types/props";
3
4
  import type {
4
5
  FormData,
@@ -64,6 +65,7 @@ import DmsFormErrorSummary from "../../build/components/form/FormErrorSummary.vu
64
65
  import DmsFormLoadError from "../../build/components/form/FormLoadError.vue";
65
66
  import {
66
67
  type FormRecordLoad,
68
+ missingRecordFailure,
67
69
  trackFormRecordLoad,
68
70
  } from "../../build/composables/form/formRecordLoad";
69
71
  import DmsFormRequiredLegend from "./FormRequiredLegend.vue";
@@ -492,7 +494,7 @@ function cancelForm(): void {
492
494
  window.history.back();
493
495
  return;
494
496
  }
495
- if (props.backTo) void router.push(props.backTo);
497
+ if (props.backTo) void router.push(pageLink(props.backTo));
496
498
  }
497
499
 
498
500
  const validators = new Set<FormValidator>();
@@ -573,7 +575,15 @@ if (props.fetchUrl) {
573
575
  });
574
576
  }
575
577
 
576
- const loadFailure = computed(() => recordLoad?.failure.value ?? null);
578
+ const loadFailure = computed(
579
+ () =>
580
+ recordLoad?.failure.value ??
581
+ missingRecordFailure({
582
+ requiresRecord: props.requiresRecord,
583
+ fetchUrl: props.fetchUrl,
584
+ resolvedFetchUrl: resolvedFetchUrl.value,
585
+ }),
586
+ );
577
587
  const isRetryingLoad = computed(() => recordLoad?.retrying.value ?? false);
578
588
 
579
589
  /** Load the record again after a failure; it is what the form starts from. */
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageRequestUrl } from "../../../build/composables/page/pageUrlValues";
2
3
  import {
3
4
  buildCascaderTree,
4
5
  CASCADER_FETCH_LIMIT,
@@ -68,8 +69,10 @@ interface CascaderResponse {
68
69
 
69
70
  const { data, status, execute } = await useDmsAsyncData(
70
71
  `cascader-${instanceId}`,
71
- () =>
72
- $authFetch<CascaderResponse>(props.searchUrl, {
72
+ () => {
73
+ const url = pageRequestUrl(props.searchUrl);
74
+ if (!url) return Promise.resolve({ results: [], total: 0 });
75
+ return $authFetch<CascaderResponse>(url, {
73
76
  params: { limit: CASCADER_FETCH_LIMIT },
74
77
  onRequestError: ({ error }: { error: Error }) => {
75
78
  toast.add({
@@ -78,7 +81,8 @@ const { data, status, execute } = await useDmsAsyncData(
78
81
  description: error.message,
79
82
  });
80
83
  },
81
- }),
84
+ });
85
+ },
82
86
  { lazy: true, immediate: false },
83
87
  );
84
88
 
@@ -349,6 +349,12 @@ const acceptString = computed(() =>
349
349
  props.constraints?.allowedMimetypes?.join(","),
350
350
  );
351
351
 
352
+ const dropPromptKey = computed(() =>
353
+ props.multiple
354
+ ? "dms.form.file.drop_prompt_multiple"
355
+ : "dms.form.file.drop_prompt",
356
+ );
357
+
352
358
  const maxSizeFormatted = computed(() => {
353
359
  if (!props.constraints?.maxSize) return null;
354
360
  return formatFileSize(props.constraints.maxSize);
@@ -373,6 +379,7 @@ onScopeDispose(() => {
373
379
  :disabled="disabled || isUploading"
374
380
  :color="color"
375
381
  :highlight="highlight"
382
+ :label="$t(dropPromptKey)"
376
383
  v-bind="dropZoneAria"
377
384
  >
378
385
  <template v-if="maxSizeFormatted" #description>