@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,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageRequestUrl } from "../../../build/composables/page/pageUrlValues";
2
3
  import type { SelectMenuItem } from "@nuxt/ui";
3
4
  import { refDebounced } from "@vueuse/core";
4
5
  import {
@@ -225,7 +226,9 @@ interface CountryOptions {
225
226
  }
226
227
 
227
228
  async function fetchCountryOptions(): Promise<CountryOptions> {
228
- const data = await $authFetch<CountryApiResponse>(props.searchUrl!, {
229
+ const url = pageRequestUrl(props.searchUrl);
230
+ if (!url) return { results: [], total: 0 };
231
+ const data = await $authFetch<CountryApiResponse>(url, {
229
232
  params: {
230
233
  q: countrySearchTermDebounced.value,
231
234
  },
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageRequestUrl } from "../../../build/composables/page/pageUrlValues";
2
3
  import { getCurrentInstance, h, render } from "vue";
3
4
  import { useFormField } from "@nuxt/ui/composables/useFormField";
4
5
  import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
@@ -84,10 +85,11 @@ function checkSyntax(text: string): void {
84
85
 
85
86
  async function loadCompletions(): Promise<CodeEditorCompletion[]> {
86
87
  const fixed = props.completions ?? [];
87
- if (!props.completionsUrl) return fixed;
88
+ const url = pageRequestUrl(props.completionsUrl);
89
+ if (!url) return fixed;
88
90
  const response = await $authFetch<
89
91
  CodeEditorCompletion[] | CompletionsResponse
90
- >(props.completionsUrl).catch(() => []);
92
+ >(url).catch(() => []);
91
93
  const fetched = Array.isArray(response) ? response : (response.items ?? []);
92
94
  return [...fixed, ...fetched];
93
95
  }
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageRequestUrl } from "../../../build/composables/page/pageUrlValues";
2
3
  import { useConfirm } from "../../../composables/confirm/useConfirm";
3
4
  import DmsCopyButton from "../../copy/CopyButton.vue";
4
5
 
@@ -43,7 +44,7 @@ const masked = computed(() => {
43
44
  });
44
45
 
45
46
  async function rotate(): Promise<void> {
46
- const url = props.rotateUrl;
47
+ const url = pageRequestUrl(props.rotateUrl);
47
48
  if (!url) return;
48
49
  const confirmed = await confirm({
49
50
  title: t("dms.form.secret.rotate_title"),
@@ -95,7 +96,7 @@ async function rotate(): Promise<void> {
95
96
  </UInput>
96
97
  <DmsCopyButton v-if="props.copyable && value" :value="value" />
97
98
  <UButton
98
- v-if="props.rotateUrl"
99
+ v-if="pageRequestUrl(props.rotateUrl)"
99
100
  icon="i-ph-arrows-clockwise"
100
101
  :label="t('dms.form.secret.rotate')"
101
102
  color="neutral"
@@ -3,6 +3,7 @@
3
3
  lang="ts"
4
4
  generic="T extends ArrayOrNested<SelectMenuItem>, M extends boolean = false"
5
5
  >
6
+ import { pageRequestUrl } from "../../../build/composables/page/pageUrlValues";
6
7
  import { useForwardPropsEmits } from "reka-ui";
7
8
  import type {
8
9
  SelectMenuProps,
@@ -193,7 +194,9 @@ interface RelationItems {
193
194
  }
194
195
 
195
196
  async function fetchRelationItems(): Promise<RelationItems> {
196
- const data = await $authFetch<RelationResponse>(props.searchUrl, {
197
+ const url = pageRequestUrl(props.searchUrl);
198
+ if (!url) return { results: [], total: 0 };
199
+ const data = await $authFetch<RelationResponse>(url, {
197
200
  params: {
198
201
  search: searchTermDebounced.value || undefined,
199
202
  },
@@ -5,6 +5,18 @@ const SINGLE_COLUMN = 1;
5
5
  /** Track floor below which a column stops being usable, as a CSS length. */
6
6
  export const GRID_DEFAULT_MIN_COLUMN_WIDTH = "240px";
7
7
 
8
+ const UNITLESS_NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)$/;
9
+
10
+ /**
11
+ * A length usable inside `calc()`. A bare `0` is a valid `gap` but not a
12
+ * length in a calculation, where it would void the whole template and leave
13
+ * the grid on a single column; a unitless number reads as pixels.
14
+ */
15
+ function calcLength(value: string): string {
16
+ const trimmed = value.trim();
17
+ return UNITLESS_NUMBER.test(trimmed) ? `${trimmed}px` : trimmed;
18
+ }
19
+
8
20
  /**
9
21
  * The column track template a `Grid` and every `GridRow` inside it share.
10
22
  *
@@ -26,8 +38,8 @@ export function gridColumnsTemplate(
26
38
  minColumnWidth: string,
27
39
  ): string {
28
40
  const columns = Math.max(maxColumns, SINGLE_COLUMN);
29
- const columnShare = `(100% - ${columns - SINGLE_COLUMN} * ${gap}) / ${columns}`;
30
- return `repeat(auto-fill, minmax(min(100%, max(${minColumnWidth}, ${columnShare})), 1fr))`;
41
+ const columnShare = `(100% - ${columns - SINGLE_COLUMN} * ${calcLength(gap)}) / ${columns}`;
42
+ return `repeat(auto-fill, minmax(min(100%, max(${calcLength(minColumnWidth)}, ${columnShare})), 1fr))`;
31
43
  }
32
44
 
33
45
  /** Custom property a `Grid` / `GridRow` hands its cells: the tracks left. */
@@ -43,7 +55,7 @@ function trackBreakpoint(
43
55
  gap: string,
44
56
  minColumnWidth: string,
45
57
  ): string {
46
- return `calc(${columns} * ${minColumnWidth} + ${columns - SINGLE_COLUMN} * ${gap})`;
58
+ return `calc(${columns} * ${calcLength(minColumnWidth)} + ${columns - SINGLE_COLUMN} * ${calcLength(gap)})`;
47
59
  }
48
60
 
49
61
  /** A container name / attribute value built from any id. */
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import { computed } from "vue";
3
4
  import { regionalDateTimeFormat } from "#dms-core/app/utils/regional";
4
5
  import DmsStatusPill from "../status-pill/StatusPill.vue";
@@ -216,7 +217,7 @@ function keyOf(item: KeyValueItem, index: number): string {
216
217
  <template v-else>
217
218
  <component
218
219
  :is="item.to ? DmsAutoLink : 'span'"
219
- :to="item.to"
220
+ :to="pageLink(item.to)"
220
221
  class="min-w-0"
221
222
  :class="item.to && item.type !== 'status' && 'hover:underline'"
222
223
  >
@@ -125,6 +125,7 @@ const theme = tv({
125
125
  dot: "size-2 rounded-full bg-(--dms-accent-fill) shadow-[0_0_0_3px_var(--dms-accent-tint)]",
126
126
  body: "min-w-0 flex-1",
127
127
  title: "flex items-center gap-2",
128
+ titleText: "",
128
129
  description: "text-muted mt-0.5 text-[12.5px]",
129
130
  // The "·" closes each entry followed by another (rather than opening it),
130
131
  // so a wrapped line never starts with a separator.
@@ -188,7 +189,11 @@ const theme = tv({
188
189
  // end, so the title and meta wrap there instead.
189
190
  truncate: {
190
191
  true: {
191
- title: "truncate max-sm:whitespace-normal",
192
+ title:
193
+ "truncate flex-nowrap max-sm:flex-wrap max-sm:whitespace-normal",
194
+ // The text box, not the flex title, carries the ellipsis: a flex
195
+ // container only clips its overflowing children.
196
+ titleText: "truncate max-sm:whitespace-normal",
192
197
  meta: "flex-nowrap overflow-hidden whitespace-nowrap max-sm:flex-wrap max-sm:whitespace-normal",
193
198
  detail: "truncate max-sm:whitespace-normal",
194
199
  },
@@ -281,7 +286,9 @@ const unreadText = computed(
281
286
  :class="ui.title()"
282
287
  :title="props.truncate ? props.title : undefined"
283
288
  >
284
- <slot>{{ props.title }}</slot>
289
+ <slot>
290
+ <span :class="ui.titleText()">{{ props.title }}</span>
291
+ </slot>
285
292
  </div>
286
293
  <p v-if="hasDescription" :class="ui.description()">
287
294
  <slot name="description">{{ props.description }}</slot>
@@ -3,10 +3,11 @@ import type { SaveStatusState } from "./SaveStatus.vue";
3
3
 
4
4
  interface InstantSaveBadgeProps {
5
5
  /**
6
- * Save activity of the page, for a subtle "just saved" flash: the icon
7
- * spins while a change is being written and turns into a tick once it is,
8
- * and the pill turns red while a save failed (the control says which).
9
- * The wording never changes. Defaults to `idle` (a lightning bolt).
6
+ * Save activity of the page, for a subtle "just saved" flash: the pill
7
+ * reads "Saving" with a spinning icon while a change is being written,
8
+ * then "Saved instantly" with a tick once it is, and turns red with "Not
9
+ * saved" while a save failed (the control says which). Defaults to `idle`
10
+ * (a lightning bolt and "Saved instantly").
10
11
  */
11
12
  state?: SaveStatusState;
12
13
  }
@@ -23,6 +24,13 @@ const ICONS: Record<SaveStatusState, string> = {
23
24
  error: "i-ph-warning-circle",
24
25
  };
25
26
 
27
+ const LABELS: Record<SaveStatusState, string> = {
28
+ idle: "dms.save_bar.instant",
29
+ saving: "dms.save_bar.saving",
30
+ saved: "dms.save_bar.instant",
31
+ error: "dms.save_bar.not_saved",
32
+ };
33
+
26
34
  const TONE_CLASSES = {
27
35
  ok: "border-success/40 bg-success/10 text-success",
28
36
  error: "border-error/40 bg-error/10 text-error",
@@ -49,6 +57,6 @@ const TONE_CLASSES = {
49
57
  class="size-3.5 shrink-0"
50
58
  :class="{ 'animate-spin': props.state === 'saving' }"
51
59
  />
52
- {{ t("dms.save_bar.instant") }}
60
+ {{ t(LABELS[props.state]) }}
53
61
  </span>
54
62
  </template>
@@ -40,7 +40,7 @@ interface SaveBarProps {
40
40
  kind?: FormKind;
41
41
  /**
42
42
  * Whether anything in the form can be changed: an `action` form nothing in
43
- * which can be changed shows its submit alone, all the time.
43
+ * which can be changed never offers Reset.
44
44
  */
45
45
  resettable?: boolean;
46
46
  }
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import { computed } from "vue";
3
4
  import type { IconWellTone } from "../icon-well/IconWell.vue";
4
5
  import DmsStatCell from "../../build/components/stat/StatCell.vue";
@@ -146,7 +147,7 @@ function wellTone(item: StatGroupItem): IconWellTone {
146
147
  :icon="item.icon"
147
148
  :detail="item.detail"
148
149
  :detail-tone="item.detailTone"
149
- :to="item.to"
150
+ :to="pageLink(item.to)"
150
151
  size="sm"
151
152
  :tone="wellTone(item)"
152
153
  :loading="props.loading"
@@ -175,7 +176,7 @@ function wellTone(item: StatGroupItem): IconWellTone {
175
176
  :icon="item.icon"
176
177
  :detail="item.detail"
177
178
  :detail-tone="item.detailTone"
178
- :to="item.to"
179
+ :to="pageLink(item.to)"
179
180
  :tone="wellTone(item)"
180
181
  :loading="props.loading"
181
182
  stack
@@ -1,20 +1,28 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- buildCascaderPathLabels,
4
3
  CASCADER_FETCH_LIMIT,
5
4
  CASCADER_PATH_SEPARATOR,
5
+ cascaderValuePath,
6
6
  resolveCascaderKeyMapping,
7
7
  type CascaderKeyMapping,
8
8
  } from "../../utils/cascader";
9
+ import { MAX_RELATION_BADGES } from "../../build/composables/data-types/relationBadges";
9
10
 
10
11
  const DEFAULT_VALUE_KEY = "_id";
11
12
 
12
13
  interface CascaderPathProps {
14
+ /** One picked value, or the list a multiple cascader holds. */
13
15
  value: unknown;
14
16
  searchUrl?: string;
15
17
  keyMapping?: Partial<CascaderKeyMapping>;
16
18
  }
17
19
 
20
+ /** A picked value of a multiple cascader: its name, its path on hover. */
21
+ interface CascaderBadge {
22
+ label: string;
23
+ title: string;
24
+ }
25
+
18
26
  const props = defineProps<CascaderPathProps>();
19
27
 
20
28
  const { $authFetch } = useAuthFetch();
@@ -31,22 +39,6 @@ const keyMapping = computed(() =>
31
39
  }),
32
40
  );
33
41
 
34
- const valueObject = computed(() =>
35
- isObject(props.value) ? (props.value as Record<string, unknown>) : undefined,
36
- );
37
-
38
- const valueId = computed(() => {
39
- const raw = valueObject.value
40
- ? valueObject.value[keyMapping.value.value]
41
- : props.value;
42
- return raw === undefined || raw === null ? undefined : String(raw);
43
- });
44
-
45
- const ownLabel = computed(() => {
46
- const label = valueObject.value?.[keyMapping.value.label];
47
- return label === undefined || label === null ? undefined : String(label);
48
- });
49
-
50
42
  const { data } = await useDmsAsyncData(
51
43
  `cascader-path-${props.searchUrl}`,
52
44
  () =>
@@ -58,21 +50,51 @@ const { data } = await useDmsAsyncData(
58
50
  { lazy: true },
59
51
  );
60
52
 
61
- const pathLabel = computed(() => {
62
- if (!valueId.value) return ownLabel.value ?? "";
63
- const rows = data.value?.results;
64
- if (rows) {
65
- const labels = buildCascaderPathLabels(
66
- rows,
67
- keyMapping.value,
68
- valueId.value,
69
- );
70
- if (labels.length > 0) return labels.join(CASCADER_PATH_SEPARATOR);
71
- }
72
- return ownLabel.value ?? valueId.value;
73
- });
53
+ const pathOf = (item: unknown): string[] =>
54
+ cascaderValuePath(item, keyMapping.value, data.value?.results);
55
+
56
+ const pathLabel = computed(() =>
57
+ pathOf(props.value).join(CASCADER_PATH_SEPARATOR),
58
+ );
59
+
60
+ const badges = computed<CascaderBadge[]>(() =>
61
+ (Array.isArray(props.value) ? props.value : [])
62
+ .map(pathOf)
63
+ .filter((path) => path.length > 0)
64
+ .map((path) => ({
65
+ label: path.at(-1) ?? "",
66
+ title: path.join(CASCADER_PATH_SEPARATOR),
67
+ })),
68
+ );
69
+ const visibleBadges = computed(() =>
70
+ badges.value.slice(0, MAX_RELATION_BADGES),
71
+ );
72
+ const hiddenBadges = computed(() => badges.value.slice(MAX_RELATION_BADGES));
74
73
  </script>
75
74
 
76
75
  <template>
77
- <span class="truncate" :title="pathLabel">{{ pathLabel }}</span>
76
+ <span
77
+ v-if="Array.isArray(props.value)"
78
+ class="inline-flex flex-wrap items-center gap-1"
79
+ >
80
+ <UBadge
81
+ v-for="badge in visibleBadges"
82
+ :key="badge.title"
83
+ :label="badge.label"
84
+ :title="badge.title"
85
+ color="neutral"
86
+ variant="subtle"
87
+ size="sm"
88
+ class="max-w-40"
89
+ />
90
+ <UBadge
91
+ v-if="hiddenBadges.length"
92
+ :label="`+${hiddenBadges.length}`"
93
+ :title="hiddenBadges.map((badge) => badge.title).join(', ')"
94
+ color="neutral"
95
+ variant="subtle"
96
+ size="sm"
97
+ />
98
+ </span>
99
+ <span v-else class="truncate" :title="pathLabel">{{ pathLabel }}</span>
78
100
  </template>
@@ -267,6 +267,11 @@ const kanbanGroupByOptions = kanbanEligibleColumns.map((column) => ({
267
267
  const { displays: registeredDisplays, getById } = useTableViewDisplays();
268
268
 
269
269
  const offeredDisplayIds = new Set(resolvedDisplays.map((d) => d.id));
270
+ // Where a stale or unavailable choice lands: the grid, unless the table view
271
+ // drops it, then its default display.
272
+ const fallbackDisplayId = offeredDisplayIds.has(TABLE_DISPLAY_ID)
273
+ ? TABLE_DISPLAY_ID
274
+ : defaultDisplay;
270
275
  const resolvedDisplay = (id: string): TableViewDisplayConfig | undefined =>
271
276
  resolvedDisplays.find((d) => d.id === id);
272
277
  const optionsForDisplay = (id: string): Record<string, unknown> | undefined =>
@@ -293,7 +298,7 @@ const activeDisplayId = ref<string>(
293
298
  ? defaultDisplay
294
299
  : offeredDisplayIds.has(persistedDisplay)
295
300
  ? persistedDisplay
296
- : TABLE_DISPLAY_ID,
301
+ : fallbackDisplayId,
297
302
  );
298
303
 
299
304
  /** A display the backend config renders by itself, with no client plugin. */
@@ -587,7 +592,7 @@ const {
587
592
  ),
588
593
  display: offeredDisplayIds.has(defaultDisplay)
589
594
  ? defaultDisplay
590
- : TABLE_DISPLAY_ID,
595
+ : fallbackDisplayId,
591
596
  density: props.density ?? DEFAULT_DENSITY,
592
597
  displays: offeredDisplayIds,
593
598
  },
@@ -1296,6 +1301,7 @@ const {
1296
1301
  rowNavigation: {
1297
1302
  rows: () => (shownResults.value ?? []) as Data[],
1298
1303
  rowIdKey: props.rowIdKey ?? ROW_ID_DEFAULT_KEY,
1304
+ getRow: (id) => tableRows.getRow(id) as Promise<Data | undefined>,
1299
1305
  },
1300
1306
  recordScope: urlScope,
1301
1307
  onRecordShown: (id) => recordLink?.shown(id),
@@ -1854,6 +1860,7 @@ const displayContext = computed(
1854
1860
  items: shownResults.value ?? EMPTY_ITEMS,
1855
1861
  columns: props.columns,
1856
1862
  loading: isListLoading.value,
1863
+ firstPageLoading: isFirstPageLoading.value,
1857
1864
  selection: { ids: selectedIds.value, ...displaySelection },
1858
1865
  pagination: {
1859
1866
  pageIndex: paginationState.value.pageIndex,
@@ -2070,14 +2077,14 @@ onMounted(() => {
2070
2077
  checkUrlParameter();
2071
2078
  void loadRelationQuickFilters();
2072
2079
  void refreshLinkTabCounts();
2073
- // The registry is client-only; once hydrated, degrade to the table display if
2074
- // the active id is no longer available (e.g. kanban with no eligible column).
2080
+ // The registry is client-only; once hydrated, degrade to the fallback display
2081
+ // if the active id is no longer available (e.g. kanban with no eligible column).
2075
2082
  if (
2076
- activeDisplayId.value !== TABLE_DISPLAY_ID &&
2083
+ activeDisplayId.value !== fallbackDisplayId &&
2077
2084
  !isConfigRenderedDisplay(activeDisplayId.value) &&
2078
2085
  !availableDisplays.value.some((d) => d.id === activeDisplayId.value)
2079
2086
  ) {
2080
- activeDisplayId.value = TABLE_DISPLAY_ID;
2087
+ activeDisplayId.value = fallbackDisplayId;
2081
2088
  }
2082
2089
  });
2083
2090
  </script>
@@ -2123,6 +2130,7 @@ onMounted(() => {
2123
2130
  :tabs="tabsWithCount"
2124
2131
  :initial-column-visibility="initialVisibility"
2125
2132
  :loading="isListLoading"
2133
+ :first-page-loading="isFirstPageLoading"
2126
2134
  :load-error="listLoadError"
2127
2135
  :data="shownResults || []"
2128
2136
  :pagination-options="{ manualPagination: true, rowCount: data?.total }"
@@ -9,7 +9,14 @@ import {
9
9
 
10
10
  const DEFAULT_BAR_RADIUS = 4;
11
11
  const DEFAULT_BAR_WIDTH_PCT = "60%";
12
- const DEFAULT_DONUT_HOLE = "76%";
12
+ const HUNDRED_PERCENT = 100;
13
+ /** Ring thickness, in percent of the radius, of a donut without `arcWidth`. */
14
+ const DEFAULT_ARC_WIDTH = 24;
15
+ /**
16
+ * The thinnest ring, in percent of the radius, a slice label ("33.0%") fits
17
+ * in: on a thinner one its white text runs past the ring onto the card.
18
+ */
19
+ const MIN_LABELLED_ARC_WIDTH = 35;
13
20
  const DEFAULT_RADIAL_HOLE = "60%";
14
21
  const CENTER_NAME_FONT_SIZE = "10.5px";
15
22
  const CENTER_NAME_FONT_WEIGHT = 600;
@@ -17,7 +24,6 @@ const CENTER_VALUE_FONT_SIZE = "20px";
17
24
  const CENTER_VALUE_FONT_WEIGHT = 650;
18
25
  const HEATMAP_RADIUS = 4;
19
26
  const HEATMAP_DEFAULT_INTENSITY = 0.6;
20
- const HUNDRED_PERCENT = 100;
21
27
 
22
28
  type PlotBuilder = (props: UseApexChartInput) => Record<string, unknown>;
23
29
 
@@ -47,8 +53,13 @@ function buildBarPlotOptions(
47
53
  }
48
54
 
49
55
  function donutSizeFor(arcWidth: number | undefined): string {
50
- if (arcWidth === undefined) return DEFAULT_DONUT_HOLE;
51
- return `${HUNDRED_PERCENT - arcWidth}%`;
56
+ return `${HUNDRED_PERCENT - (arcWidth ?? DEFAULT_ARC_WIDTH)}%`;
57
+ }
58
+
59
+ /** Whether a slice label fits inside the chart's ring; always true off donuts. */
60
+ export function ringFitsSliceLabels(input: UseApexChartInput): boolean {
61
+ if (input.type !== "donut") return true;
62
+ return (input.arcWidth ?? DEFAULT_ARC_WIDTH) >= MIN_LABELLED_ARC_WIDTH;
52
63
  }
53
64
 
54
65
  /** v2 donut centre: mono eyebrow over a 20px value. */
@@ -0,0 +1,123 @@
1
+ import { APEX_TYPE_CONFIGS } from "./apexTypeConfigs";
2
+ import type { ChartSeries } from "./types";
3
+ import type { UseApexChartInput } from "./useApexChart.types";
4
+
5
+ const DEFAULT_Y_RANGE_PADDING = 0.08;
6
+ const ZERO_BOUND_MAGNITUDE = 1;
7
+ const STEP_DIVISIONS = 10;
8
+ const NICE_STEP_FACTORS = [1, 2, 5, 10];
9
+ const DECIMAL_BASE = 10;
10
+ /** Enough digits to keep every real value while dropping binary float noise. */
11
+ const SIGNIFICANT_DIGITS = 12;
12
+
13
+ const DOWNWARD = -1;
14
+ const UPWARD = 1;
15
+
16
+ type Rounding = (value: number) => number;
17
+
18
+ /** Bounds Apex resolves from the extent it measured on the drawn series. */
19
+ interface AutoYBounds {
20
+ min?: (nativeMin: number) => number;
21
+ max?: (nativeMax: number) => number;
22
+ }
23
+
24
+ function isFiniteNumber(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value);
26
+ }
27
+
28
+ function withoutFloatNoise(value: number): number {
29
+ return Number(value.toPrecision(SIGNIFICANT_DIGITS));
30
+ }
31
+
32
+ function pointValues(point: ChartSeries["data"][number]): unknown[] {
33
+ if (typeof point === "number") return [point];
34
+ if (!point || typeof point !== "object") return [];
35
+ return Array.isArray(point.y) ? point.y : [point.y];
36
+ }
37
+
38
+ function seriesValues(series: ChartSeries[]): number[] {
39
+ return series.flatMap((entry) =>
40
+ entry.data.flatMap(pointValues).filter(isFiniteNumber),
41
+ );
42
+ }
43
+
44
+ function annotationValues(input: UseApexChartInput): number[] {
45
+ return (input.annotations ?? [])
46
+ .flatMap((annotation) => [annotation.y, annotation.y2])
47
+ .filter(isFiniteNumber);
48
+ }
49
+
50
+ function resolvePadding(input: UseApexChartInput): number {
51
+ const requested = input.autoYRange?.padding ?? DEFAULT_Y_RANGE_PADDING;
52
+ return Number.isFinite(requested) && requested >= 0
53
+ ? requested
54
+ : DEFAULT_Y_RANGE_PADDING;
55
+ }
56
+
57
+ function padOutward(bound: number, padding: number, direction: number): number {
58
+ return bound + direction * (Math.abs(bound) || ZERO_BOUND_MAGNITUDE) * padding;
59
+ }
60
+
61
+ /** The smallest 1, 2 or 5 times a power of ten that covers `span`. */
62
+ export function niceStep(span: number): number {
63
+ const magnitude = DECIMAL_BASE ** Math.floor(Math.log10(span));
64
+ const factor =
65
+ NICE_STEP_FACTORS.find((candidate) => candidate * magnitude >= span) ??
66
+ DECIMAL_BASE;
67
+ return withoutFloatNoise(factor * magnitude);
68
+ }
69
+
70
+ function snapToStep(value: number, step: number, round: Rounding): number {
71
+ return withoutFloatNoise(round(withoutFloatNoise(value / step)) * step);
72
+ }
73
+
74
+ /**
75
+ * The tick step for the padded extent of what is known up front: the series
76
+ * and the annotations. Apex only lays round ticks between bounds that are
77
+ * multiples of a round step; padded raw bounds give 603.6, 437.2…
78
+ */
79
+ function estimateStep(values: number[], padding: number): number | undefined {
80
+ if (values.length === 0) return undefined;
81
+ const lower = padOutward(Math.min(...values), padding, DOWNWARD);
82
+ const upper = padOutward(Math.max(...values), padding, UPWARD);
83
+ const span = upper - lower;
84
+ return span > 0 ? niceStep(span / STEP_DIVISIONS) : undefined;
85
+ }
86
+
87
+ function stepForBound(bound: number, knownStep: number | undefined): number {
88
+ if (knownStep !== undefined) return knownStep;
89
+ return niceStep((Math.abs(bound) || ZERO_BOUND_MAGNITUDE) / STEP_DIVISIONS);
90
+ }
91
+
92
+ /**
93
+ * Y bounds that take in the annotations, padded outward and rounded to a round
94
+ * step so the axis ticks read 100, 200… rather than the raw padded extent.
95
+ */
96
+ export function buildAutoYRange(input: UseApexChartInput): AutoYBounds {
97
+ if (!input.autoYRange || APEX_TYPE_CONFIGS[input.type].isCircular) return {};
98
+ const padding = resolvePadding(input);
99
+ const annotations = annotationValues(input);
100
+ const knownStep = estimateStep(
101
+ [...seriesValues(input.series), ...annotations],
102
+ padding,
103
+ );
104
+ const bound = (native: number, direction: number, round: Rounding) => {
105
+ const extent = [native, ...annotations];
106
+ const raw = direction === DOWNWARD ? Math.min(...extent) : Math.max(...extent);
107
+ const padded = padOutward(raw, padding, direction);
108
+ return snapToStep(padded, stepForBound(padded, knownStep), round);
109
+ };
110
+ return {
111
+ min: (nativeMin) => bound(nativeMin, DOWNWARD, Math.floor),
112
+ max: (nativeMax) => bound(nativeMax, UPWARD, Math.ceil),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Axis labels without a format of their own. Apex writes them with as many
118
+ * decimals as the most precise data point, so 160 reads "160.00000000000000"
119
+ * once one price carries float noise.
120
+ */
121
+ export function formatPlainAxisValue(value: number): string {
122
+ return isFiniteNumber(value) ? String(withoutFloatNoise(value)) : String(value);
123
+ }