@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.
- package/CHANGELOG.md +35 -0
- package/dist/hooks/tenant-instances.d.ts +1 -0
- package/dist/hooks/tenant-instances.js +13 -0
- package/dist/hooks/tenant-instances.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
- package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
- package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
- package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
- package/dist/test/unit/utils/tenant-instances.test.js +53 -0
- package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/tenant-instances.d.ts +2 -0
- package/dist/utils/tenant-instances.js +38 -0
- package/dist/utils/tenant-instances.js.map +1 -0
- package/dist/validation/auth.schema.d.ts +2 -2
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
- package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
- package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
- package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
- package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
- package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
- package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
- package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
- package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
- package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
- package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
- package/frontend-vue/package.json +2 -2
- package/frontend-vue/pnpm-lock.yaml +5 -5
- package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
- package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
- package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
- package/frontend-vue/tests/block-route-params.test.ts +48 -0
- package/frontend-vue/tests/code-block.test.ts +155 -0
- package/frontend-vue/tests/code-highlight.test.ts +72 -0
- package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
- package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
- package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
- package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
- package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
- package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
- package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
- package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
- package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
- package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
- package/frontend-vue/tests/page-fill-height.test.ts +20 -3
- package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
- package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
- package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
- package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
- package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
- package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
- package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
- package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
- package/frontend-vue/tests/table-view-sources.test.ts +67 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +14 -3
- package/skills/dms-pages/REFERENCE.md +17 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
computed,
|
|
3
|
-
onBeforeUnmount,
|
|
4
3
|
onMounted,
|
|
5
4
|
ref,
|
|
6
5
|
watch,
|
|
@@ -12,12 +11,11 @@ import {
|
|
|
12
11
|
usePeriodScope,
|
|
13
12
|
} from "../../../../dms-core/app/composables/period/usePeriodScope";
|
|
14
13
|
import type { PeriodState } from "../../../../dms-core/app/composables/period/types";
|
|
15
|
-
import { useRealtimeTopic } from "../../../../dms-core/app/composables/realtime/useRealtimeTopic";
|
|
16
14
|
import {
|
|
17
15
|
hasUrlVariables,
|
|
18
16
|
resolveUrlVariables,
|
|
19
17
|
} from "../../build/utils/urlVariables";
|
|
20
|
-
import {
|
|
18
|
+
import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
|
|
21
19
|
|
|
22
20
|
export interface UseChartFetchOptions<T> {
|
|
23
21
|
/**
|
|
@@ -132,20 +130,6 @@ function readStaticData<T>(
|
|
|
132
130
|
return source ?? null;
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
function subscribeRealtimeTopics(
|
|
136
|
-
topic: string | string[] | undefined,
|
|
137
|
-
onEvent: () => void,
|
|
138
|
-
): void {
|
|
139
|
-
if (!topic) return;
|
|
140
|
-
const topics = Array.isArray(topic) ? topic : [topic];
|
|
141
|
-
for (const entry of topics) {
|
|
142
|
-
useRealtimeTopic(entry, (event) => {
|
|
143
|
-
if (!("type" in event)) return;
|
|
144
|
-
onEvent();
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
133
|
/**
|
|
150
134
|
* The URL to request: `fetchUrl` with its tokens filled, or `undefined` while
|
|
151
135
|
* one has no value. Only a URL naming a token reads the route.
|
|
@@ -164,16 +148,6 @@ function useResolvedFetchUrl<T>(
|
|
|
164
148
|
);
|
|
165
149
|
}
|
|
166
150
|
|
|
167
|
-
// Mounted only: the page refresh is a browser event, and a block the server
|
|
168
|
-
// renders has nothing to refetch.
|
|
169
|
-
function subscribePageRefresh(onRefresh: () => void): void {
|
|
170
|
-
let unsubscribe: (() => void) | undefined;
|
|
171
|
-
onMounted(() => {
|
|
172
|
-
unsubscribe = onPageBlocksRefresh(onRefresh);
|
|
173
|
-
});
|
|
174
|
-
onBeforeUnmount(() => unsubscribe?.());
|
|
175
|
-
}
|
|
176
|
-
|
|
177
151
|
export function useChartFetch<T>(
|
|
178
152
|
options: UseChartFetchOptions<T>,
|
|
179
153
|
): UseChartFetchReturn<T> {
|
|
@@ -290,8 +264,7 @@ export function useChartFetch<T>(
|
|
|
290
264
|
scheduleRefresh,
|
|
291
265
|
);
|
|
292
266
|
onMounted(fetchOnMount);
|
|
293
|
-
|
|
294
|
-
subscribePageRefresh(refreshSameInputs);
|
|
267
|
+
useRefreshTriggers(options.realtimeTopic, refreshSameInputs);
|
|
295
268
|
} else if (typeof options.staticData === "function") {
|
|
296
269
|
watch(
|
|
297
270
|
() => readStaticData(options.staticData),
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ReplaceUrlVariablesContext,
|
|
14
14
|
hasUrlVariables,
|
|
15
15
|
replaceUrlVariables,
|
|
16
|
+
resolveTextVariables,
|
|
16
17
|
resolveUrlVariables,
|
|
17
18
|
} from "../../build/utils/urlVariables";
|
|
18
19
|
|
|
@@ -347,7 +348,7 @@ export function resolveSubmitDefaults(
|
|
|
347
348
|
const resolved: Record<string, unknown> = {};
|
|
348
349
|
for (const [key, value] of Object.entries(submitDefaults)) {
|
|
349
350
|
if (typeof value === "string" && hasUrlVariables(value)) {
|
|
350
|
-
const replaced =
|
|
351
|
+
const replaced = resolveTextVariables(value, context);
|
|
351
352
|
if (replaced === undefined) continue;
|
|
352
353
|
resolved[key] = replaced;
|
|
353
354
|
} else {
|
|
@@ -29,6 +29,8 @@ export interface TabProps extends TabComponentProps {
|
|
|
29
29
|
* names, read in one request and again when a watch action fires.
|
|
30
30
|
*/
|
|
31
31
|
badgesUrl?: string;
|
|
32
|
+
/** Topics whose events make the tab set read `badgesUrl` again. */
|
|
33
|
+
realtimeTopic?: string | string[];
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** The badges `badgesUrl` answers with, by tab slot. */
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import type { DrawerDirection } from "../../containers/types";
|
|
2
|
+
|
|
1
3
|
export type ActionTarget =
|
|
2
4
|
| {
|
|
3
5
|
type: "drawer";
|
|
4
6
|
component: ComponentInfo;
|
|
5
7
|
title?: string;
|
|
6
8
|
description?: string;
|
|
9
|
+
/** Edge the drawer slides in from (`bottom` by default). */
|
|
10
|
+
direction?: DrawerDirection;
|
|
7
11
|
}
|
|
8
12
|
| {
|
|
9
13
|
type: "modal";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The window event a data block (StatGroup, KeyValueList, NavCardGrid,
|
|
3
|
-
* ActivityFeed, KPI, top list, meter, chart, a tab set's badges)
|
|
4
|
-
* on, keeping its values on screen until the new answer lands.
|
|
3
|
+
* ActivityFeed, KPI, top list, meter, chart, a tab set's badges, table view)
|
|
4
|
+
* re-fetches on, keeping its values on screen until the new answer lands.
|
|
5
5
|
*/
|
|
6
6
|
export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
|
|
7
7
|
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"edit_item_description": "Change its fields, then save.",
|
|
26
26
|
"edit_item_in": "Changes are saved to {caption}.",
|
|
27
27
|
"search_placeholder": "Search...",
|
|
28
|
+
"search_open": "Open search",
|
|
28
29
|
"options_title": "Table options",
|
|
29
30
|
"filters_title": "Filters",
|
|
30
31
|
"columns_title": "Columns",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"edit_item_description": "Modifiez ses champs, puis enregistrez.",
|
|
26
26
|
"edit_item_in": "Les modifications sont enregistrées dans « {caption} ».",
|
|
27
27
|
"search_placeholder": "Rechercher...",
|
|
28
|
+
"search_open": "Ouvrir la recherche",
|
|
28
29
|
"options_title": "Options de la table",
|
|
29
30
|
"filters_title": "Filtres",
|
|
30
31
|
"columns_title": "Colonnes",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"packageManager": "pnpm@10.6.5",
|
|
13
13
|
"engines": {
|
|
14
|
-
"@antelopejs/dms-frontend": ">=0.5.
|
|
14
|
+
"@antelopejs/dms-frontend": ">=0.5.2 <0.6.0"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"typecheck": "ajs dms verify-source --layer \"$PWD\" --local-package @antelopejs/dms=.. --local-package @antelopejs/interface-dms=../../interface-dms",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@antelopejs/core": ">=1.13.4 <2",
|
|
76
|
-
"@antelopejs/dms-frontend": ">=0.5.
|
|
76
|
+
"@antelopejs/dms-frontend": ">=0.5.2 <0.6.0",
|
|
77
77
|
"@eslint/js": "^8.57.0",
|
|
78
78
|
"@inertiajs/vue3": "^3.7.0",
|
|
79
79
|
"@nuxt/ui": "4.10.0",
|
|
@@ -157,8 +157,8 @@ importers:
|
|
|
157
157
|
specifier: '>=1.13.4 <2'
|
|
158
158
|
version: 1.13.5
|
|
159
159
|
'@antelopejs/dms-frontend':
|
|
160
|
-
specifier: '>=0.5.
|
|
161
|
-
version: 0.5.
|
|
160
|
+
specifier: '>=0.5.2 <0.6.0'
|
|
161
|
+
version: 0.5.2(@antelopejs/core@1.13.5)
|
|
162
162
|
'@eslint/js':
|
|
163
163
|
specifier: ^8.57.0
|
|
164
164
|
version: 8.57.0
|
|
@@ -234,8 +234,8 @@ packages:
|
|
|
234
234
|
engines: {node: '>=20.19'}
|
|
235
235
|
hasBin: true
|
|
236
236
|
|
|
237
|
-
'@antelopejs/dms-frontend@0.5.
|
|
238
|
-
resolution: {integrity: sha512-
|
|
237
|
+
'@antelopejs/dms-frontend@0.5.2':
|
|
238
|
+
resolution: {integrity: sha512-0kChPedSTpKES7aN/gLj5tAe4WRaU+FrwaRKpOyvo3fvvRPfDTvlTYjjdUpabsF150d3+V/AksXstZ5eb36QwQ==}
|
|
239
239
|
engines: {node: '>=20.12.0'}
|
|
240
240
|
hasBin: true
|
|
241
241
|
peerDependencies:
|
|
@@ -4205,7 +4205,7 @@ snapshots:
|
|
|
4205
4205
|
semver: 7.8.5
|
|
4206
4206
|
typescript: 5.9.3
|
|
4207
4207
|
|
|
4208
|
-
'@antelopejs/dms-frontend@0.5.
|
|
4208
|
+
'@antelopejs/dms-frontend@0.5.2(@antelopejs/core@1.13.5)':
|
|
4209
4209
|
dependencies:
|
|
4210
4210
|
'@antelopejs/core': 1.13.5
|
|
4211
4211
|
chokidar: 4.0.3
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
formatActivityTime,
|
|
4
4
|
formatShortDate,
|
|
5
5
|
groupActivityByDay,
|
|
6
|
+
activityText,
|
|
6
7
|
resolveActivityParams,
|
|
7
8
|
relativeDayBucket,
|
|
8
9
|
} from "../layers/dms-ui/app/build/components/activity-feed/activityFeedDays";
|
|
@@ -87,25 +88,38 @@ describe("activity feed day groups", () => {
|
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
describe("resolveActivityParams", () => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`${key}(${JSON.stringify(params ?? {})})`;
|
|
93
|
-
|
|
94
|
-
it("translates a $ value with the entry's literal values", () => {
|
|
91
|
+
it("makes a $ value a text of its own, with the entry's other values", () => {
|
|
92
|
+
const count = { type: "count", value: 2 } as const;
|
|
95
93
|
expect(
|
|
96
|
-
resolveActivityParams(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
resolveActivityParams({
|
|
95
|
+
device: "$activity.device",
|
|
96
|
+
browser: "Chrome",
|
|
97
|
+
count,
|
|
98
|
+
}),
|
|
100
99
|
).toEqual({
|
|
101
|
-
device:
|
|
100
|
+
device: {
|
|
101
|
+
key: "$activity.device",
|
|
102
|
+
params: { browser: "Chrome", count },
|
|
103
|
+
},
|
|
102
104
|
browser: "Chrome",
|
|
103
|
-
|
|
105
|
+
count,
|
|
104
106
|
});
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
it("gives nothing for an entry without values", () => {
|
|
108
|
-
expect(resolveActivityParams(undefined
|
|
110
|
+
expect(resolveActivityParams(undefined)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("activityText", () => {
|
|
115
|
+
it("gives a $ title the entry's values and leaves other texts alone", () => {
|
|
116
|
+
expect(activityText("$a.title", { name: "Léa" })).toEqual({
|
|
117
|
+
key: "$a.title",
|
|
118
|
+
params: { name: "Léa" },
|
|
119
|
+
});
|
|
120
|
+
expect(activityText("Plain", { name: "Léa" })).toBe("Plain");
|
|
121
|
+
const composed = { key: "a.composed" };
|
|
122
|
+
expect(activityText(composed, { name: "Léa" })).toBe(composed);
|
|
109
123
|
});
|
|
110
124
|
});
|
|
111
125
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A Banner with a `fetchUrl` draws what its route answers, over its static
|
|
4
|
+
* options field by field; draws nothing while the first answer is on its way
|
|
5
|
+
* or when the route answers no banner (`null`, `{}`, a 204); reads its route
|
|
6
|
+
* again on a page refresh; and runs the button targets the route answers.
|
|
7
|
+
*/
|
|
8
|
+
import { createApp, defineComponent, h, reactive, ref } from "vue";
|
|
9
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
10
|
+
import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
|
|
11
|
+
|
|
12
|
+
const requests: string[] = [];
|
|
13
|
+
let answer: unknown = null;
|
|
14
|
+
const pressed: unknown[] = [];
|
|
15
|
+
|
|
16
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
17
|
+
$authFetch: async (url: string) => {
|
|
18
|
+
requests.push(url);
|
|
19
|
+
return answer;
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
|
|
23
|
+
vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
|
|
24
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
25
|
+
vi.stubGlobal("useApiError", () => {});
|
|
26
|
+
vi.stubGlobal("useI18n", () => ({ t: String, locale: ref("en") }));
|
|
27
|
+
|
|
28
|
+
vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
|
|
29
|
+
useWatch: () => ({ state: ref({}) }),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock(
|
|
32
|
+
"../layers/dms-ui/app/build/composables/actions/useActionTargets",
|
|
33
|
+
() => ({
|
|
34
|
+
useActionTargets: () => ({
|
|
35
|
+
handleCustomButton: (button: unknown) => pressed.push(button),
|
|
36
|
+
}),
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
vi.mock("../layers/dms-ui/app/components/banner/Banner.vue", () => ({
|
|
40
|
+
default: defineComponent({
|
|
41
|
+
props: {
|
|
42
|
+
title: { type: String, default: undefined },
|
|
43
|
+
description: { type: String, default: undefined },
|
|
44
|
+
tone: { type: String, default: undefined },
|
|
45
|
+
icon: { type: String, default: undefined },
|
|
46
|
+
},
|
|
47
|
+
setup:
|
|
48
|
+
(props, { slots }) =>
|
|
49
|
+
() =>
|
|
50
|
+
h("section", { "data-banner": JSON.stringify(props) }, [
|
|
51
|
+
slots.actions?.(),
|
|
52
|
+
]),
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const { default: BannerBlock } = await import(
|
|
57
|
+
"../layers/dms-ui/app/components/blocks/BannerBlock.vue"
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// useChartFetch debounces every refresh past the first by 180ms.
|
|
61
|
+
const SETTLE_MS = 250;
|
|
62
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
63
|
+
const FETCH_URL = "/api/ws/{{params.id}}/health";
|
|
64
|
+
|
|
65
|
+
const UButton = defineComponent({
|
|
66
|
+
props: { label: { type: String, default: undefined } },
|
|
67
|
+
setup:
|
|
68
|
+
(props, { attrs }) =>
|
|
69
|
+
() =>
|
|
70
|
+
h("button", { onClick: attrs.onClick }, props.label),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
74
|
+
|
|
75
|
+
function mountBanner(props: Record<string, unknown>) {
|
|
76
|
+
const root = document.createElement("div");
|
|
77
|
+
const app = createApp(BannerBlock, {
|
|
78
|
+
componentId: "health",
|
|
79
|
+
pageId: "ws",
|
|
80
|
+
routeParams: { id: "ws-1" },
|
|
81
|
+
fetchUrl: FETCH_URL,
|
|
82
|
+
...props,
|
|
83
|
+
});
|
|
84
|
+
app.component("UButton", UButton);
|
|
85
|
+
app.mount(root);
|
|
86
|
+
mounted.push(app);
|
|
87
|
+
return {
|
|
88
|
+
root,
|
|
89
|
+
read: () => {
|
|
90
|
+
const banner = root.querySelector("[data-banner]");
|
|
91
|
+
return banner ? JSON.parse(banner.getAttribute("data-banner")!) : null;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
requests.length = 0;
|
|
98
|
+
pressed.length = 0;
|
|
99
|
+
answer = null;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("draws the fetched props over the static options", async () => {
|
|
107
|
+
answer = { tone: "error", title: "Provider down" };
|
|
108
|
+
const banner = mountBanner({ title: "Static", description: "Kept" });
|
|
109
|
+
expect(banner.read()).toBeNull();
|
|
110
|
+
await settle();
|
|
111
|
+
|
|
112
|
+
expect(requests).toEqual(["/api/ws/ws-1/health"]);
|
|
113
|
+
expect(banner.read()).toMatchObject({
|
|
114
|
+
tone: "error",
|
|
115
|
+
title: "Provider down",
|
|
116
|
+
description: "Kept",
|
|
117
|
+
icon: "i-ph-warning-circle",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each([
|
|
122
|
+
["null", null],
|
|
123
|
+
["an empty object", {}],
|
|
124
|
+
["no content (204)", undefined],
|
|
125
|
+
])("draws nothing when the route answers %s", async (_name, response) => {
|
|
126
|
+
answer = response;
|
|
127
|
+
const banner = mountBanner({ title: "Static" });
|
|
128
|
+
await settle();
|
|
129
|
+
expect(requests).toHaveLength(1);
|
|
130
|
+
expect(banner.root.innerHTML).toBe("<!--v-if-->");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("follows a page refresh, appearing and going away", async () => {
|
|
134
|
+
const banner = mountBanner({});
|
|
135
|
+
await settle();
|
|
136
|
+
expect(banner.read()).toBeNull();
|
|
137
|
+
|
|
138
|
+
answer = { title: "Degraded" };
|
|
139
|
+
refreshPageBlocks();
|
|
140
|
+
await settle();
|
|
141
|
+
expect(banner.read()).toMatchObject({ title: "Degraded" });
|
|
142
|
+
|
|
143
|
+
answer = null;
|
|
144
|
+
refreshPageBlocks();
|
|
145
|
+
await settle();
|
|
146
|
+
expect(banner.read()).toBeNull();
|
|
147
|
+
expect(requests).toHaveLength(3);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("runs a button target the route answers", async () => {
|
|
151
|
+
const replay = {
|
|
152
|
+
label: "Replay",
|
|
153
|
+
target: { type: "api", url: "/api/replay", successMessage: "Replayed" },
|
|
154
|
+
confirm: { title: "Replay the queue?" },
|
|
155
|
+
};
|
|
156
|
+
answer = { title: "Outage", actions: [replay] };
|
|
157
|
+
const banner = mountBanner({});
|
|
158
|
+
await settle();
|
|
159
|
+
|
|
160
|
+
banner.root.querySelector("button")!.click();
|
|
161
|
+
expect(pressed).toEqual([replay]);
|
|
162
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every block reading its data through useChartFetch hands it the period
|
|
3
|
+
* scope and the realtime topics of its options: a block that dropped them
|
|
4
|
+
* would silently stop following its PeriodSelector or its topics.
|
|
5
|
+
*/
|
|
6
|
+
import { type Component, createSSRApp, defineComponent, h, ref } from "vue";
|
|
7
|
+
import { renderToString } from "vue/server-renderer";
|
|
8
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
9
|
+
import type { UseChartFetchOptions } from "../layers/dms-ui/app/composables/chart/useChartFetch";
|
|
10
|
+
|
|
11
|
+
const fetchOptions: Array<UseChartFetchOptions<unknown>> = [];
|
|
12
|
+
|
|
13
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
14
|
+
useChartFetch: (options: UseChartFetchOptions<unknown>) => {
|
|
15
|
+
fetchOptions.push(options);
|
|
16
|
+
return {
|
|
17
|
+
data: ref(null),
|
|
18
|
+
isLoading: ref(false),
|
|
19
|
+
error: ref(null),
|
|
20
|
+
refresh: async () => {},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
|
|
25
|
+
useWatch: () => ({ state: ref({}) }),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock(
|
|
28
|
+
"../layers/dms-core/app/composables/components/useComponentEvent",
|
|
29
|
+
() => ({ useComponentEvent: () => ({}) }),
|
|
30
|
+
);
|
|
31
|
+
// The frames around the blocks are not under test here.
|
|
32
|
+
vi.mock("../layers/dms-ui/app/components/card/Card.vue", () => ({
|
|
33
|
+
default: defineComponent({ setup: () => () => h("div") }),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock(
|
|
36
|
+
"#dms-layout/app/build/composables/navigation/useCategoryNavCards",
|
|
37
|
+
() => ({
|
|
38
|
+
useCategoryNavCards: () => ({ cards: ref([]) }),
|
|
39
|
+
usePreviewEntryVeil: () => ({}),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const BLOCKS: Record<string, string> = {
|
|
44
|
+
StatGroupBlock: "../layers/dms-ui/app/components/blocks/StatGroupBlock.vue",
|
|
45
|
+
KeyValueListBlock:
|
|
46
|
+
"../layers/dms-ui/app/components/blocks/KeyValueListBlock.vue",
|
|
47
|
+
NavCardGridBlock:
|
|
48
|
+
"../layers/dms-ui/app/components/blocks/NavCardGridBlock.vue",
|
|
49
|
+
ActivityFeed:
|
|
50
|
+
"../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue",
|
|
51
|
+
MeterBlock: "../layers/dms-ui/app/components/meter/MeterBlock.vue",
|
|
52
|
+
};
|
|
53
|
+
const GLOBAL_COMPONENTS = ["DmsCard", "UIcon", "USkeleton", "UButton", "ULink"];
|
|
54
|
+
const SCOPE = "dashboard";
|
|
55
|
+
const TOPICS = ["stats:first", "stats:second"];
|
|
56
|
+
|
|
57
|
+
const Slot = defineComponent({
|
|
58
|
+
setup:
|
|
59
|
+
(_, { slots }) =>
|
|
60
|
+
() =>
|
|
61
|
+
h("div", slots.default?.()),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function render(block: Component): Promise<void> {
|
|
65
|
+
const app = createSSRApp({
|
|
66
|
+
render: () =>
|
|
67
|
+
h(block, {
|
|
68
|
+
componentId: "block",
|
|
69
|
+
pageId: "page",
|
|
70
|
+
fetchUrl: "/api/data",
|
|
71
|
+
periodScope: SCOPE,
|
|
72
|
+
realtimeTopic: TOPICS,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
for (const name of GLOBAL_COMPONENTS) app.component(name, Slot);
|
|
76
|
+
await renderToString(app);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
fetchOptions.length = 0;
|
|
81
|
+
vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
|
|
82
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
83
|
+
vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
87
|
+
|
|
88
|
+
it.each(Object.keys(BLOCKS))(
|
|
89
|
+
"%s forwards periodScope and realtimeTopic",
|
|
90
|
+
async (name) => {
|
|
91
|
+
const block = (await import(BLOCKS[name])).default as Component;
|
|
92
|
+
await render(block);
|
|
93
|
+
expect(fetchOptions).toHaveLength(1);
|
|
94
|
+
expect(fetchOptions[0]).toMatchObject({
|
|
95
|
+
fetchUrl: "/api/data",
|
|
96
|
+
periodScope: SCOPE,
|
|
97
|
+
realtimeTopic: TOPICS,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A list block reading its items from a route follows a PeriodSelector like
|
|
4
|
+
* a KPI card (the period goes with every request, and a new period reads the
|
|
5
|
+
* route again) and reads the route again on each event of its realtime
|
|
6
|
+
* topics, keeping its items on screen until the answer lands.
|
|
7
|
+
*/
|
|
8
|
+
import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
|
|
9
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
10
|
+
import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
|
|
11
|
+
|
|
12
|
+
const requests: string[] = [];
|
|
13
|
+
let revision = 1;
|
|
14
|
+
const topicHandlers = new Map<string, RealtimeTopicHandler>();
|
|
15
|
+
|
|
16
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
17
|
+
$authFetch: async (url: string) => {
|
|
18
|
+
requests.push(url);
|
|
19
|
+
return { items: [{ revision }] };
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
|
|
23
|
+
|
|
24
|
+
vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
|
|
25
|
+
useWatch: () => ({ state: ref({}) }),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock(
|
|
28
|
+
"../layers/dms-core/app/composables/components/useComponentEvent",
|
|
29
|
+
() => ({ useComponentEvent: () => ({}) }),
|
|
30
|
+
);
|
|
31
|
+
vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
|
|
32
|
+
useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
|
|
33
|
+
topicHandlers.set(topic, handler);
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const { usePeriod } = await import(
|
|
38
|
+
"../layers/dms-core/app/composables/period/usePeriod"
|
|
39
|
+
);
|
|
40
|
+
const { registerPeriodScope } = await import(
|
|
41
|
+
"../layers/dms-core/app/composables/period/usePeriodScope"
|
|
42
|
+
);
|
|
43
|
+
const { useBlockItems } = await import(
|
|
44
|
+
"../layers/dms-ui/app/build/composables/blocks/useBlockItems"
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const SCOPE = "block-items-period";
|
|
48
|
+
const STATS_URL = "/api/stats";
|
|
49
|
+
const FACTS_TOPICS = ["facts:first", "facts:second"];
|
|
50
|
+
// useChartFetch debounces every refresh past the first by 180ms.
|
|
51
|
+
const SETTLE_MS = 250;
|
|
52
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
53
|
+
|
|
54
|
+
interface ItemsProbe {
|
|
55
|
+
items?: { value: unknown[] };
|
|
56
|
+
isPending?: { value: boolean };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SelectedPeriod {
|
|
60
|
+
period?: ReturnType<typeof usePeriod>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
64
|
+
|
|
65
|
+
function mountPeriodPage() {
|
|
66
|
+
const selected: SelectedPeriod = {};
|
|
67
|
+
const Selector = defineComponent({
|
|
68
|
+
setup() {
|
|
69
|
+
const period = usePeriod({ defaultPreset: "last-7-days" });
|
|
70
|
+
registerPeriodScope(SCOPE, period.state);
|
|
71
|
+
selected.period = period;
|
|
72
|
+
return () => h("div");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const Block = defineComponent({
|
|
76
|
+
setup() {
|
|
77
|
+
useBlockItems({
|
|
78
|
+
items: () => [],
|
|
79
|
+
fetchUrl: STATS_URL,
|
|
80
|
+
periodScope: SCOPE,
|
|
81
|
+
});
|
|
82
|
+
return () => h("div");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const app = createApp({ render: () => h("main", [h(Selector), h(Block)]) });
|
|
86
|
+
app.mount(document.createElement("div"));
|
|
87
|
+
mounted.push(app);
|
|
88
|
+
return selected;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mountRealtimeBlock(probe: ItemsProbe) {
|
|
92
|
+
const Block = defineComponent({
|
|
93
|
+
setup() {
|
|
94
|
+
const { items, isPending } = useBlockItems({
|
|
95
|
+
items: () => [],
|
|
96
|
+
fetchUrl: STATS_URL,
|
|
97
|
+
realtimeTopic: FACTS_TOPICS,
|
|
98
|
+
});
|
|
99
|
+
probe.items = items;
|
|
100
|
+
probe.isPending = isPending;
|
|
101
|
+
return () => h("div");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const app = createApp(Block);
|
|
105
|
+
app.mount(document.createElement("div"));
|
|
106
|
+
mounted.push(app);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
requests.length = 0;
|
|
111
|
+
revision = 1;
|
|
112
|
+
topicHandlers.clear();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("requests the items with the period of its scope, again when it changes", async () => {
|
|
120
|
+
const selected = mountPeriodPage();
|
|
121
|
+
await settle();
|
|
122
|
+
expect(requests).toHaveLength(1);
|
|
123
|
+
expect(requests[0]).toMatch(/^\/api\/stats\?from=.*&preset=last-7-days/);
|
|
124
|
+
|
|
125
|
+
selected.period?.setPreset("last-30-days");
|
|
126
|
+
await nextTick();
|
|
127
|
+
await settle();
|
|
128
|
+
expect(requests).toHaveLength(2);
|
|
129
|
+
expect(requests[1]).toContain("preset=last-30-days");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reads the route again on each event of any of its topics", async () => {
|
|
133
|
+
const probe: ItemsProbe = {};
|
|
134
|
+
mountRealtimeBlock(probe);
|
|
135
|
+
await settle();
|
|
136
|
+
expect([...topicHandlers.keys()]).toEqual(FACTS_TOPICS);
|
|
137
|
+
expect(probe.items?.value).toEqual([{ revision: 1 }]);
|
|
138
|
+
|
|
139
|
+
revision = 2;
|
|
140
|
+
topicHandlers.get("facts:second")?.({ type: "facts.update" } as never);
|
|
141
|
+
expect(probe.isPending?.value).toBe(false);
|
|
142
|
+
expect(probe.items?.value).toEqual([{ revision: 1 }]);
|
|
143
|
+
await settle();
|
|
144
|
+
|
|
145
|
+
expect(requests).toEqual([STATS_URL, STATS_URL]);
|
|
146
|
+
expect(probe.items?.value).toEqual([{ revision: 2 }]);
|
|
147
|
+
});
|