@antelopejs/dms 0.7.1 → 0.7.2

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 (110) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +2 -2
  78. package/frontend-vue/pnpm-lock.yaml +5 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -0,0 +1,205 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createApp,
5
+ defineComponent,
6
+ h,
7
+ nextTick,
8
+ provide,
9
+ reactive,
10
+ ref,
11
+ watch,
12
+ type App,
13
+ } from "vue";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { useActionTargets } from "../layers/dms-ui/app/build/composables/actions/useActionTargets";
16
+ import type { ActionTarget } from "../layers/dms-ui/app/composables/table-view/types/action-target";
17
+
18
+ vi.mock("reka-ui", () => {
19
+ const passThrough = defineComponent({
20
+ setup: (_props, { slots }) => () => h("div", slots.default?.()),
21
+ });
22
+ return {
23
+ DialogTitle: passThrough,
24
+ DialogDescription: passThrough,
25
+ VisuallyHidden: passThrough,
26
+ };
27
+ });
28
+
29
+ let app: App | undefined;
30
+ let host: HTMLDivElement;
31
+
32
+ beforeEach(() => {
33
+ host = document.createElement("div");
34
+ document.body.append(host);
35
+ });
36
+
37
+ afterEach(() => {
38
+ app?.unmount();
39
+ app = undefined;
40
+ host.remove();
41
+ vi.unstubAllGlobals();
42
+ });
43
+
44
+ describe("a drawer action target's direction", () => {
45
+ const opened: Array<Record<string, unknown>> = [];
46
+
47
+ const mountTargets = () => {
48
+ let targets: ReturnType<typeof useActionTargets> | undefined;
49
+ app = createApp(
50
+ defineComponent({
51
+ setup() {
52
+ targets = useActionTargets({
53
+ api: vi.fn() as never,
54
+ pageId: "page",
55
+ componentId: "table",
56
+ handleApiError: vi.fn(),
57
+ });
58
+ return () => h("div");
59
+ },
60
+ }),
61
+ );
62
+ app.mount(host);
63
+ return targets!;
64
+ };
65
+
66
+ const drawerTarget = (
67
+ direction?: "right" | "left" | "top" | "bottom",
68
+ ): ActionTarget => ({
69
+ type: "drawer",
70
+ component: { componentName: "Inspector" },
71
+ ...(direction && { direction }),
72
+ });
73
+
74
+ beforeEach(() => {
75
+ opened.length = 0;
76
+ vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
77
+ vi.stubGlobal("useTranslation", () => ({
78
+ processI18n: (text: string) => text,
79
+ processApiMessage: (text: string) => text,
80
+ }));
81
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
82
+ vi.stubGlobal("useModal", () => ({ open: vi.fn() }));
83
+ vi.stubGlobal("useDrawer", () => ({
84
+ open: (options: Record<string, unknown>) => {
85
+ opened.push(options);
86
+ return {
87
+ result: new Promise(() => {}),
88
+ close: vi.fn(),
89
+ patch: vi.fn(),
90
+ };
91
+ },
92
+ }));
93
+ vi.stubGlobal("useExportJob", () => ({ runJob: vi.fn() }));
94
+ vi.stubGlobal("useConfirm", () => ({ confirm: vi.fn(async () => true) }));
95
+ vi.stubGlobal("useAuthFetch", () => ({ $authFetch: vi.fn() }));
96
+ vi.stubGlobal("resolveDmsComponent", () =>
97
+ defineComponent({ render: () => null }),
98
+ );
99
+ });
100
+
101
+ it("opens a toolbar or header button's drawer from the edge it names", async () => {
102
+ const targets = mountTargets();
103
+ targets.handleCustomButton({
104
+ label: "Inspect",
105
+ target: drawerTarget("right"),
106
+ });
107
+ await vi.waitFor(() => expect(opened).toHaveLength(1));
108
+ expect(opened[0]!.direction).toBe("right");
109
+ });
110
+
111
+ it("opens a row action's drawer from the edge it names", async () => {
112
+ const targets = mountTargets();
113
+ targets.handleCustomRowAction(
114
+ { label: "Inspect", target: drawerTarget("left") },
115
+ { _id: "r1" },
116
+ );
117
+ await vi.waitFor(() => expect(opened).toHaveLength(1));
118
+ expect(opened[0]!.direction).toBe("left");
119
+ });
120
+
121
+ it("leaves the direction to the drawer when the target names none", async () => {
122
+ const targets = mountTargets();
123
+ targets.handleCustomButton({ label: "Inspect", target: drawerTarget() });
124
+ await vi.waitFor(() => expect(opened).toHaveLength(1));
125
+ expect(opened[0]!.direction).toBe(undefined);
126
+ });
127
+ });
128
+
129
+ describe("the drawer container", () => {
130
+ interface DrawerStubProps {
131
+ direction?: string;
132
+ ui?: Record<string, string>;
133
+ }
134
+
135
+ const rendered = reactive<DrawerStubProps>({});
136
+
137
+ const mountDrawer = async (props: Record<string, unknown>) => {
138
+ const { default: DynamicDrawer } = await import(
139
+ "../layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue"
140
+ );
141
+ app = createApp({
142
+ setup: () => () =>
143
+ h(DynamicDrawer, {
144
+ title: "Inspector",
145
+ containerId: "drawer-1",
146
+ component: defineComponent({ render: () => null }),
147
+ ...props,
148
+ }),
149
+ });
150
+ app.component(
151
+ "UDrawer",
152
+ defineComponent({
153
+ props: { direction: String, ui: Object },
154
+ setup(stubProps, { slots }) {
155
+ return () => {
156
+ Object.assign(rendered, stubProps);
157
+ return h("div", [slots.header?.(), slots.body?.()]);
158
+ };
159
+ },
160
+ }),
161
+ );
162
+ app.component(
163
+ "UContainer",
164
+ defineComponent({
165
+ setup: (_props, { slots }) => () =>
166
+ h("div", { "data-container": "" }, slots.default?.()),
167
+ }),
168
+ );
169
+ app.component("UButton", () => h("button"));
170
+ app.component("USkeleton", () => h("span"));
171
+ app.mount(host);
172
+ await nextTick();
173
+ };
174
+
175
+ beforeEach(() => {
176
+ rendered.direction = undefined;
177
+ rendered.ui = undefined;
178
+ vi.stubGlobal("ref", ref);
179
+ vi.stubGlobal("computed", computed);
180
+ vi.stubGlobal("watch", watch);
181
+ vi.stubGlobal("provide", provide);
182
+ vi.stubGlobal("useLeaveGuard", () => ({
183
+ executeGuards: async () => true,
184
+ clearGuards: vi.fn(),
185
+ }));
186
+ vi.stubGlobal("useDmsRoute", () => reactive({ path: "/" }));
187
+ });
188
+
189
+ it("slides in from the bottom, full width, by default", async () => {
190
+ await mountDrawer({});
191
+ expect(rendered.direction).toBe("bottom");
192
+ expect(rendered.ui?.content).toBe(undefined);
193
+ const containers = host.querySelectorAll("[data-container]");
194
+ expect(containers[0]!.className).toContain("max-sm:px-0");
195
+ expect(containers[0]!.className).not.toContain("lg:px-0");
196
+ });
197
+
198
+ it("is a side sheet as wide as a side panel from the right", async () => {
199
+ await mountDrawer({ direction: "right" });
200
+ expect(rendered.direction).toBe("right");
201
+ expect(rendered.ui?.content).toBe("w-[440px]");
202
+ const containers = host.querySelectorAll("[data-container]");
203
+ expect(containers[0]!.className).toContain("px-0 sm:px-0 lg:px-0");
204
+ });
205
+ });
@@ -0,0 +1,53 @@
1
+ // @vitest-environment jsdom
2
+ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import EmptyState from "../layers/dms-ui/app/components/empty-state/EmptyState.vue";
5
+
6
+ vi.mock("../layers/dms-ui/app/components/icon-well/IconWell.vue", () => ({
7
+ default: { render: () => null },
8
+ }));
9
+
10
+ let app: App | undefined;
11
+
12
+ function mountWithLateSlots() {
13
+ const shown = ref(false);
14
+ app = createApp({
15
+ render: () =>
16
+ h(
17
+ EmptyState,
18
+ { title: "No tables" },
19
+ shown.value
20
+ ? {
21
+ default: () => "Create one to start.",
22
+ actions: () => h("a", { href: "/new" }, "New table"),
23
+ }
24
+ : {},
25
+ ),
26
+ });
27
+ app.component("UButton", defineComponent({ setup: () => () => h("button") }));
28
+ const container = document.createElement("div");
29
+ app.mount(container);
30
+ return { container, shown };
31
+ }
32
+
33
+ afterEach(() => {
34
+ app?.unmount();
35
+ app = undefined;
36
+ });
37
+
38
+ describe("empty state slots", () => {
39
+ it("shows an actions slot the parent adds after the first render", async () => {
40
+ const { container, shown } = mountWithLateSlots();
41
+ expect(container.querySelector("a")).toBeNull();
42
+
43
+ shown.value = true;
44
+ await nextTick();
45
+ expect(container.querySelector("a")?.textContent).toBe("New table");
46
+ expect(container.textContent).toContain("Create one to start.");
47
+
48
+ shown.value = false;
49
+ await nextTick();
50
+ expect(container.querySelector("a")).toBeNull();
51
+ expect(container.textContent).not.toContain("Create one to start.");
52
+ });
53
+ });
@@ -0,0 +1,88 @@
1
+ // @vitest-environment jsdom
2
+ import { createApp, h, type App } from "vue";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import FlowCanvas from "../layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue";
5
+
6
+ vi.mock("@vue-flow/core", async () => {
7
+ const vue = await import("vue");
8
+ const selectedNodes = vue.ref([
9
+ {
10
+ id: "users",
11
+ computedPosition: { x: 10, y: 20 },
12
+ dimensions: { width: 100, height: 40 },
13
+ },
14
+ ]);
15
+ const Slotted = vue.defineComponent({
16
+ setup:
17
+ (_, { slots }) =>
18
+ () =>
19
+ vue.h("div", slots.default?.()),
20
+ });
21
+ return {
22
+ VueFlow: Slotted,
23
+ Panel: Slotted,
24
+ PanelPosition: {
25
+ TopLeft: "top-left",
26
+ TopCenter: "top-center",
27
+ TopRight: "top-right",
28
+ BottomLeft: "bottom-left",
29
+ BottomCenter: "bottom-center",
30
+ BottomRight: "bottom-right",
31
+ },
32
+ useVueFlow: () => ({
33
+ viewport: vue.ref({ x: 0, y: 0, zoom: 1 }),
34
+ getSelectedNodes: selectedNodes,
35
+ zoomTo: () => undefined,
36
+ }),
37
+ };
38
+ });
39
+
40
+ vi.mock("@vue-flow/background", () => ({
41
+ Background: { render: () => null },
42
+ }));
43
+ vi.mock("@vue-flow/controls", () => ({
44
+ Controls: { render: () => null },
45
+ ControlButton: { render: () => null },
46
+ }));
47
+ vi.mock("@vue-flow/minimap", () => ({ MiniMap: { render: () => null } }));
48
+
49
+ let app: App | undefined;
50
+
51
+ function mountCanvas(props: Record<string, unknown>) {
52
+ const deleted: string[][] = [];
53
+ app = createApp({
54
+ render: () =>
55
+ h(FlowCanvas, {
56
+ deletableNodes: true,
57
+ ...props,
58
+ onDeleteNodes: (ids: string[]) => deleted.push(ids),
59
+ }),
60
+ });
61
+ const container = document.createElement("div");
62
+ app.mount(container);
63
+ return { root: container.firstElementChild as HTMLElement, deleted };
64
+ }
65
+
66
+ afterEach(() => {
67
+ app?.unmount();
68
+ app = undefined;
69
+ });
70
+
71
+ const BADGE = ".dms-flow-canvas__delete-button";
72
+
73
+ describe("flow canvas delete badge", () => {
74
+ it("draws a delete badge on a selected node by default", () => {
75
+ const { root, deleted } = mountCanvas({});
76
+ const badge = root.querySelector<HTMLButtonElement>(BADGE);
77
+ expect(badge).not.toBeNull();
78
+ badge!.click();
79
+ expect(deleted).toEqual([["users"]]);
80
+ });
81
+
82
+ it("hides the badge on request and keeps keyboard deletion", () => {
83
+ const { root, deleted } = mountCanvas({ deleteBadge: false });
84
+ expect(root.querySelector(BADGE)).toBeNull();
85
+ root.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
86
+ expect(deleted).toEqual([["users"]]);
87
+ });
88
+ });
@@ -35,6 +35,17 @@ describe("page-mode form redirect", () => {
35
35
  ).toEqual({ path: "/workspaces/ws-42" });
36
36
  });
37
37
 
38
+ // The page path the router hands over is already percent-encoded: the path
39
+ // sent back is the one the page was reached at.
40
+ it("sends back an encoded page path as it was", () => {
41
+ expect(
42
+ resolveFormSuccessTarget(
43
+ EDIT_REDIRECT,
44
+ routeOf(EDIT_PATTERN, "/workspaces/ws%2042/invoiceTable/inv-7/edit"),
45
+ ),
46
+ ).toEqual({ path: "/workspaces/ws%2042" });
47
+ });
48
+
38
49
  it("carries back the declared query params only", () => {
39
50
  expect(
40
51
  resolveFormSuccessTarget(
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A KeyValueList row with `copy` draws a copy button next to its value, from
4
+ * static items as from the items a `fetchUrl` answers; `copyValue` copies
5
+ * another text than the one shown.
6
+ */
7
+ import { createApp, defineComponent, h, reactive, ref } from "vue";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { createI18n } from "vue-i18n";
10
+
11
+ const i18n = createI18n({
12
+ legacy: false,
13
+ locale: "en",
14
+ missingWarn: false,
15
+ fallbackWarn: false,
16
+ messages: { en: { dms: { button: { copy: "Copy", copied: "Copied" } } } },
17
+ });
18
+
19
+ const copied: string[] = [];
20
+
21
+ // jsdom has no clipboard: the button's copy lands in `copied` instead.
22
+ vi.mock("@vueuse/core", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("@vueuse/core")>()),
24
+ useClipboard: () => ({
25
+ copy: async (text: string) => {
26
+ copied.push(text);
27
+ },
28
+ copied: ref(false),
29
+ }),
30
+ }));
31
+
32
+ let fetchedItems: unknown[] = [];
33
+
34
+ vi.stubGlobal("useAuthFetch", () => ({
35
+ $authFetch: async () => ({ items: fetchedItems }),
36
+ }));
37
+ vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
38
+
39
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
40
+ useWatch: () => ({ state: ref({}) }),
41
+ }));
42
+ vi.mock(
43
+ "../layers/dms-core/app/composables/components/useComponentEvent",
44
+ () => ({ useComponentEvent: () => ({}) }),
45
+ );
46
+
47
+ const KeyValueListBlock = (
48
+ await import("../layers/dms-ui/app/components/blocks/KeyValueListBlock.vue")
49
+ ).default;
50
+
51
+ const SETTLE_MS = 50;
52
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
53
+ const mounted: Array<{ unmount: () => void }> = [];
54
+
55
+ function render(props: Record<string, unknown>): HTMLElement {
56
+ const root = document.createElement("div");
57
+ const app = createApp(KeyValueListBlock, { card: false, ...props });
58
+ app.component("UIcon", defineComponent({ render: () => h("i") }));
59
+ app.component("USkeleton", defineComponent({ render: () => h("span") }));
60
+ app.mount(root);
61
+ mounted.push(app);
62
+ return root;
63
+ }
64
+
65
+ function copyButtons(root: HTMLElement): HTMLButtonElement[] {
66
+ return [...root.querySelectorAll<HTMLButtonElement>("button[title]")];
67
+ }
68
+
69
+ beforeEach(() => {
70
+ fetchedItems = [];
71
+ copied.splice(0);
72
+ vi.stubGlobal("useI18n", () => ({
73
+ t: i18n.global.t,
74
+ locale: i18n.global.locale,
75
+ }));
76
+ });
77
+
78
+ afterEach(() => {
79
+ mounted.splice(0).forEach((app) => app.unmount());
80
+ });
81
+
82
+ describe("KeyValueList copy button", () => {
83
+ it("copies the shown value of a row that asks for it, and only those", async () => {
84
+ const root = render({
85
+ items: [
86
+ { label: "Endpoint", value: "https://api.example.com", copy: true },
87
+ { label: "Plan", value: "Business" },
88
+ { label: "Token", value: null, copy: true },
89
+ ],
90
+ });
91
+
92
+ const buttons = copyButtons(root);
93
+ expect(buttons).toHaveLength(1);
94
+ buttons[0]!.click();
95
+ await settle();
96
+ expect(copied).toEqual(["https://api.example.com"]);
97
+ });
98
+
99
+ it("copies copyValue rather than the shown text, from fetched items", async () => {
100
+ fetchedItems = [
101
+ {
102
+ label: "Thumbnail",
103
+ value: "thumb.webp",
104
+ copyValue: "https://cdn.example.com/media/42/thumb.webp",
105
+ },
106
+ ];
107
+ const root = render({ fetchUrl: "/api/media/42/urls" });
108
+ await settle();
109
+
110
+ copyButtons(root)[0]!.click();
111
+ await settle();
112
+ expect(copied).toEqual(["https://cdn.example.com/media/42/thumb.webp"]);
113
+ });
114
+ });
@@ -0,0 +1,53 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, expect, it } from "vitest";
3
+ import { type App, createApp, h, nextTick } from "vue";
4
+
5
+ const ITEMS = [
6
+ { value: "a", label: "Alpha" },
7
+ { value: "b", label: "Beta" },
8
+ ];
9
+ // The former `lg` viewport breakpoint, now read off the component's own width.
10
+ const SIDE_BY_SIDE_COLUMNS = "@5xl:grid-cols-[380px_minmax(0,1fr)]";
11
+ const VIEWPORT_VARIANT = /(?:^|\s)(?:sm|md|lg|xl|2xl):/;
12
+
13
+ let app: App | undefined;
14
+ let host: HTMLDivElement;
15
+
16
+ async function mount() {
17
+ const { default: MasterDetail } = await import(
18
+ "../layers/dms-ui/app/components/master-detail/MasterDetail.vue"
19
+ );
20
+ app = createApp({
21
+ setup: () => () =>
22
+ h(
23
+ MasterDetail,
24
+ { items: ITEMS, modelValue: "a" },
25
+ { default: () => h("p", "detail") },
26
+ ),
27
+ });
28
+ app.component("UIcon", () => h("i"));
29
+ app.mount(host);
30
+ await nextTick();
31
+ }
32
+
33
+ beforeEach(() => {
34
+ host = document.createElement("div");
35
+ document.body.append(host);
36
+ });
37
+
38
+ afterEach(() => {
39
+ app?.unmount();
40
+ host.remove();
41
+ });
42
+
43
+ it("lays the panes out from its own width, not the viewport's", async () => {
44
+ await mount();
45
+ const container = host.firstElementChild as HTMLElement;
46
+ const grid = container.firstElementChild as HTMLElement;
47
+ expect(container.classList).toContain("@container");
48
+ expect(grid.classList).toContain("grid-cols-1");
49
+ expect(grid.classList).toContain(SIDE_BY_SIDE_COLUMNS);
50
+ for (const element of host.querySelectorAll("*")) {
51
+ expect(element.getAttribute("class") ?? "").not.toMatch(VIEWPORT_VARIANT);
52
+ }
53
+ });
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ import { createApp, defineComponent, h, type App } from "vue";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import MonoCell from "../layers/dms-ui/app/build/components/table/MonoCell.vue";
5
+
6
+ const ButtonStub = defineComponent({
7
+ setup: () => () => h("button"),
8
+ });
9
+
10
+ let app: App | undefined;
11
+
12
+ function mountCell(copy: boolean) {
13
+ app = createApp({
14
+ render: () => h(MonoCell, { value: "a-very-long-identifier", copy }),
15
+ });
16
+ app.component("UButton", ButtonStub);
17
+ const container = document.createElement("div");
18
+ app.mount(container);
19
+ return container.firstElementChild as HTMLElement;
20
+ }
21
+
22
+ beforeEach(() => {
23
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
24
+ vi.stubGlobal("useToast", () => ({ add: () => undefined }));
25
+ vi.stubGlobal("Color", { success: "success" });
26
+ });
27
+
28
+ afterEach(() => {
29
+ app?.unmount();
30
+ app = undefined;
31
+ vi.unstubAllGlobals();
32
+ });
33
+
34
+ describe("mono cell", () => {
35
+ it("holds its root to the cell's width so a long value can truncate", () => {
36
+ const root = mountCell(true);
37
+ expect(root.classList).toContain("flex");
38
+ expect(root.classList).not.toContain("inline-flex");
39
+ expect(root.classList).toContain("max-w-full");
40
+ expect(root.classList).toContain("min-w-0");
41
+ const value = root.querySelector("span");
42
+ expect(value?.classList).toContain("truncate");
43
+ expect(value?.getAttribute("title")).toBe("a-very-long-identifier");
44
+ });
45
+
46
+ it("keeps the copy button at its size beside the value", () => {
47
+ const root = mountCell(true);
48
+ expect(root.querySelector("button")?.classList).toContain("shrink-0");
49
+ });
50
+ });
@@ -105,6 +105,7 @@ beforeEach(() => {
105
105
  vi.stubGlobal("useTranslation", () => ({
106
106
  processI18n: (text: string) => text,
107
107
  }));
108
+ vi.stubGlobal("useI18n", () => ({ t: String, locale: ref("en") }));
108
109
  });
109
110
 
110
111
  afterEach(() => {
@@ -90,9 +90,9 @@ const FLOW_PAGE_MARKUP =
90
90
  '<h1 class="text-highlighted text-2xl font-[650] leading-[1.2] tracking-[-0.03em]"><!--[-->Explorer<!--]--></h1><!--v-if--></div><!--[--><!--[--><!-- eslint-disable vue/no-v-html --><!--v-if--><!--]--><!--]--></section>' +
91
91
  '<div class="contents [html[data-dms-role-preview=pending]_&amp;]:hidden"><!--[--><!--[--><!--[-->' +
92
92
  '<div class="dms-page-stack space-y-6">' +
93
- '<div class="">' +
93
+ '<div class="empty:hidden">' +
94
94
  '<section data-component="stats" page-id="tools.explorer" layout-path="stats"></section></div>' +
95
- '<div class="">' +
95
+ '<div class="empty:hidden">' +
96
96
  '<section data-component="explorer" page-id="tools.explorer" layout-path="explorer"></section></div></div><!----><!--]--><!--]--><!--]--></div>' +
97
97
  PAGE_SKELETON_MARKUP +
98
98
  "<!----><!--]--><!--]--><!--]--></div></div><!--[--><!----><!--]--></div><!----></div>";
@@ -124,8 +124,12 @@ const Container: FunctionalComponent = (_, { attrs, slots }) =>
124
124
  h("div", { ...attrs, class: ["w-full", attrs.class] }, slots.default?.());
125
125
  Container.inheritAttrs = false;
126
126
 
127
+ // A component id starting with `hidden` stands for a block that has nothing
128
+ // to show (a fetched Banner the route answered `null`): it renders nothing.
127
129
  const PageComponent: FunctionalComponent<{ componentId: string }> = (props) =>
128
- h("section", { "data-component": props.componentId });
130
+ props.componentId.startsWith("hidden")
131
+ ? null
132
+ : h("section", { "data-component": props.componentId });
129
133
  PageComponent.props = ["componentId"];
130
134
 
131
135
  const FillHeightProbe: FunctionalComponent = () =>
@@ -299,6 +303,19 @@ describe("DefaultLayout without fillHeight", () => {
299
303
  });
300
304
  });
301
305
 
306
+ describe("a block that renders nothing", () => {
307
+ it("leaves an empty wrapper the stack hides, so it takes no gap", async () => {
308
+ pageLayout.value = withComponents("hiddenBanner", "stats");
309
+ const host = parse(await renderInLayout({}, CatchAllPage));
310
+ const [banner, stats] = Array.from(
311
+ host.querySelectorAll(".dms-page-stack > div"),
312
+ );
313
+ expect(banner?.matches(":empty")).toBe(true);
314
+ expectClasses(banner, "empty:hidden");
315
+ expect(stats?.matches(":empty")).toBe(false);
316
+ });
317
+ });
318
+
302
319
  describe("DefaultLayout with fillHeight", () => {
303
320
  it("makes the page region a flex column with a minimum height", async () => {
304
321
  const host = parse(
@@ -89,6 +89,15 @@ describe("form URL variables on a repeated placeholder", () => {
89
89
  ).toBe("/workspaces/ws-1/invoices/row-7");
90
90
  });
91
91
 
92
+ it("encodes an occurrence like any parameter", () => {
93
+ expect(
94
+ replaceUrlVariables("/workspaces/{{params.id:1}}/rows", {
95
+ routeParams: { id: "row-7", "id:1": "ws 1/2", "id:2": "row-7" },
96
+ routeQuery: {},
97
+ }),
98
+ ).toBe("/workspaces/ws%201%2F2/rows");
99
+ });
100
+
92
101
  it("leaves an indexed token that no occurrence answers alone", () => {
93
102
  expect(replaceUrlVariables("/invoices/{{params.id:3}}", context)).toBe(
94
103
  "/invoices/{{params.id:3}}",