@antelopejs/dms 0.7.11 → 0.7.13

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 (79) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/implementations/dms/page.js +14 -1
  3. package/dist/implementations/dms/page.js.map +1 -1
  4. package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
  5. package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
  6. package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  10. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  13. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  14. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  15. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  16. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  17. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  18. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +23 -2
  19. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
  20. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
  21. package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
  22. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  23. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  24. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  29. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  30. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  31. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  32. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  33. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  34. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  35. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  36. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  37. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +7 -1
  38. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  39. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  40. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  41. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  42. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  43. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +2 -0
  44. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  45. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  46. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  50. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  51. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  52. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  53. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  54. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  55. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  56. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  57. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  58. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  59. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  60. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
  61. package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
  62. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  63. package/frontend-vue/tests/code-block.test.ts +28 -0
  64. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  65. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  66. package/frontend-vue/tests/header-actions.test.ts +1 -1
  67. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  68. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  69. package/frontend-vue/tests/module-routing.test.ts +158 -0
  70. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  71. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  72. package/frontend-vue/tests/regional-format.test.ts +69 -2
  73. package/frontend-vue/tests/scroll-to-block.test.ts +171 -0
  74. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  75. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  76. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  77. package/package.json +2 -2
  78. package/skills/dms/SKILL.md +28 -4
  79. package/skills/dms-module/SKILL.md +2 -1
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, resolveComponent } from "vue";
2
+ import { computed, ref, resolveComponent } from "vue";
3
3
  import DmsActivityItem from "../activity/ActivityItem.vue";
4
4
  import DmsBlockActions, {
5
5
  type BlockAction,
@@ -48,6 +48,16 @@ interface ActivityFeedProps extends Partial<DefaultComponentProps> {
48
48
  groupByDay?: boolean;
49
49
  /** Shows at most this many entries. */
50
50
  maxItems?: number;
51
+ /**
52
+ * Shows the first this many entries, and a "Show N more" button under them
53
+ * that unfolds the rest (up to `maxItems`), and folds them back.
54
+ */
55
+ visibleItems?: number;
56
+ /**
57
+ * Entries wrap their title, details and detail line rather than cutting
58
+ * each to one line with an ellipsis.
59
+ */
60
+ wrap?: boolean;
51
61
  /**
52
62
  * Fills its grid cell instead of growing with its entries, which scroll
53
63
  * under the fixed head: the cards beside it set the row's height.
@@ -80,6 +90,8 @@ const props = withDefaults(defineProps<ActivityFeedProps>(), {
80
90
  realtimeTopic: undefined,
81
91
  groupByDay: true,
82
92
  maxItems: undefined,
93
+ visibleItems: undefined,
94
+ wrap: false,
83
95
  fillHeight: false,
84
96
  skeletonCount: undefined,
85
97
  actions: () => [],
@@ -119,13 +131,36 @@ const entries = computed<ActivityFeedItem[]>(() =>
119
131
  props.maxItems ? list.value.slice(0, props.maxItems) : list.value,
120
132
  );
121
133
 
134
+ // Folded, the feed shows its first `visibleItems` entries; the button under
135
+ // them unfolds the rest and folds them back.
136
+ const isUnfolded = ref(false);
137
+ const foldedCount = computed(() =>
138
+ props.visibleItems
139
+ ? Math.max(0, entries.value.length - props.visibleItems)
140
+ : 0,
141
+ );
142
+ const shownEntries = computed<ActivityFeedItem[]>(() =>
143
+ foldedCount.value > 0 && !isUnfolded.value
144
+ ? entries.value.slice(0, props.visibleItems)
145
+ : entries.value,
146
+ );
147
+ const foldLabel = computed(() =>
148
+ isUnfolded.value
149
+ ? t("dms.activity_feed.show_fewer")
150
+ : t(
151
+ "dms.activity_feed.show_more",
152
+ { count: foldedCount.value },
153
+ foldedCount.value,
154
+ ),
155
+ );
156
+
122
157
  const days = computed<ActivityFeedDay[]>(() =>
123
158
  props.groupByDay
124
- ? groupActivityByDay(entries.value, locale.value, {
159
+ ? groupActivityByDay(shownEntries.value, locale.value, {
125
160
  today: t("dms.activity_feed.today"),
126
161
  yesterday: t("dms.activity_feed.yesterday"),
127
162
  })
128
- : [{ key: "all", items: entries.value }],
163
+ : [{ key: "all", items: shownEntries.value }],
129
164
  );
130
165
 
131
166
  const trailingOf = (item: ActivityFeedItem): string | undefined => {
@@ -158,11 +193,12 @@ const metaOf = (item: ActivityFeedItem): string | undefined =>
158
193
  const isFirstLoad = isPending;
159
194
 
160
195
  // The placeholder takes the feed's loaded shape: as many rows as it will show
161
- // (`skeletonCount`, else `maxItems`; never more than `maxItems`), under day
162
- // separators when the entries are grouped by day.
196
+ // (`skeletonCount`, else `visibleItems` or `maxItems`; never more than
197
+ // those), under day separators when the entries are grouped by day.
163
198
  const skeletonDays = computed<number[][]>(() => {
164
- const wanted = props.skeletonCount ?? props.maxItems ?? SKELETON_ROWS;
165
- const count = props.maxItems ? Math.min(wanted, props.maxItems) : wanted;
199
+ const shown = props.visibleItems ?? props.maxItems;
200
+ const wanted = props.skeletonCount ?? shown ?? SKELETON_ROWS;
201
+ const count = shown ? Math.min(wanted, shown) : wanted;
166
202
  const perDay = props.groupByDay ? SKELETON_ROWS_PER_DAY : count;
167
203
  const days: number[][] = [];
168
204
  for (let start = 0; start < count; start += perDay) {
@@ -328,10 +364,25 @@ const wrapperProps = computed(() =>
328
364
  :trailing="trailingOf(item)"
329
365
  :unread="item.unread"
330
366
  :mono="props.mono"
367
+ :wrap="props.wrap"
331
368
  :to="item.to"
332
369
  :interactive="!!item.to"
333
370
  />
334
371
  </section>
372
+ <div
373
+ v-if="foldedCount > 0"
374
+ class="dms-activity-feed__fold border-t border-(--ui-border-muted) px-[18px] pt-2 pb-1"
375
+ >
376
+ <UButton
377
+ :label="foldLabel"
378
+ :aria-expanded="isUnfolded"
379
+ color="neutral"
380
+ variant="link"
381
+ size="xs"
382
+ class="p-0 font-medium"
383
+ @click="isUnfolded = !isUnfolded"
384
+ />
385
+ </div>
335
386
  </div>
336
387
  </div>
337
388
  </component>
@@ -4,6 +4,7 @@ import DmsCodeSnippet from "../code-snippet/CodeSnippet.vue";
4
4
  import DmsBlockStatus from "../../build/components/blocks/BlockStatus.vue";
5
5
  import { useChartFetch } from "../../composables/chart/useChartFetch";
6
6
  import { readFetchNotFound } from "../../build/utils/fetchNotFound";
7
+ import { PLAIN_CODE_LANGUAGE } from "../../build/utils/codeLanguages";
7
8
  import { useWatch } from "../../../../dms-core/app/composables/watch/useWatch";
8
9
  import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
9
10
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
@@ -58,7 +59,10 @@ const { data, isLoading, error, refresh } = useChartFetch<CodeBlockResponse>({
58
59
  });
59
60
 
60
61
  const code = computed(() => data.value?.code ?? props.code);
61
- const language = computed(() => data.value?.language ?? props.language);
62
+ // The block always heads its code with its language, plain text included.
63
+ const language = computed(
64
+ () => data.value?.language ?? props.language ?? PLAIN_CODE_LANGUAGE,
65
+ );
62
66
  const title = computed(() =>
63
67
  props.title ? processText(props.title) : undefined,
64
68
  );
@@ -5,6 +5,7 @@ import DmsEmptyState, {
5
5
  type EmptyStateVariant,
6
6
  } from "../empty-state/EmptyState.vue";
7
7
  import DmsCodeSnippet from "../code-snippet/CodeSnippet.vue";
8
+ import { PLAIN_CODE_LANGUAGE } from "../../build/utils/codeLanguages";
8
9
  import DmsBlockActions, {
9
10
  type BlockAction,
10
11
  } from "../../build/components/blocks/BlockActions.vue";
@@ -75,7 +76,7 @@ const description = computed(() =>
75
76
  <template v-if="props.code" #content>
76
77
  <DmsCodeSnippet
77
78
  :code="props.code.content"
78
- :language="props.code.language"
79
+ :language="props.code.language ?? PLAIN_CODE_LANGUAGE"
79
80
  :copy="props.code.copy ?? true"
80
81
  />
81
82
  </template>
@@ -5,11 +5,11 @@ import { usePageLive } from "../../composables/live/usePageLive";
5
5
  import { formatElapsed } from "../../build/composables/live/formatElapsed";
6
6
  import { useLiveToggleShortcut } from "../../build/composables/live/useLiveToggleShortcut";
7
7
  import { HIDDEN_TAB_NOTE } from "../../build/composables/live/pageLiveController";
8
+ import type { ConfirmTranslate } from "../../build/composables/confirm/confirmDialogTexts";
8
9
  import {
9
- resolveConfirmText,
10
- type ConfirmTranslate,
11
- } from "../../build/composables/confirm/confirmDialogTexts";
12
- import type { PageLiveNote, PageLiveNoteTone } from "../../types/live";
10
+ missedRowsText,
11
+ showLiveNote,
12
+ } from "../../build/composables/live/liveNotes";
13
13
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
14
14
 
15
15
  interface LiveToggleBlockProps extends DefaultComponentProps {
@@ -40,11 +40,6 @@ const ALIGN_CLASSES: Record<
40
40
  center: "justify-center",
41
41
  right: "justify-end",
42
42
  };
43
- const TONE_CLASSES: Record<PageLiveNoteTone, string> = {
44
- neutral: "",
45
- warning: "text-warning",
46
- error: "text-error",
47
- };
48
43
  const PILL_BUTTON_CLASS =
49
44
  "focus-visible:outline-primary flex items-center gap-1.5 rounded-[5px] px-2 py-1 text-[12px] font-medium transition-colors focus-visible:outline-2";
50
45
  const PILL_IDLE_CLASS = "text-muted hover:text-default hover:bg-elevated";
@@ -90,15 +85,10 @@ const updatedLabel = computed(() => {
90
85
  : updated;
91
86
  });
92
87
 
93
- const noteText = (note: PageLiveNote) =>
94
- resolveConfirmText(note.text, note.params ?? {}, translate);
95
-
96
88
  const missedText = computed(() => {
97
89
  const count = live.missedCount.value;
98
90
  if (count === 0) return "";
99
- return props.missedLabel
100
- ? resolveConfirmText(props.missedLabel, { count }, translate)
101
- : t("dms.live.new_rows", { count }, count);
91
+ return missedRowsText(count, props.missedLabel, translate);
102
92
  });
103
93
 
104
94
  // "Paused · typing in the Tester · 40 new requests", the detail apart: it
@@ -107,7 +97,7 @@ const pausedText = computed(() => {
107
97
  const reason = live.pauseReason.value;
108
98
  return [
109
99
  t("dms.live.paused"),
110
- reason ? noteText(reason) : "",
100
+ reason ? showLiveNote(reason, translate).text : "",
111
101
  missedText.value,
112
102
  ]
113
103
  .filter((part) => part !== "")
@@ -117,10 +107,7 @@ const pausedText = computed(() => {
117
107
  const detail = computed(() => {
118
108
  const note = live.missedDetail.value;
119
109
  if (!note || live.missedCount.value === 0) return null;
120
- return {
121
- text: noteText(note),
122
- toneClass: TONE_CLASSES[note.tone ?? "neutral"],
123
- };
110
+ return showLiveNote(note, translate);
124
111
  });
125
112
 
126
113
  // A hidden tab resumes by itself when it shows again: no button for it.
@@ -21,6 +21,7 @@ import type {
21
21
  ChartAnnotation,
22
22
  ChartAutoYRange,
23
23
  ChartCurve,
24
+ ChartLegendPosition,
24
25
  ChartResponse,
25
26
  ChartSeries,
26
27
  ChartType,
@@ -48,6 +49,7 @@ interface Props extends DefaultComponentProps {
48
49
  showGrid?: boolean;
49
50
  showTooltip?: boolean;
50
51
  showLegend?: boolean;
52
+ legendPosition?: ChartLegendPosition;
51
53
  smooth?: boolean;
52
54
  curve?: ChartCurve;
53
55
  stacked?: boolean;
@@ -238,6 +240,7 @@ const apex = useApexChart(() => ({
238
240
  showGrid: props.showGrid,
239
241
  showTooltip: props.showTooltip,
240
242
  showLegend: effectiveShowLegend.value,
243
+ legendPosition: props.legendPosition,
241
244
  smooth: props.smooth,
242
245
  curve: props.curve,
243
246
  stacked: props.stacked,
@@ -29,11 +29,14 @@ interface Props extends DefaultComponentProps {
29
29
  fetchUrl?: string;
30
30
  fetchUrlMethod?: string;
31
31
  periodScope?: string;
32
+ realtimeTopic?: string | string[];
32
33
  valueFormat?: ValueFormat;
33
34
  currencyCode?: string;
34
35
  valuePrecision?: ValuePrecision;
35
36
  showDelta?: boolean;
36
37
  showLegend?: boolean;
38
+ /** The wrapped chart draws its own legend, naming each series. */
39
+ showSeriesLegend?: boolean;
37
40
  primaryLabel?: string;
38
41
  comparisonLabel?: string;
39
42
  }
@@ -53,6 +56,7 @@ const DEFAULT_COMPARISON_LABEL_KEY = "dms.chart.legend_comparison";
53
56
  const props = withDefaults(defineProps<Props>(), {
54
57
  showDelta: true,
55
58
  showLegend: true,
59
+ showSeriesLegend: false,
56
60
  valueFormat: "compact",
57
61
  currencyCode: "EUR",
58
62
  });
@@ -71,6 +75,7 @@ const { data, isLoading } = useChartFetch<ChartCardResponse>({
71
75
  fetchUrlMethod: props.fetchUrlMethod,
72
76
  routeParams: () => props.routeParams,
73
77
  periodScope: props.periodScope,
78
+ realtimeTopic: props.realtimeTopic,
74
79
  watchSource: () => watchKey.value,
75
80
  });
76
81
 
@@ -126,7 +131,7 @@ const nestedContext = reactive<NestedChartContext>({
126
131
  cardSeries: [],
127
132
  cardComparisonSeries: [],
128
133
  isLoading: false,
129
- hideLegend: true,
134
+ hideLegend: !props.showSeriesLegend,
130
135
  valueFormat: props.valueFormat,
131
136
  currencyCode: props.currencyCode,
132
137
  valuePrecision: props.valuePrecision,
@@ -138,6 +143,7 @@ watchEffect(() => {
138
143
  nestedContext.cardSeries = cardSeries.value;
139
144
  nestedContext.cardComparisonSeries = cardComparisonSeries.value;
140
145
  nestedContext.isLoading = isLoading.value;
146
+ nestedContext.hideLegend = !props.showSeriesLegend;
141
147
  nestedContext.valueFormat = props.valueFormat;
142
148
  nestedContext.currencyCode = props.currencyCode;
143
149
  nestedContext.valuePrecision = props.valuePrecision;
@@ -10,7 +10,17 @@ import type { CodeToken, highlightCode } from "../../build/utils/codeHighlight";
10
10
 
11
11
  // Read-only code in the editor's look: a head with the title, the language
12
12
  // and a copy button, then the code highlighted by the editor's languages.
13
- // Until the highlighter loads (and on the server) the code shows as is.
13
+ // Without a title or a language there is no head: the copy button floats in
14
+ // the code's corner. Until the highlighter loads (and on the server) the
15
+ // code shows as is.
16
+
17
+ /**
18
+ * How long lines break: `false` scrolls them sideways, `true` wraps them
19
+ * between words, `"anywhere"` also breaks a word too long for the line (a
20
+ * URL, a token, a minified payload).
21
+ */
22
+ type CodeSnippetWrap = boolean | "anywhere";
23
+
14
24
  interface CodeSnippetProps {
15
25
  /** The code, verbatim. */
16
26
  code: string;
@@ -25,8 +35,13 @@ interface CodeSnippetProps {
25
35
  title?: string;
26
36
  /** Lines shown before the code scrolls; all of them when unset. */
27
37
  maxLines?: number;
38
+ /**
39
+ * A CSS height the code scrolls past (`"20rem"`), when `maxLines` is not
40
+ * set: for a payload whose lines differ in length once wrapped.
41
+ */
42
+ maxHeight?: string;
28
43
  /** Wraps long lines instead of scrolling sideways. */
29
- wrap?: boolean;
44
+ wrap?: CodeSnippetWrap;
30
45
  }
31
46
 
32
47
  const props = withDefaults(defineProps<CodeSnippetProps>(), {
@@ -34,6 +49,7 @@ const props = withDefaults(defineProps<CodeSnippetProps>(), {
34
49
  copy: true,
35
50
  title: undefined,
36
51
  maxLines: undefined,
52
+ maxHeight: undefined,
37
53
  wrap: false,
38
54
  });
39
55
 
@@ -41,24 +57,39 @@ const LINE_HEIGHT_PX = 19;
41
57
  // The code's top and bottom padding (`py-2`), around its lines.
42
58
  const CODE_PADDING_PX = 16;
43
59
 
60
+ const WRAP_CLASSES: Record<"false" | "true" | "anywhere", string> = {
61
+ false: "whitespace-pre",
62
+ true: "break-words whitespace-pre-wrap",
63
+ anywhere: "whitespace-pre-wrap [overflow-wrap:anywhere]",
64
+ };
65
+
44
66
  const highlight = shallowRef<typeof highlightCode | null>(null);
45
67
 
46
68
  const language = computed(() => resolveCodeLanguage(props.language));
69
+ // A language given, even `text`, is named in a head; without a title either,
70
+ // a head would only hold the copy button.
71
+ const isHeadless = computed(() => !props.title && props.language === undefined);
47
72
  const hasHead = computed(
48
- () => !!props.title || props.copy || language.value !== PLAIN_CODE_LANGUAGE,
73
+ () =>
74
+ !isHeadless.value &&
75
+ (!!props.title || props.copy || language.value !== PLAIN_CODE_LANGUAGE),
76
+ );
77
+ const hasFloatingCopy = computed(() => isHeadless.value && props.copy);
78
+ const wrapClass = computed(
79
+ () => WRAP_CLASSES[String(props.wrap) as keyof typeof WRAP_CLASSES],
49
80
  );
50
81
  const tokens = computed<CodeToken[]>(() =>
51
82
  highlight.value
52
83
  ? highlight.value(props.code, language.value)
53
84
  : [{ text: props.code }],
54
85
  );
55
- const sizeStyle = computed(() =>
56
- props.maxLines
57
- ? {
58
- maxHeight: `${props.maxLines * LINE_HEIGHT_PX + CODE_PADDING_PX}px`,
59
- }
60
- : undefined,
61
- );
86
+ const sizeStyle = computed(() => {
87
+ if (props.maxLines)
88
+ return {
89
+ maxHeight: `${props.maxLines * LINE_HEIGHT_PX + CODE_PADDING_PX}px`,
90
+ };
91
+ return props.maxHeight ? { maxHeight: props.maxHeight } : undefined;
92
+ });
62
93
 
63
94
  onMounted(async () => {
64
95
  const module = await import("../../build/utils/codeHighlight");
@@ -68,7 +99,7 @@ onMounted(async () => {
68
99
 
69
100
  <template>
70
101
  <div
71
- class="border-default min-w-0 overflow-hidden rounded-[10px] border bg-(--ui-bg)"
102
+ class="border-default relative min-w-0 overflow-hidden rounded-[10px] border bg-(--ui-bg)"
72
103
  >
73
104
  <div
74
105
  v-if="hasHead"
@@ -85,10 +116,17 @@ onMounted(async () => {
85
116
  </span>
86
117
  <DmsCopyButton v-if="props.copy" :value="props.code" />
87
118
  </div>
119
+ <!-- Kept clear of the code: the code's end padding leaves it room. -->
120
+ <DmsCopyButton
121
+ v-if="hasFloatingCopy"
122
+ :value="props.code"
123
+ class="absolute end-1 top-1"
124
+ data-floating-copy
125
+ />
88
126
  <!-- Focusable: a keyboard scrolls the code when it overflows. -->
89
127
  <pre
90
- class="text-highlighted m-0 overflow-auto px-3.5 py-2 font-mono text-[12.5px] leading-[19px]"
91
- :class="props.wrap ? 'break-words whitespace-pre-wrap' : 'whitespace-pre'"
128
+ class="text-highlighted m-0 overflow-auto py-2 ps-3.5 font-mono text-[12.5px] leading-[19px]"
129
+ :class="[wrapClass, hasFloatingCopy ? 'pe-10' : 'pe-3.5']"
92
130
  :style="sizeStyle"
93
131
  :data-language="language"
94
132
  tabindex="0"
@@ -17,6 +17,10 @@ import {
17
17
  type FormValidator,
18
18
  } from "../../composables/form/types/validation";
19
19
  import { FORM_FIELD_LOADING_KEY } from "../../composables/form/types/field-loading";
20
+ import {
21
+ FORM_HANDLE_KEY,
22
+ linkedFieldId,
23
+ } from "../../build/composables/form/enclosingForm";
20
24
  import { FORM_CONTENT_LANGUAGE_KEY } from "../../composables/form/types/content-language";
21
25
  import { DMS_SECTION_SURFACE_KEY } from "../../build/components/section/context";
22
26
  import DmsSaveBar from "../save-bar/SaveBar.vue";
@@ -30,7 +34,7 @@ import { DMS_CONTAINER_KEY } from "../../build/composables/containers/context";
30
34
  import { useFormDirty } from "../../composables/unsaved-changes/useFormDirty";
31
35
  import { useUnsavedChanges } from "../../composables/unsaved-changes/useUnsavedChanges";
32
36
  import { formSaveMode } from "../../build/composables/form/formFooter";
33
- import { usePageRecordLabel } from "#dms-core/app/build/composables/page/usePageRecordLabel";
37
+ import { usePageRecordLabel } from "#dms-core/app/composables/page/usePageRecordLabel";
34
38
  import { formatRecordLabel } from "../../build/composables/table-view/utils/formTexts";
35
39
  import {
36
40
  FORM_ENTRY_CONTEXT_KEY,
@@ -168,6 +172,13 @@ async function focusField(name: string | undefined): Promise<void> {
168
172
  row?.scrollIntoView({ block: "center" });
169
173
  }
170
174
 
175
+ /** Brings a field into view and focuses it: a link's anchor, a field's request. */
176
+ async function revealField(fieldId: string): Promise<void> {
177
+ await formSettled();
178
+ fieldRow(fieldId)?.scrollIntoView({ block: "center" });
179
+ await focusField(fieldId);
180
+ }
181
+
171
182
  /**
172
183
  * A refused submit: its field errors go under the fields, the first focused.
173
184
  *
@@ -516,6 +527,13 @@ function onSubmit(event: Parameters<typeof handleSubmit>[0]) {
516
527
  }
517
528
 
518
529
  provide(FORM_VALIDATOR_KEY, registerValidator);
530
+ provide(FORM_HANDLE_KEY, {
531
+ submit: async () => {
532
+ await form.value?.submit();
533
+ },
534
+ registerValidator,
535
+ revealField,
536
+ });
519
537
  provide(FORM_FIELD_LOADING_KEY, setFieldLoading);
520
538
  provide(FORM_CONTENT_LANGUAGE_KEY, "*");
521
539
 
@@ -580,6 +598,25 @@ if (props.labelKey && !inFormContainer) {
580
598
  });
581
599
  }
582
600
 
601
+ // A page opened on a field's anchor (`#retentionDays`, the id of its
602
+ // control) brings it into view and focuses it once drawn: a link followed
603
+ // inside the app gets no jump from the browser, and the fields of a record
604
+ // exist only once it loaded. A form in a drawer or a modal follows none: the
605
+ // anchor is the page's.
606
+ if (!inFormContainer && !container) {
607
+ const followFieldAnchor = () => {
608
+ const fieldId = linkedFieldId(window.location.hash, allFieldIds.value);
609
+ if (fieldId) void revealField(fieldId);
610
+ };
611
+ onMounted(() => {
612
+ followFieldAnchor();
613
+ window.addEventListener("hashchange", followFieldAnchor);
614
+ });
615
+ onBeforeUnmount(() =>
616
+ window.removeEventListener("hashchange", followFieldAnchor),
617
+ );
618
+ }
619
+
583
620
  const isHorizontal = props.fieldsOrientation !== "vertical";
584
621
  const surface: FormSurface = inFormContainer
585
622
  ? "container"
@@ -4,9 +4,14 @@ import type {
4
4
  InputNumberEmits,
5
5
  InputNumberSlots,
6
6
  } from "@nuxt/ui/components/InputNumber.vue";
7
- import { computed } from "vue";
7
+ import { computed, nextTick } from "vue";
8
8
  import { useForwardPropsEmits } from "reka-ui";
9
9
  import { useControlError } from "../../../build/composables/form/useControlError";
10
+ import {
11
+ numberAdjustment,
12
+ parseTypedNumber,
13
+ } from "../../../build/utils/numberAdjustment";
14
+ import type { InputNumberAdjustment } from "../../../types/input-number";
10
15
 
11
16
  defineOptions({ inheritAttrs: false });
12
17
 
@@ -15,16 +20,27 @@ interface InputNumberFieldProps extends InputNumberProps {
15
20
  suffix?: string;
16
21
  }
17
22
 
23
+ interface InputNumberAdjustEmits {
24
+ /**
25
+ * The field kept another value than the one typed, on leaving it or on
26
+ * Enter: clamped to `min` / `max`, or put on its `step`.
27
+ */
28
+ adjust: [adjustment: InputNumberAdjustment];
29
+ }
30
+
31
+ type InputNumberFieldEmits = InputNumberEmits & InputNumberAdjustEmits;
32
+
18
33
  const props = defineProps<InputNumberFieldProps>();
19
- const emits = defineEmits<InputNumberEmits>();
34
+ const emits = defineEmits<InputNumberFieldEmits>();
20
35
  defineSlots<InputNumberSlots>();
21
36
 
22
37
  const forwarded = useForwardPropsEmits(props, emits);
23
38
  const inputProps = computed(() => {
24
- const { suffix: _suffix, ...rest } = forwarded.value;
39
+ const { suffix: _suffix, onAdjust: _onAdjust, ...rest } = forwarded.value;
25
40
  return rest;
26
41
  });
27
42
  const { processI18n } = useTranslation();
43
+ const { locale } = useI18n();
28
44
 
29
45
  // v2 number fields keep the value left-aligned with a compact caret stepper.
30
46
  const DEFAULT_ORIENTATION = "vertical";
@@ -41,6 +57,41 @@ function onIncomingText(event: InputEvent): void {
41
57
  const refusesNegative = props.min !== undefined && props.min >= 0;
42
58
  report(refusesNegative && text.includes("-") ? BELOW_MIN : undefined);
43
59
  }
60
+
61
+ // What was typed since the field last settled, and the value it settled on.
62
+ let typedText: string | null = null;
63
+ let settledValue: number | null | undefined;
64
+
65
+ function onTyped(event: Event): void {
66
+ typedText = (event.target as HTMLInputElement).value;
67
+ settledValue = undefined;
68
+ }
69
+
70
+ function onSettledValue(value: number | null | undefined): void {
71
+ settledValue = value;
72
+ }
73
+
74
+ // The number field keeps its own copy of the text typed, which it does not
75
+ // always redraw when it settles on the value it already held (1020 typed
76
+ // over 1,000): its `change` handler copies the kept value's text back.
77
+ function redraw(input: HTMLInputElement): void {
78
+ input.dispatchEvent(new Event("change"));
79
+ }
80
+
81
+ /** Once the field applied the typed text: on leaving it, or on Enter. */
82
+ async function settle(event: Event): Promise<void> {
83
+ if (typedText === null) return;
84
+ const text = typedText;
85
+ typedText = null;
86
+ const input = event.target as HTMLInputElement;
87
+ await nextTick();
88
+ const typed = parseTypedNumber(text, props.locale ?? locale.value);
89
+ const kept = settledValue === undefined ? props.modelValue : settledValue;
90
+ const adjustment = numberAdjustment(typed, kept, props);
91
+ if (!adjustment) return;
92
+ emits("adjust", adjustment);
93
+ redraw(input);
94
+ }
44
95
  </script>
45
96
 
46
97
  <template>
@@ -49,9 +100,13 @@ function onIncomingText(event: InputEvent): void {
49
100
  <div
50
101
  :class="props.suffix ? 'flex items-center gap-2' : 'contents'"
51
102
  @beforeinput.capture="onIncomingText"
103
+ @input="onTyped"
104
+ @keydown.enter="settle"
52
105
  >
53
106
  <UInputNumber
54
107
  v-bind="{ ...inputProps, ...$attrs }"
108
+ @blur="settle"
109
+ @update:model-value="onSettledValue"
55
110
  :class="props.suffix && 'min-w-0 flex-1'"
56
111
  :orientation="props.orientation ?? DEFAULT_ORIENTATION"
57
112
  :increment="{ color: 'neutral' }"
@@ -3,8 +3,11 @@ import type { TabProps } from "../../composables/tab/types";
3
3
  import { type TabBadges, TabVariant } from "../../composables/tab/types/props";
4
4
  import { useChartFetch } from "../../composables/chart/useChartFetch";
5
5
  import { confirmLeave } from "../../build/composables/unsaved-changes/registry";
6
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
7
+ import type { BadgeProps } from "@nuxt/ui";
6
8
 
7
9
  const { processI18n } = useTranslation();
10
+ const { processText } = useComposedText();
8
11
 
9
12
  const props = withDefaults(defineProps<TabProps>(), {
10
13
  // A tab set with nothing in it yet is a legitimate state — a page being
@@ -45,9 +48,24 @@ const countFormat = computed(() => new Intl.NumberFormat(locale.value));
45
48
  const COUNT_BADGE_CLASS =
46
49
  "rounded-[4px] bg-elevated px-[5px] py-px font-mono text-[10.5px] font-semibold tabular-nums text-dimmed ring-0 in-data-[state=active]:bg-primary/10 in-data-[state=active]:text-primary";
47
50
 
51
+ type TabBadge = TabProps["items"][number]["badge"];
52
+
53
+ // A badge written as text is a BlockText: a `$` key or a ComposedText is
54
+ // written in the reader's language, a number as it is.
55
+ function writtenBadge(
56
+ badge: TabBadge,
57
+ ): string | number | BadgeProps | undefined {
58
+ if (badge === undefined || typeof badge === "number") return badge;
59
+ if (typeof badge === "string" || "key" in badge) return processText(badge);
60
+ const { label } = badge;
61
+ return typeof label === "string"
62
+ ? { ...badge, label: processText(label) }
63
+ : badge;
64
+ }
65
+
48
66
  function badgeOf(item: TabProps["items"][number]) {
49
67
  const fetched = fetchedBadges.value?.[item.slot];
50
- if (fetched === undefined) return item.badge;
68
+ if (fetched === undefined) return writtenBadge(item.badge);
51
69
  return typeof fetched === "number"
52
70
  ? countFormat.value.format(fetched)
53
71
  : fetched;
@@ -827,6 +827,8 @@ const liveHold = useTableLiveHold({
827
827
  gate: () => liveGate,
828
828
  signals: {
829
829
  isRowOpen: () => openRowId.value !== undefined,
830
+ isSearching: () => !!effectiveGlobalFilter.value,
831
+ isSorted: () => !isSameSorting(effectiveSorting.value, defaultSortState),
830
832
  tableElement: () => tableRoot.value?.$el ?? null,
831
833
  },
832
834
  });
@@ -2102,9 +2104,15 @@ onMounted(() => {
2102
2104
  @duplicate="handleRowDuplicate"
2103
2105
  @edit="handleRowEdit"
2104
2106
  >
2105
- <template v-if="liveHold.newCount.value > 0" #notice>
2107
+ <template
2108
+ v-if="liveHold.newCount.value > 0 || liveHold.reason.value"
2109
+ #notice
2110
+ >
2106
2111
  <LiveHoldBanner
2107
2112
  :count="liveHold.newCount.value"
2113
+ :reason="liveHold.reason.value"
2114
+ :missed-label="liveHold.missedLabel"
2115
+ :detail="liveHold.missedDetail.value"
2108
2116
  @show="liveHold.showNew"
2109
2117
  />
2110
2118
  </template>
@@ -30,6 +30,7 @@ interface Props extends DefaultComponentProps {
30
30
  fetchUrl?: string;
31
31
  fetchUrlMethod?: string;
32
32
  periodScope?: string;
33
+ realtimeTopic?: string | string[];
33
34
  valueFormat?: ValueFormat;
34
35
  currencyCode?: string;
35
36
  valuePrecision?: ValuePrecision;
@@ -120,6 +121,7 @@ const { data, isLoading } = useChartFetch<TopListCardResponse>({
120
121
  fetchUrlMethod: props.fetchUrlMethod,
121
122
  routeParams: () => props.routeParams,
122
123
  periodScope: props.periodScope,
124
+ realtimeTopic: props.realtimeTopic,
123
125
  staticData: () => staticData.value,
124
126
  watchSource: () => watchKey.value,
125
127
  });
@@ -64,6 +64,9 @@ export interface ChartSeries {
64
64
 
65
65
  export type ChartCurve = "smooth" | "straight" | "stepline";
66
66
 
67
+ /** Mirror of `ChartLegendPosition`: where a chart draws its legend. */
68
+ export type ChartLegendPosition = "top" | "bottom";
69
+
67
70
  /** Opt-in expansion of native Y bounds to include annotation lines and bands. */
68
71
  export interface ChartAutoYRange {
69
72
  /** Outward fraction of each bound's magnitude (or 1 at zero). Defaults to 0.08. */