@antelopejs/dms 0.7.11 → 0.7.12

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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  3. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  5. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  6. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  7. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  8. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  9. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  10. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  11. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +1 -1
  12. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -10
  13. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  14. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  15. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  16. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  17. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  18. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  19. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  20. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  21. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  22. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  23. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  24. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  25. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  26. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  27. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  28. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -1
  29. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  30. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  31. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  32. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  33. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  34. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  35. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  36. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  37. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  39. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  40. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  41. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  42. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  43. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  44. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  45. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  46. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  47. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  48. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  49. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  50. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  51. package/frontend-vue/tests/code-block.test.ts +28 -0
  52. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  53. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  54. package/frontend-vue/tests/header-actions.test.ts +1 -1
  55. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  56. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  57. package/frontend-vue/tests/module-routing.test.ts +139 -0
  58. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  59. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  60. package/frontend-vue/tests/regional-format.test.ts +69 -2
  61. package/frontend-vue/tests/scroll-to-block.test.ts +142 -0
  62. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  63. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  64. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  65. package/package.json +2 -2
  66. package/skills/dms/SKILL.md +28 -4
  67. package/skills/dms-module/SKILL.md +2 -1
@@ -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
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A chart names its series in a legend drawn in the dashboard's colours, at
3
+ * the top of an axis chart or under a circular one unless `legendPosition`
4
+ * says otherwise. Inside a ChartCard the chart's legend stays off, the card
5
+ * heading its single series, until the card asks for it with
6
+ * `showSeriesLegend`: no raw Apex `legend` option is needed for either.
7
+ */
8
+ import { createSSRApp, defineComponent, h, ref, type PropType } from "vue";
9
+ import { renderToString } from "vue/server-renderer";
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import ChartCard from "../layers/dms-ui/app/components/chart/ChartCard.vue";
12
+ import Chart from "../layers/dms-ui/app/components/chart/Chart.vue";
13
+ import { useApexChart } from "../layers/dms-ui/app/composables/chart/useApexChart";
14
+ import type { UseApexChartInput } from "../layers/dms-ui/app/composables/chart/useApexChart.types";
15
+ import type { ChartSeries } from "../layers/dms-ui/app/composables/chart/types";
16
+
17
+ const SERIES: ChartSeries[] = [
18
+ { name: "Average", data: [{ x: "10:00", y: 42 }] },
19
+ { name: "p95", data: [{ x: "10:00", y: 96 }] },
20
+ ];
21
+
22
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
23
+ useChartFetch: () => ({
24
+ data: ref({ value: 42, series: SERIES }),
25
+ isLoading: ref(false),
26
+ }),
27
+ }));
28
+
29
+ interface LegendOptions {
30
+ show: boolean;
31
+ position: string;
32
+ }
33
+
34
+ const LegendProbe = defineComponent({
35
+ props: {
36
+ options: {
37
+ type: Object as PropType<{ legend: LegendOptions }>,
38
+ required: true,
39
+ },
40
+ },
41
+ setup: (props) => () =>
42
+ h("output", {
43
+ "data-show": String(props.options.legend.show),
44
+ "data-position": props.options.legend.position,
45
+ }),
46
+ });
47
+
48
+ const Slot = defineComponent({
49
+ setup:
50
+ (_, { slots }) =>
51
+ () =>
52
+ h("div", slots.default?.()),
53
+ });
54
+
55
+ function legendOf(input: Partial<UseApexChartInput>) {
56
+ const chart = useApexChart(() => ({
57
+ type: "line",
58
+ series: SERIES,
59
+ ...input,
60
+ }));
61
+ return chart.options.value.legend as Record<string, unknown>;
62
+ }
63
+
64
+ async function renderCard(cardProps: Record<string, unknown>) {
65
+ vi.stubGlobal("useI18n", () => ({
66
+ locale: ref("en"),
67
+ t: (key: string) => key,
68
+ }));
69
+ vi.stubGlobal("useTranslation", () => ({
70
+ processI18n: (text: string) => text,
71
+ }));
72
+ vi.stubGlobal("useComponentEvent", () => ({}));
73
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
74
+ const app = createSSRApp({
75
+ render: () =>
76
+ h(
77
+ ChartCard,
78
+ {
79
+ title: "Latency",
80
+ componentId: "card",
81
+ pageId: "routes",
82
+ ...cardProps,
83
+ },
84
+ () =>
85
+ h(Chart, {
86
+ type: "line",
87
+ componentId: "plot",
88
+ pageId: "routes",
89
+ legendPosition: "bottom",
90
+ }),
91
+ ),
92
+ });
93
+ for (const name of ["DmsCard", "DmsClientOnly", "UIcon", "USkeleton"])
94
+ app.component(name, Slot);
95
+ app.component("DmsApexChartHost", LegendProbe);
96
+ return renderToString(app);
97
+ }
98
+
99
+ afterEach(() => vi.unstubAllGlobals());
100
+
101
+ describe("chart legend", () => {
102
+ it("sits above an axis chart and under a circular one by default", () => {
103
+ expect(legendOf({}).position).toBe("top");
104
+ expect(legendOf({ type: "donut", series: [] }).position).toBe("bottom");
105
+ });
106
+
107
+ it("follows legendPosition", () => {
108
+ expect(legendOf({ legendPosition: "bottom" }).position).toBe("bottom");
109
+ expect(
110
+ legendOf({ type: "donut", series: [], legendPosition: "top" }).position,
111
+ ).toBe("top");
112
+ });
113
+
114
+ it("is drawn in the dashboard's theme", () => {
115
+ const legend = legendOf({});
116
+ expect(legend.show).toBe(true);
117
+ expect(legend.markers).toMatchObject({ shape: "square", strokeWidth: 0 });
118
+ expect(legend.labels).toHaveProperty("colors");
119
+ });
120
+ });
121
+
122
+ describe("series legend of a ChartCard", () => {
123
+ it("stays off in a card by default", async () => {
124
+ const html = await renderCard({});
125
+ expect(html).toContain('data-show="false"');
126
+ });
127
+
128
+ it("names the series where the chart places it when the card asks", async () => {
129
+ const html = await renderCard({ showSeriesLegend: true });
130
+ expect(html).toContain('data-show="true"');
131
+ expect(html).toContain('data-position="bottom"');
132
+ });
133
+ });
@@ -94,6 +94,34 @@ describe("DmsCodeSnippet", () => {
94
94
  expect(pre().previousElementSibling).toBeNull();
95
95
  });
96
96
 
97
+ it("floats the copy button in the code's corner without a title or a language", async () => {
98
+ await mountSnippet({ code: '{"ok":true}' });
99
+ expect(host.querySelector("[data-floating-copy]")).toBe(copyButton());
100
+ expect(host.textContent).not.toContain("Text");
101
+ expect(pre().className).toContain("pe-10");
102
+ });
103
+
104
+ it("keeps the head of a snippet naming its language", async () => {
105
+ await mountSnippet({ code: "plain words", language: "text" });
106
+ expect(host.querySelector("[data-floating-copy]")).toBeNull();
107
+ expect(copyButton()).not.toBeNull();
108
+ expect(pre().className).not.toContain("pe-10");
109
+ });
110
+
111
+ it("breaks a word too long for the line with wrap anywhere", async () => {
112
+ await mountSnippet({ code: "x".repeat(400), wrap: "anywhere" });
113
+ expect(pre().className).toContain("[overflow-wrap:anywhere]");
114
+ expect(pre().className).toContain("whitespace-pre-wrap");
115
+ });
116
+
117
+ it("caps the height with a CSS length when no line count is set", async () => {
118
+ await mountSnippet({ code: "a", maxHeight: "20rem" });
119
+ expect(pre().style.maxHeight).toBe("20rem");
120
+ app?.unmount();
121
+ await mountSnippet({ code: "a\nb", maxHeight: "20rem", maxLines: 1 });
122
+ expect(pre().style.maxHeight).toBe("35px");
123
+ });
124
+
97
125
  it("wraps long lines or scrolls them, and caps the height in lines", async () => {
98
126
  await mountSnippet({ code: "a\nb\nc", wrap: true, maxLines: 2 });
99
127
  expect(pre().className).toContain("whitespace-pre-wrap");
@@ -44,6 +44,7 @@ const load = async (name: string) =>
44
44
  .default as Component;
45
45
 
46
46
  beforeEach(() => {
47
+ vi.stubGlobal("useI18n", () => ({ locale: { value: "en" } }));
47
48
  vi.stubGlobal("useTranslation", () => ({
48
49
  processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
49
50
  }));