@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
@@ -3,6 +3,7 @@ import type {
3
3
  ChartAnnotation,
4
4
  ChartAutoYRange,
5
5
  ChartCurve,
6
+ ChartLegendPosition,
6
7
  ChartSeries,
7
8
  ChartType,
8
9
  ChartXAxis,
@@ -35,6 +36,7 @@ export interface UseApexChartInput {
35
36
  showGrid?: boolean;
36
37
  showTooltip?: boolean;
37
38
  showLegend?: boolean;
39
+ legendPosition?: ChartLegendPosition;
38
40
  smooth?: boolean;
39
41
  curve?: ChartCurve;
40
42
  stacked?: boolean;
@@ -1,7 +1,17 @@
1
1
  import type { InjectionKey } from "vue";
2
2
 
3
+ /**
4
+ * A check a `Form` runs before each submit: `false` stops the submit before
5
+ * anything is sent. See `useEnclosingForm().addValidator`.
6
+ */
3
7
  export type FormValidator = () => boolean;
8
+
9
+ /** Adds a {@link FormValidator} to a `Form`; returns its removal. */
4
10
  export type RegisterValidator = (validator: FormValidator) => () => void;
5
11
 
12
+ /**
13
+ * What a `Form` provides to register a validator. Kept for the fields that
14
+ * inject it; a module's field calls `useEnclosingForm().addValidator`.
15
+ */
6
16
  export const FORM_VALIDATOR_KEY: InjectionKey<RegisterValidator> =
7
17
  Symbol("form-validator");
@@ -0,0 +1,61 @@
1
+ import { getCurrentScope, inject, onScopeDispose } from "vue";
2
+ import { FORM_HANDLE_KEY } from "../../build/composables/form/enclosingForm";
3
+ import type { FormValidator } from "./types/validation";
4
+
5
+ /** The DMS `Form` a custom field is drawn in (see {@link useEnclosingForm}). */
6
+ export interface EnclosingForm {
7
+ /**
8
+ * Submits the form as its save button does: the form validates its fields,
9
+ * runs the validators, then sends what changed. Resolves once the submit
10
+ * was handled; a refused one shows its errors as usual.
11
+ */
12
+ submit: () => Promise<void>;
13
+ /**
14
+ * Runs `validator` before each submit: answering `false` stops it, before
15
+ * anything is sent (and before the other validators). Removed when the
16
+ * calling component goes, or by calling the returned function.
17
+ */
18
+ addValidator: (validator: FormValidator) => () => void;
19
+ /**
20
+ * Brings a field of the form into view, by its id, and focuses its control:
21
+ * its first focusable part when the control is a group.
22
+ */
23
+ focusField: (fieldId: string) => Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * The DMS `Form` the calling component is drawn in: what a custom field (an
28
+ * `inputComponent` of the module's own) needs of it without reading the DOM.
29
+ * `null` outside a `Form`.
30
+ *
31
+ * ```ts
32
+ * const form = useEnclosingForm();
33
+ * const { confirm } = useConfirm();
34
+ * // Ask before a save that deletes data; submit again once confirmed.
35
+ * form?.addValidator(() => {
36
+ * if (!isLowered.value || isConfirmed) return true;
37
+ * void confirm({ title: "Delete older logs?" }).then((accepted) => {
38
+ * isConfirmed = accepted;
39
+ * if (accepted) void form.submit();
40
+ * });
41
+ * return false;
42
+ * });
43
+ * ```
44
+ *
45
+ * A page opened on the anchor of a field (`/settings#retentionDays`, the
46
+ * field's id) scrolls to it and focuses it once the form is drawn, without
47
+ * any of this: `focusField` is for a link or a button inside the page.
48
+ */
49
+ export function useEnclosingForm(): EnclosingForm | null {
50
+ const handle = inject(FORM_HANDLE_KEY, null);
51
+ if (!handle) return null;
52
+ return {
53
+ submit: handle.submit,
54
+ addValidator: (validator) => {
55
+ const remove = handle.registerValidator(validator);
56
+ if (getCurrentScope()) onScopeDispose(remove);
57
+ return remove;
58
+ },
59
+ focusField: handle.revealField,
60
+ };
61
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Focuses a page's own search (⌘ / on macOS, Ctrl / elsewhere), so it never
3
+ * competes with "/" for the navigation search. `meta` is read as Ctrl off
4
+ * macOS by `defineShortcuts`. The shifted variant is the same physical keys
5
+ * on layouts where "/" needs Shift (AZERTY: Shift + :), which
6
+ * `defineShortcuts` would otherwise reject as an extra modifier.
7
+ */
8
+ export const PAGE_SEARCH_SHORTCUTS = ["meta_/", "meta_shift_/"] as const;
9
+
10
+ /**
11
+ * `defineShortcuts` map focusing a page's own search on ⌘ / or Ctrl /: the
12
+ * keys a custom page binds its search to, "/" being the navigation
13
+ * search's.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * defineShortcuts(buildPageSearchShortcuts(() => input.value?.focus()));
18
+ * ```
19
+ */
20
+ export function buildPageSearchShortcuts(
21
+ focus: () => void,
22
+ ): Record<string, () => void> {
23
+ return Object.fromEntries(PAGE_SEARCH_SHORTCUTS.map((key) => [key, focus]));
24
+ }
@@ -75,8 +75,9 @@ export interface PageLiveState {
75
75
  * const { isPaused, missedCount, resume, hold, release } = usePageLive();
76
76
  * ```
77
77
  *
78
- * A component that refetches on its own realtime topic need not use it:
79
- * built-in blocks with a `realtimeTopic` already follow the state.
78
+ * A component that refetches on its own realtime topic need not read it:
79
+ * `usePageLiveRefresh` gates its refresh as the built-in blocks with a
80
+ * `realtimeTopic` are.
80
81
  */
81
82
  export function usePageLive(): PageLiveState {
82
83
  const isPaused = computed(() => currentPageLive()?.isPaused.value ?? false);
@@ -0,0 +1,103 @@
1
+ import { onBeforeUnmount, onMounted, type ComputedRef } from "vue";
2
+ import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
3
+ import { usePageLive } from "./usePageLive";
4
+
5
+ const MS_PER_SECOND = 1000;
6
+ // How often an idle refresh looks at the clock: a read lands at most this
7
+ // late after its idle period.
8
+ const IDLE_CHECK_MS = 5 * MS_PER_SECOND;
9
+
10
+ /** What makes a module's component read its data again (see {@link usePageLiveRefresh}). */
11
+ export interface PageLiveRefreshOptions {
12
+ /**
13
+ * Realtime topics whose events make the component read again, as a
14
+ * built-in block's `realtimeTopic`.
15
+ */
16
+ realtimeTopic?: string | string[];
17
+ /**
18
+ * Reads again after this many seconds without a read, while the page is
19
+ * live: for data that moves with time alone (a health judged over the last
20
+ * hour, a "last seen"), when no event comes. Nothing is read while paused.
21
+ */
22
+ idleRefresh?: number;
23
+ }
24
+
25
+ /** The refresh of a module's component, gated like a built-in block's. */
26
+ export interface PageLiveRefresh {
27
+ /** Events the component missed while the page is paused, 0 when live. */
28
+ missed: ComputedRef<number>;
29
+ /**
30
+ * A read nobody asked for (a clock, a feed of the component's own): capped
31
+ * by `maxRate` in Direct, counted while paused and read once on resume.
32
+ */
33
+ trigger: () => void;
34
+ /** Reads now, paused or not, as an action of the user does. */
35
+ refreshNow: () => void;
36
+ }
37
+
38
+ /**
39
+ * Keeps a module's own component in step with the page's Live / Pause state
40
+ * exactly as the built-in blocks with a `realtimeTopic` are: `refresh` runs on
41
+ * the events of `realtimeTopic` and on `refreshLivePageBlocks()` at most once
42
+ * per `maxRate` in Direct (the slower `slowRateAfter` rate once the page's
43
+ * `PeriodSelector` shows a long period), the last event of a burst included;
44
+ * paused, nothing runs, the missed events count in the toggle's "N new", and
45
+ * one read catches up on resume. `refreshPageBlocks()`, an action of the
46
+ * user, runs it at once, paused or not.
47
+ *
48
+ * On a page without `live`, `refresh` runs on every event and every refresh
49
+ * of the page. Call it from a component's setup; the subscriptions end with
50
+ * the component.
51
+ *
52
+ * ```ts
53
+ * const { useFetchAuth } = useAuthFetch();
54
+ * const { data, refresh } = useFetchAuth<Health>("/api/health");
55
+ * usePageLiveRefresh(() => void refresh(), {
56
+ * realtimeTopic: "api:requests",
57
+ * idleRefresh: 60,
58
+ * });
59
+ * ```
60
+ */
61
+ export function usePageLiveRefresh(
62
+ refresh: () => void,
63
+ options: PageLiveRefreshOptions = {},
64
+ ): PageLiveRefresh {
65
+ let lastReadAt = Date.now();
66
+ const read = () => {
67
+ lastReadAt = Date.now();
68
+ refresh();
69
+ };
70
+ const gate = useRefreshTriggers(options.realtimeTopic, read);
71
+ if (options.idleRefresh) {
72
+ useIdleRefresh(
73
+ options.idleRefresh * MS_PER_SECOND,
74
+ () => lastReadAt,
75
+ gate.trigger,
76
+ );
77
+ }
78
+ return {
79
+ missed: gate.missed,
80
+ trigger: () => gate.trigger(),
81
+ refreshNow: () => gate.catchUp(),
82
+ };
83
+ }
84
+
85
+ // Browser only, from mount: a server render reads once and has no clock.
86
+ function useIdleRefresh(
87
+ idleMs: number,
88
+ lastReadAt: () => number,
89
+ trigger: () => void,
90
+ ): void {
91
+ const live = usePageLive();
92
+ let timer: ReturnType<typeof setInterval> | undefined;
93
+ onMounted(() => {
94
+ timer = setInterval(
95
+ () => {
96
+ if (live.isPaused.value) return;
97
+ if (Date.now() - lastReadAt() >= idleMs) trigger();
98
+ },
99
+ Math.min(idleMs, IDLE_CHECK_MS),
100
+ );
101
+ });
102
+ onBeforeUnmount(() => clearInterval(timer));
103
+ }
@@ -2,6 +2,7 @@ export interface UseTabReturn {
2
2
  activeTab: Ref<string>;
3
3
  nextTab: () => void;
4
4
  previousTab: () => void;
5
+ /** Opens a tab by its index, or by its slot (`"tester"`). */
5
6
  goToTab: (index: number | string) => void;
6
7
  goToFirstTab: () => void;
7
8
  goToLastTab: () => void;
@@ -1,10 +1,17 @@
1
1
  import type { AvatarProps, BadgeProps } from "@nuxt/ui";
2
+ import type { BlockText } from "../../../../../dms-core/app/types/composed-text";
2
3
 
3
4
  export interface TabItem {
4
5
  label: string;
6
+ /** The tab's id: its panel's slot, and its name in the URL (`?tab=<slot>`). */
5
7
  slot: string;
6
8
  icon?: string;
7
- badge?: string | number | BadgeProps;
9
+ /**
10
+ * A count or a short word after the label: a `$` i18n key or a
11
+ * `ComposedText` is written in the reader's language, as is the `label`
12
+ * of a `BadgeProps`.
13
+ */
14
+ badge?: BlockText | number | BadgeProps;
8
15
  disabled?: boolean;
9
16
  shortcut?: string;
10
17
  avatar?: AvatarProps;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * The index of the tab a URL names: its slot (`?tab=tester`), or its index
3
+ * (`?tab=2`) as written before tabs were named. `null` when it names none.
4
+ */
5
+ function tabIndexOf(
6
+ value: unknown,
7
+ slots: readonly string[],
8
+ tabCount: number,
9
+ ): number | null {
10
+ if (typeof value !== "string" || value === "") return null;
11
+ const bySlot = slots.indexOf(value);
12
+ if (bySlot >= 0) return bySlot;
13
+ const index = Number(value);
14
+ return Number.isInteger(index) && index >= 0 && index < tabCount
15
+ ? index
16
+ : null;
17
+ }
18
+
1
19
  export function useTab(
2
20
  tabCount: Ref<number> | number,
3
21
  props: TabProps,
@@ -15,24 +33,22 @@ export function useTab(
15
33
  const totalTabs = computed(() =>
16
34
  isNumber(tabCount) ? tabCount : tabCount.value,
17
35
  );
36
+ const slots = computed(() => (props.items ?? []).map((item) => item.slot));
37
+
38
+ const urlTabIndex = (value: unknown) =>
39
+ tabIndexOf(value, slots.value, totalTabs.value);
18
40
 
19
41
  const getInitialTab = () => {
20
- if (props.persistState && route.query[stateKey]) {
21
- const tabFromUrl = route.query[stateKey] as string;
22
- const tabIndex = Number.parseInt(tabFromUrl, 10);
23
- if (
24
- !Number.isNaN(tabIndex) &&
25
- tabIndex >= 0 &&
26
- tabIndex < totalTabs.value
27
- ) {
28
- return tabFromUrl;
29
- }
30
- }
31
- return String(0);
42
+ const fromUrl = props.persistState
43
+ ? urlTabIndex(route.query[stateKey])
44
+ : null;
45
+ return String(fromUrl ?? 0);
32
46
  };
33
47
 
34
48
  const activeTab = ref<string>(getInitialTab());
35
49
 
50
+ // The open tab is written by its slot, which stays right when tabs are
51
+ // added or reordered; a tab without one keeps its index.
36
52
  const updateUrl = async (newTab: string) => {
37
53
  if (!props.persistState) return;
38
54
 
@@ -41,13 +57,15 @@ export function useTab(
41
57
  // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
42
58
  delete query[stateKey];
43
59
  } else {
44
- query[stateKey] = newTab;
60
+ query[stateKey] = slots.value[Number(newTab)] || newTab;
45
61
  }
46
62
  await router.push({ query });
47
63
  };
48
64
 
49
65
  const goToTab = (index: number | string) => {
50
- const indexNum = isString(index) ? Number.parseInt(index, 10) : index;
66
+ const indexNum = isString(index)
67
+ ? (tabIndexOf(index, slots.value, totalTabs.value) ?? -1)
68
+ : index;
51
69
  const indexStr = String(indexNum);
52
70
 
53
71
  if (indexNum < 0 || indexNum >= totalTabs.value) {
@@ -95,15 +113,9 @@ export function useTab(
95
113
  (newValue) => {
96
114
  if (!props.persistState) return;
97
115
 
98
- const tabStr = newValue as string;
99
- const tabIndex = Number.parseInt(tabStr, 10);
100
- if (
101
- !Number.isNaN(tabIndex) &&
102
- tabIndex >= 0 &&
103
- tabIndex < totalTabs.value &&
104
- tabStr !== activeTab.value
105
- ) {
106
- activeTab.value = tabStr;
116
+ const tabIndex = urlTabIndex(newValue);
117
+ if (tabIndex !== null && String(tabIndex) !== activeTab.value) {
118
+ activeTab.value = String(tabIndex);
107
119
  }
108
120
  },
109
121
  );
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Why a number field keeps another value than the one typed: above its `max`,
3
+ * below its `min`, or off its `step`.
4
+ */
5
+ export type InputNumberAdjustmentReason = "min" | "max" | "step";
6
+
7
+ /**
8
+ * What `DmsInputNumber` emits as `adjust` when it settles a typed number on
9
+ * another value (on leaving the field, or on Enter): 1020 typed in a field of
10
+ * steps of 50 gives `{ typed: 1020, value: 1000, reason: "step" }`.
11
+ */
12
+ export interface InputNumberAdjustment {
13
+ /** The number as typed. */
14
+ typed: number;
15
+ /** The number the field keeps, and now shows. */
16
+ value: number;
17
+ reason: InputNumberAdjustmentReason;
18
+ }
@@ -51,4 +51,10 @@ export interface TableViewLiveHoldOptions {
51
51
  whileRowOpen?: boolean;
52
52
  /** While the user has scrolled its first row out of view. */
53
53
  whileScrolled?: boolean;
54
+ /** While the user's search narrows the rows. */
55
+ whileSearching?: boolean;
56
+ /** While the user sorts the rows otherwise than by the table's default. */
57
+ whileSorted?: boolean;
58
+ /** What the strip calls the new rows, given `{count}`. */
59
+ missedLabel?: string;
54
60
  }
@@ -0,0 +1,23 @@
1
+ import { blockAnchorElement } from "../build/composables/page/blockAnchors";
2
+
3
+ const DEFAULT_SCROLL: ScrollIntoViewOptions = { block: "start" };
4
+
5
+ /**
6
+ * Brings a block of the page into view, by its anchor: the key path it is
7
+ * declared under, its static field's key for a top-level block (`summary`),
8
+ * then each child key down to it (`tabs.statistics`, `grid.row.latency`).
9
+ * Answers `false`, without scrolling, when no such block is on screen: an
10
+ * unknown key, a block in a closed tab, or one the viewer may not see.
11
+ *
12
+ * @param key The block's anchor
13
+ * @param options Passed to `scrollIntoView`; defaults to `{ block: "start" }`
14
+ */
15
+ export function scrollToBlock(
16
+ key: string,
17
+ options: ScrollIntoViewOptions = DEFAULT_SCROLL,
18
+ ): boolean {
19
+ const element = blockAnchorElement(key);
20
+ if (!element) return false;
21
+ element.scrollIntoView(options);
22
+ return true;
23
+ }
@@ -702,7 +702,9 @@
702
702
  "error_title": "Activity could not be loaded",
703
703
  "error_description": "Check your connection, then try again.",
704
704
  "retry": "Try again",
705
- "list_label": "Activity"
705
+ "list_label": "Activity",
706
+ "show_more": "Show {count} more",
707
+ "show_fewer": "Show fewer"
706
708
  },
707
709
  "leave_guard": {
708
710
  "title": "Discard unsaved changes?",
@@ -745,7 +747,12 @@
745
747
  "paused": "Paused",
746
748
  "resume": "Resume",
747
749
  "reason_hidden": "tab hidden",
748
- "polling": "no realtime, polling"
750
+ "polling": "no realtime, polling",
751
+ "table_held": "Table on hold",
752
+ "held_row_open": "row open",
753
+ "held_searching": "search active",
754
+ "held_sorted": "sorted otherwise than by default",
755
+ "held_scrolled": "scrolled down"
749
756
  }
750
757
  }
751
758
  }
@@ -703,7 +703,9 @@
703
703
  "error_title": "Impossible de charger l'activité",
704
704
  "error_description": "Vérifiez votre connexion, puis réessayez.",
705
705
  "retry": "Réessayer",
706
- "list_label": "Activité"
706
+ "list_label": "Activité",
707
+ "show_more": "Afficher {count} de plus",
708
+ "show_fewer": "Afficher moins"
707
709
  },
708
710
  "leave_guard": {
709
711
  "title": "Abandonner les modifications non enregistrées ?",
@@ -746,7 +748,12 @@
746
748
  "paused": "En pause",
747
749
  "resume": "Reprendre",
748
750
  "reason_hidden": "onglet masqué",
749
- "polling": "sans temps réel, relecture régulière"
751
+ "polling": "sans temps réel, relecture régulière",
752
+ "table_held": "Tableau figé",
753
+ "held_row_open": "ligne ouverte",
754
+ "held_searching": "recherche active",
755
+ "held_sorted": "tri autre que celui par défaut",
756
+ "held_scrolled": "défilé vers le bas"
750
757
  }
751
758
  }
752
759
  }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * An ActivityFeed that must not hide what its entries say: `wrap` lets each
4
+ * entry take the lines it needs instead of an ellipsis, and `visibleItems`
5
+ * shows the first entries with a "Show N more" button that unfolds the rest.
6
+ */
7
+ import { computed, createApp, defineComponent, h, nextTick, ref } from "vue";
8
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
9
+ import ActivityFeed from "../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue";
10
+
11
+ const items = ref<Array<Record<string, unknown>>>([]);
12
+
13
+ vi.mock("../layers/dms-ui/app/build/composables/blocks/useBlockItems", () => ({
14
+ useBlockItems: () => ({
15
+ items: computed(() => items.value),
16
+ count: computed(() => undefined),
17
+ isPending: computed(() => false),
18
+ hasError: computed(() => false),
19
+ refresh: async () => {},
20
+ }),
21
+ }));
22
+
23
+ const Stub = defineComponent({
24
+ inheritAttrs: false,
25
+ props: { title: { type: String, default: undefined }, label: String },
26
+ setup:
27
+ (props, { slots, attrs }) =>
28
+ () =>
29
+ h(
30
+ attrs.onClick ? "button" : "div",
31
+ { class: attrs.class, onClick: attrs.onClick },
32
+ [props.label ?? props.title, slots.default?.()],
33
+ ),
34
+ });
35
+
36
+ let app: ReturnType<typeof createApp> | undefined;
37
+
38
+ function mountFeed(props: Record<string, unknown>): HTMLElement {
39
+ const host = document.createElement("div");
40
+ app = createApp(() =>
41
+ h(ActivityFeed, {
42
+ componentId: "attention",
43
+ pageId: "overview",
44
+ groupByDay: false,
45
+ ...props,
46
+ }),
47
+ );
48
+ for (const name of ["DmsCard", "UIcon", "USkeleton", "UButton", "ULink"]) {
49
+ app.component(name, Stub);
50
+ }
51
+ app.mount(host);
52
+ return host;
53
+ }
54
+
55
+ const entryTitles = (host: HTMLElement) =>
56
+ host.textContent?.match(/Route \d/g) ?? [];
57
+
58
+ beforeEach(() => {
59
+ items.value = Array.from({ length: 5 }, (_, index) => ({
60
+ id: `r${index}`,
61
+ title: `Route ${index}`,
62
+ detail: `500 "Upstream timed out while reading the response" × ${index + 1}`,
63
+ }));
64
+ vi.stubGlobal("useI18n", () => ({
65
+ locale: ref("en-GB"),
66
+ t: (key: string, params?: { count?: number }) =>
67
+ params ? `${key}:${params.count}` : key,
68
+ }));
69
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
70
+ });
71
+
72
+ afterEach(() => {
73
+ app?.unmount();
74
+ vi.unstubAllGlobals();
75
+ });
76
+
77
+ it("shows every entry, without a fold, by default", () => {
78
+ const host = mountFeed({});
79
+ expect(entryTitles(host)).toHaveLength(5);
80
+ expect(host.querySelector(".dms-activity-feed__fold")).toBeNull();
81
+ });
82
+
83
+ it("shows the first visibleItems and unfolds the rest, then folds them back", async () => {
84
+ const host = mountFeed({ visibleItems: 3 });
85
+ expect(entryTitles(host)).toEqual(["Route 0", "Route 1", "Route 2"]);
86
+ const button = host.querySelector<HTMLButtonElement>(
87
+ ".dms-activity-feed__fold button",
88
+ )!;
89
+ expect(button.textContent).toBe("dms.activity_feed.show_more:2");
90
+
91
+ button.click();
92
+ await nextTick();
93
+ expect(entryTitles(host)).toHaveLength(5);
94
+ expect(button.textContent).toBe("dms.activity_feed.show_fewer");
95
+
96
+ button.click();
97
+ await nextTick();
98
+ expect(entryTitles(host)).toHaveLength(3);
99
+ });
100
+
101
+ it("needs no fold when the entries fit in visibleItems", () => {
102
+ const host = mountFeed({ visibleItems: 5 });
103
+ expect(host.querySelector(".dms-activity-feed__fold")).toBeNull();
104
+ });
105
+
106
+ it("cuts each entry to one line by default, and wraps it whole with wrap", () => {
107
+ const cut = mountFeed({ mono: true });
108
+ expect(cut.querySelector(".truncate")).not.toBeNull();
109
+ app?.unmount();
110
+
111
+ const wrapped = mountFeed({ mono: true, wrap: true });
112
+ expect(wrapped.querySelector(".truncate")).toBeNull();
113
+ expect(
114
+ wrapped.querySelector(".\\[overflow-wrap\\:anywhere\\]"),
115
+ ).not.toBeNull();
116
+ expect(wrapped.textContent).toContain(
117
+ '500 "Upstream timed out while reading the response" × 1',
118
+ );
119
+ });
@@ -49,6 +49,8 @@ const BLOCKS: Record<string, string> = {
49
49
  ActivityFeed:
50
50
  "../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue",
51
51
  MeterBlock: "../layers/dms-ui/app/components/meter/MeterBlock.vue",
52
+ ChartCard: "../layers/dms-ui/app/components/chart/ChartCard.vue",
53
+ TopListCard: "../layers/dms-ui/app/components/top-list/TopListCard.vue",
52
54
  };
53
55
  const GLOBAL_COMPONENTS = ["DmsCard", "UIcon", "USkeleton", "UButton", "ULink"];
54
56
  const SCOPE = "dashboard";
@@ -81,6 +83,9 @@ beforeEach(() => {
81
83
  vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
82
84
  vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
83
85
  vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
86
+ // The cards reach these through auto-imports rather than imports.
87
+ vi.stubGlobal("useComponentEvent", () => ({}));
88
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
84
89
  });
85
90
 
86
91
  afterEach(() => vi.unstubAllGlobals());
@@ -18,6 +18,16 @@ describe("buildBreadcrumbTarget", () => {
18
18
  expect(buildBreadcrumbTarget("/projects", {}, {})).to.equal(undefined);
19
19
  });
20
20
 
21
+ it("links a module's root to the module's landing page", () => {
22
+ expect(
23
+ buildBreadcrumbTarget(
24
+ "/modules/api",
25
+ { landingSlug: "/modules/api/overview" },
26
+ {},
27
+ ),
28
+ ).to.equal("/modules/api/overview");
29
+ });
30
+
21
31
  it("carries only the query values an ancestor page requires", () => {
22
32
  expect(
23
33
  buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {