@antelopejs/dms 0.7.1 → 0.7.3
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 +47 -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 +4 -3
- package/frontend-vue/pnpm-lock.yaml +8 -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
|
@@ -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
|
+
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
18
|
import {
|
|
19
19
|
replaceUrlVariables,
|
|
20
|
+
resolveTextVariables,
|
|
20
21
|
resolveUrlVariables,
|
|
21
22
|
} from "../layers/dms-ui/app/build/utils/urlVariables";
|
|
22
23
|
|
|
@@ -96,6 +97,53 @@ describe("resolveUrlVariables", () => {
|
|
|
96
97
|
);
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
it("encodes the values it fills, so each stays one parameter", () => {
|
|
101
|
+
expect(
|
|
102
|
+
resolveUrlVariables("/api/log/{{params.id}}?route={{query.route}}", {
|
|
103
|
+
routeParams: { id: "a b" },
|
|
104
|
+
routeQuery: { route: "GET /api/a?b=c&d" },
|
|
105
|
+
}),
|
|
106
|
+
).toBe("/api/log/a%20b?route=GET%20%2Fapi%2Fa%3Fb%3Dc%26d");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// The router keeps the page path percent-encoded, and route parameters are
|
|
110
|
+
// read from it.
|
|
111
|
+
it("does not encode a route parameter twice", () => {
|
|
112
|
+
expect(
|
|
113
|
+
replaceUrlVariables("/api/ws/{{params.id}}", {
|
|
114
|
+
routeParams: { id: "caf%C3%A9%20bar" },
|
|
115
|
+
routeQuery: {},
|
|
116
|
+
}),
|
|
117
|
+
).toBe("/api/ws/caf%C3%A9%20bar");
|
|
118
|
+
expect(
|
|
119
|
+
replaceUrlVariables("/api/ws/{{params.id}}", {
|
|
120
|
+
routeParams: { id: "100%" },
|
|
121
|
+
routeQuery: {},
|
|
122
|
+
}),
|
|
123
|
+
).toBe("/api/ws/100%25");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("writes response values as answered", () => {
|
|
127
|
+
expect(
|
|
128
|
+
replaceUrlVariables("{{response.redirectPath}}&tab={{query.tab}}", {
|
|
129
|
+
routeQuery: { tab: "a&b" },
|
|
130
|
+
response: { redirectPath: "/settings/users?invited=1" },
|
|
131
|
+
}),
|
|
132
|
+
).toBe("/settings/users?invited=1&tab=a%26b");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("fills a plain value without encoding it", () => {
|
|
136
|
+
expect(
|
|
137
|
+
resolveTextVariables("{{query.status}} / {{params.id}}", {
|
|
138
|
+
routeParams: { id: "ws-1" },
|
|
139
|
+
routeQuery: { status: "on hold" },
|
|
140
|
+
}),
|
|
141
|
+
).toBe("on hold / ws-1");
|
|
142
|
+
expect(resolveTextVariables("{{query.status}}", { routeQuery: {} })).toBe(
|
|
143
|
+
undefined,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
99
147
|
it("leaves a URL without tokens as it is", () => {
|
|
100
148
|
expect(resolveUrlVariables("/api/plain", { routeQuery: {} })).toBe(
|
|
101
149
|
"/api/plain",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { type App, createApp, h, ref } from "vue";
|
|
4
|
+
|
|
5
|
+
const data = ref<Record<string, unknown> | null>(null);
|
|
6
|
+
const error = ref<unknown>(null);
|
|
7
|
+
const isLoading = ref(false);
|
|
8
|
+
const fetchOptions = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
11
|
+
useChartFetch: (options: unknown) => {
|
|
12
|
+
fetchOptions(options);
|
|
13
|
+
return { data, isLoading, error, refresh: async () => {} };
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
|
|
18
|
+
useWatch: () => ({ state: ref({}) }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
let app: App | undefined;
|
|
22
|
+
|
|
23
|
+
const flushPromises = () => new Promise((resolve) => setTimeout(resolve));
|
|
24
|
+
let host: HTMLDivElement;
|
|
25
|
+
|
|
26
|
+
async function mount(path: string, props: Record<string, unknown>) {
|
|
27
|
+
const { default: component } = await import(path);
|
|
28
|
+
app = createApp({ setup: () => () => h(component, props) });
|
|
29
|
+
for (const name of ["UIcon", "USkeleton", "UButton"]) {
|
|
30
|
+
app.component(name, (_, { attrs }) =>
|
|
31
|
+
h("i", { ...attrs, "data-stub": name }),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
app.mount(host);
|
|
35
|
+
await flushPromises();
|
|
36
|
+
await vi.dynamicImportSettled();
|
|
37
|
+
await flushPromises();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mountSnippet = (props: Record<string, unknown>) =>
|
|
41
|
+
mount("../layers/dms-ui/app/components/code-snippet/CodeSnippet.vue", props);
|
|
42
|
+
const mountBlock = (props: Record<string, unknown>) =>
|
|
43
|
+
mount("../layers/dms-ui/app/components/blocks/CodeBlock.vue", {
|
|
44
|
+
componentId: "code",
|
|
45
|
+
pageId: "page",
|
|
46
|
+
...props,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const pre = () => host.querySelector("pre")!;
|
|
50
|
+
const copyButton = () => host.querySelector("button");
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
data.value = null;
|
|
54
|
+
error.value = null;
|
|
55
|
+
isLoading.value = false;
|
|
56
|
+
fetchOptions.mockClear();
|
|
57
|
+
vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
|
|
58
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
59
|
+
host = document.createElement("div");
|
|
60
|
+
document.body.append(host);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
app?.unmount();
|
|
65
|
+
host.remove();
|
|
66
|
+
vi.unstubAllGlobals();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("DmsCodeSnippet", () => {
|
|
70
|
+
it("highlights the code in its language, under a head naming it", async () => {
|
|
71
|
+
await mountSnippet({
|
|
72
|
+
code: '{ "id": 1 }',
|
|
73
|
+
language: "json",
|
|
74
|
+
title: "Body",
|
|
75
|
+
});
|
|
76
|
+
expect(pre().textContent).toBe('{ "id": 1 }');
|
|
77
|
+
expect(pre().querySelector('[style*="--ui-info"]')?.textContent).toBe(
|
|
78
|
+
'"id"',
|
|
79
|
+
);
|
|
80
|
+
expect(host.textContent).toContain("Body");
|
|
81
|
+
expect(host.textContent).toContain("JSON");
|
|
82
|
+
expect(copyButton()).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reads an alias as its language", async () => {
|
|
86
|
+
await mountSnippet({ code: "pnpm install", language: "bash" });
|
|
87
|
+
expect(pre().dataset.language).toBe("shell");
|
|
88
|
+
expect(host.textContent).toContain("Shell");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("drops the copy button and the head when nothing is left to show", async () => {
|
|
92
|
+
await mountSnippet({ code: "plain words", copy: false });
|
|
93
|
+
expect(copyButton()).toBeNull();
|
|
94
|
+
expect(pre().previousElementSibling).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("wraps long lines or scrolls them, and caps the height in lines", async () => {
|
|
98
|
+
await mountSnippet({ code: "a\nb\nc", wrap: true, maxLines: 2 });
|
|
99
|
+
expect(pre().className).toContain("whitespace-pre-wrap");
|
|
100
|
+
expect(pre().style.maxHeight).toBe("54px");
|
|
101
|
+
app?.unmount();
|
|
102
|
+
await mountSnippet({ code: "a" });
|
|
103
|
+
expect(pre().className).toContain("whitespace-pre");
|
|
104
|
+
expect(pre().className).not.toContain("whitespace-pre-wrap");
|
|
105
|
+
expect(pre().style.maxHeight).toBe("");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("CodeBlock", () => {
|
|
110
|
+
it("shows its static code and title", async () => {
|
|
111
|
+
await mountBlock({ code: "SELECT 1", language: "sql", title: "Query" });
|
|
112
|
+
expect(pre().textContent).toBe("SELECT 1");
|
|
113
|
+
expect(host.textContent).toContain("Query");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("replaces the code and the language with what its route answers", async () => {
|
|
117
|
+
data.value = { code: "echo hi", language: "shell" };
|
|
118
|
+
await mountBlock({
|
|
119
|
+
code: "old",
|
|
120
|
+
language: "json",
|
|
121
|
+
fetchUrl: "/api/snippet",
|
|
122
|
+
});
|
|
123
|
+
expect(fetchOptions).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({ fetchUrl: "/api/snippet" }),
|
|
125
|
+
);
|
|
126
|
+
expect(pre().textContent).toBe("echo hi");
|
|
127
|
+
expect(pre().dataset.language).toBe("shell");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("draws placeholder lines until the first answer", async () => {
|
|
131
|
+
isLoading.value = true;
|
|
132
|
+
await mountBlock({ fetchUrl: "/api/snippet" });
|
|
133
|
+
expect(host.querySelector("pre")).toBeNull();
|
|
134
|
+
expect(host.querySelector("[aria-busy]")).not.toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("offers a retry when the first load fails", async () => {
|
|
138
|
+
error.value = new Error("network");
|
|
139
|
+
await mountBlock({ fetchUrl: "/api/snippet" });
|
|
140
|
+
expect(host.querySelector("pre")).toBeNull();
|
|
141
|
+
expect(host.textContent).toContain("dms.blocks.load_error");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("EmptyState block", () => {
|
|
146
|
+
it("shows a snippet between its text and its actions", async () => {
|
|
147
|
+
await mount("../layers/dms-ui/app/components/blocks/EmptyStateBlock.vue", {
|
|
148
|
+
title: "No email sent yet",
|
|
149
|
+
code: { content: "await mail.send({ to })", language: "typescript" },
|
|
150
|
+
});
|
|
151
|
+
expect(pre().textContent).toBe("await mail.send({ to })");
|
|
152
|
+
expect(host.textContent).toContain("TypeScript");
|
|
153
|
+
expect(copyButton()).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type CodeToken,
|
|
4
|
+
highlightCode,
|
|
5
|
+
} from "../layers/dms-ui/app/build/utils/codeHighlight";
|
|
6
|
+
import { resolveCodeLanguage } from "../layers/dms-ui/app/build/utils/codeLanguages";
|
|
7
|
+
|
|
8
|
+
const KEYWORD = "var(--ui-primary)";
|
|
9
|
+
const STRING = "var(--ui-success)";
|
|
10
|
+
const NUMBER = "var(--ui-warning)";
|
|
11
|
+
const COMMENT = "var(--ui-text-dimmed)";
|
|
12
|
+
const PROPERTY = "var(--ui-info)";
|
|
13
|
+
|
|
14
|
+
const colorOf = (tokens: CodeToken[], text: string) =>
|
|
15
|
+
tokens.find((token) => token.text === text)?.style?.color;
|
|
16
|
+
|
|
17
|
+
const joined = (tokens: CodeToken[]) =>
|
|
18
|
+
tokens.map((token) => token.text).join("");
|
|
19
|
+
|
|
20
|
+
describe("read-only code highlighting", () => {
|
|
21
|
+
it("keeps every character of the code, in order", () => {
|
|
22
|
+
const code = '{\n "name": "media",\n "size": 42\n}';
|
|
23
|
+
expect(joined(highlightCode(code, "json"))).toBe(code);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("colours JSON keys, strings and numbers", () => {
|
|
27
|
+
const tokens = highlightCode('{ "name": "media", "size": 42 }', "json");
|
|
28
|
+
expect(colorOf(tokens, '"name"')).toBe(PROPERTY);
|
|
29
|
+
expect(colorOf(tokens, '"media"')).toBe(STRING);
|
|
30
|
+
expect(colorOf(tokens, "42")).toBe(NUMBER);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reads TypeScript, SQL and HTML with the editor's languages", () => {
|
|
34
|
+
expect(
|
|
35
|
+
colorOf(highlightCode("const x: number = 1;", "typescript"), "const"),
|
|
36
|
+
).toBe(KEYWORD);
|
|
37
|
+
expect(
|
|
38
|
+
colorOf(highlightCode("SELECT id FROM users", "sql"), "SELECT"),
|
|
39
|
+
).toBe(KEYWORD);
|
|
40
|
+
expect(colorOf(highlightCode('<a href="/">x</a>', "html"), "a")).toBe(
|
|
41
|
+
KEYWORD,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("marks a shell command, its flags, strings, variables and comments", () => {
|
|
46
|
+
const code =
|
|
47
|
+
'curl -X POST "$API_URL" \\\n -H "Accept: json" # send\necho $HOME | grep root';
|
|
48
|
+
const tokens = highlightCode(code, "shell");
|
|
49
|
+
expect(joined(tokens)).toBe(code);
|
|
50
|
+
expect(colorOf(tokens, "curl")).toBe(KEYWORD);
|
|
51
|
+
expect(colorOf(tokens, "-X")).toBe(PROPERTY);
|
|
52
|
+
expect(colorOf(tokens, '"$API_URL"')).toBe(STRING);
|
|
53
|
+
expect(colorOf(tokens, "-H")).toBe(PROPERTY);
|
|
54
|
+
expect(colorOf(tokens, "# send")).toBe(COMMENT);
|
|
55
|
+
expect(colorOf(tokens, "echo")).toBe(KEYWORD);
|
|
56
|
+
expect(colorOf(tokens, "$HOME")).toBe(NUMBER);
|
|
57
|
+
expect(colorOf(tokens, "grep")).toBe(KEYWORD);
|
|
58
|
+
expect(colorOf(tokens, "root")).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("shows plain text and unknown languages as written", () => {
|
|
62
|
+
expect(highlightCode("SELECT 1", "text")).toEqual([{ text: "SELECT 1" }]);
|
|
63
|
+
expect(highlightCode("x = 1", "cobol")).toEqual([{ text: "x = 1" }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reads the usual aliases as their language", () => {
|
|
67
|
+
expect(resolveCodeLanguage("bash")).toBe("shell");
|
|
68
|
+
expect(resolveCodeLanguage("TS")).toBe("typescript");
|
|
69
|
+
expect(resolveCodeLanguage("plain")).toBe("text");
|
|
70
|
+
expect(resolveCodeLanguage(undefined)).toBe("text");
|
|
71
|
+
});
|
|
72
|
+
});
|