@antelopejs/dms 0.7.7 → 0.7.8
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 +23 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +9 -2
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The `sidePanel` action target: a non-modal panel docked on the right that
|
|
4
|
+
* opens a module's component on a row, the DMS drawing its header (title,
|
|
5
|
+
* position among the rows shown, previous / next, J / K, close, Escape), and
|
|
6
|
+
* the table's `rowClick: "single"` mode that opens it on a single click.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
computed,
|
|
10
|
+
createApp,
|
|
11
|
+
defineComponent,
|
|
12
|
+
h,
|
|
13
|
+
nextTick,
|
|
14
|
+
onBeforeUnmount,
|
|
15
|
+
onMounted,
|
|
16
|
+
provide,
|
|
17
|
+
ref,
|
|
18
|
+
toRef,
|
|
19
|
+
watch,
|
|
20
|
+
type App,
|
|
21
|
+
type PropType,
|
|
22
|
+
type Ref,
|
|
23
|
+
} from "vue";
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
25
|
+
import { tv } from "tailwind-variants";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { resolve } from "node:path";
|
|
28
|
+
import SidePanelHost from "../layers/dms-layout/app/build/components/layout/SidePanelHost.vue";
|
|
29
|
+
import RowSidePanel from "../layers/dms-layout/app/build/components/layout/RowSidePanel.vue";
|
|
30
|
+
import {
|
|
31
|
+
readSidePanelPreferences,
|
|
32
|
+
SIDE_PANEL_COOKIE,
|
|
33
|
+
} from "../layers/dms-layout/app/build/composables/layout/sidePanelState";
|
|
34
|
+
import {
|
|
35
|
+
closeRowSidePanel,
|
|
36
|
+
ROW_SIDE_PANEL_ID,
|
|
37
|
+
useRowSidePanelContent,
|
|
38
|
+
} from "../layers/dms-layout/app/build/composables/layout/rowSidePanel";
|
|
39
|
+
import { useAppSidePanels } from "../layers/dms-layout/app/composables/useAppSidePanels";
|
|
40
|
+
import { useSidePanel } from "../layers/dms-layout/app/composables/useSidePanel";
|
|
41
|
+
import { useActionTargets } from "../layers/dms-ui/app/build/composables/actions/useActionTargets";
|
|
42
|
+
import {
|
|
43
|
+
isDeepLinkAction,
|
|
44
|
+
resolveDeepLinkedRow,
|
|
45
|
+
} from "../layers/dms-ui/app/build/composables/table-view/utils/recordLink";
|
|
46
|
+
import { isRowClickTrigger } from "../layers/dms-ui/app/build/utils/rowClickTrigger";
|
|
47
|
+
import { EYEBROW_CLASS } from "../layers/dms-ui/app/build/utils/eyebrow";
|
|
48
|
+
import { REORDER_HANDLE_CLASS } from "../layers/dms-ui/app/build/composables/table-view/useReorderHandles";
|
|
49
|
+
import type { CustomRowAction } from "../layers/dms-ui/app/types/row-action";
|
|
50
|
+
|
|
51
|
+
const PANEL_COMPONENT = "RunPanel";
|
|
52
|
+
const ROW_PANEL_COMPONENT = "DmsRowSidePanel";
|
|
53
|
+
const DESKTOP_QUERY = "(min-width: 1024px)";
|
|
54
|
+
|
|
55
|
+
interface Row {
|
|
56
|
+
_id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let sharedState: Map<string, Ref<unknown>>;
|
|
61
|
+
let cookies: Map<string, Ref<unknown>>;
|
|
62
|
+
let app: App | undefined;
|
|
63
|
+
let panelMounts: number;
|
|
64
|
+
let refreshes: number;
|
|
65
|
+
|
|
66
|
+
function useDmsState<T>(key: string, init?: () => T): Ref<T> {
|
|
67
|
+
if (!sharedState.has(key)) sharedState.set(key, ref(init?.()));
|
|
68
|
+
return sharedState.get(key) as Ref<T>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function useDmsCookie<T>(
|
|
72
|
+
name: string,
|
|
73
|
+
options: { default?: () => unknown } = {},
|
|
74
|
+
): Ref<T> {
|
|
75
|
+
if (!cookies.has(name)) cookies.set(name, ref(options.default?.() ?? null));
|
|
76
|
+
return cookies.get(name) as Ref<T>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const openPanelId = () =>
|
|
80
|
+
readSidePanelPreferences(cookies.get(SIDE_PANEL_COOKIE)?.value).openId;
|
|
81
|
+
|
|
82
|
+
const RunPanel = defineComponent({
|
|
83
|
+
props: {
|
|
84
|
+
rowData: { type: Object as PropType<Row>, default: undefined },
|
|
85
|
+
onSuccessCallback: {
|
|
86
|
+
type: Function as PropType<() => void>,
|
|
87
|
+
default: undefined,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
setup(props) {
|
|
91
|
+
onMounted(() => {
|
|
92
|
+
panelMounts += 1;
|
|
93
|
+
});
|
|
94
|
+
return () =>
|
|
95
|
+
h("p", { "data-run": "" }, [
|
|
96
|
+
props.rowData?.name ?? "none",
|
|
97
|
+
h("button", {
|
|
98
|
+
"data-save": "",
|
|
99
|
+
onClick: () => props.onSuccessCallback?.(),
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const ButtonStub = defineComponent({
|
|
106
|
+
props: { disabled: Boolean, ariaLabel: { type: String, default: undefined } },
|
|
107
|
+
emits: ["click"],
|
|
108
|
+
setup(props, { emit, attrs }) {
|
|
109
|
+
return () =>
|
|
110
|
+
h("button", {
|
|
111
|
+
...attrs,
|
|
112
|
+
disabled: props.disabled,
|
|
113
|
+
"aria-label": props.ariaLabel,
|
|
114
|
+
onClick: () => emit("click"),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function installRuntime(): void {
|
|
120
|
+
const runtime: Record<string, unknown> = {
|
|
121
|
+
computed,
|
|
122
|
+
onBeforeUnmount,
|
|
123
|
+
onMounted,
|
|
124
|
+
provide,
|
|
125
|
+
ref,
|
|
126
|
+
toRef,
|
|
127
|
+
watch,
|
|
128
|
+
markRaw: <T>(value: T) => value,
|
|
129
|
+
useDmsCookie,
|
|
130
|
+
useDmsState,
|
|
131
|
+
Color: { success: "success" },
|
|
132
|
+
HttpMethod: { post: "POST" },
|
|
133
|
+
interpolateUrl: (url: string) => url,
|
|
134
|
+
resolveDmsComponent: (name: string) =>
|
|
135
|
+
name === PANEL_COMPONENT ? RunPanel : undefined,
|
|
136
|
+
useI18n: () => ({
|
|
137
|
+
t: (key: string, params?: Record<string, unknown>) =>
|
|
138
|
+
params ? `${key}(${params.index}/${params.total})` : key,
|
|
139
|
+
}),
|
|
140
|
+
useTranslation: () => ({
|
|
141
|
+
processI18n: (value: string, params?: Record<string, unknown>) =>
|
|
142
|
+
params ? `${value}(${params.index}/${params.total})` : value,
|
|
143
|
+
processApiMessage: (value: string) => value,
|
|
144
|
+
}),
|
|
145
|
+
useUserSession: () => ({ loggedIn: ref(true) }),
|
|
146
|
+
useToast: () => ({ add: vi.fn() }),
|
|
147
|
+
useModal: () => ({ open: vi.fn() }),
|
|
148
|
+
useDrawer: () => ({ open: vi.fn() }),
|
|
149
|
+
useExportJob: () => ({ runJob: vi.fn() }),
|
|
150
|
+
useConfirm: () => ({ confirm: vi.fn(async () => true) }),
|
|
151
|
+
useAuthFetch: () => ({ $authFetch: vi.fn() }),
|
|
152
|
+
};
|
|
153
|
+
Object.entries(runtime).forEach(([name, value]) =>
|
|
154
|
+
vi.stubGlobal(name, value),
|
|
155
|
+
);
|
|
156
|
+
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
157
|
+
matches: query === DESKTOP_QUERY,
|
|
158
|
+
media: query,
|
|
159
|
+
addEventListener: () => {},
|
|
160
|
+
removeEventListener: () => {},
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rows: Row[] = [
|
|
165
|
+
{ _id: "r1", name: "First" },
|
|
166
|
+
{ _id: "r2", name: "Second" },
|
|
167
|
+
{ _id: "r3", name: "Third" },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const panelAction = (
|
|
171
|
+
target: Partial<CustomRowAction["target"]> = {},
|
|
172
|
+
action: Partial<CustomRowAction> = {},
|
|
173
|
+
): CustomRowAction => ({
|
|
174
|
+
label: "Request",
|
|
175
|
+
isDefault: true,
|
|
176
|
+
target: {
|
|
177
|
+
type: "sidePanel",
|
|
178
|
+
component: { componentName: PANEL_COMPONENT },
|
|
179
|
+
title: "Request details",
|
|
180
|
+
...target,
|
|
181
|
+
} as CustomRowAction["target"],
|
|
182
|
+
...action,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
interface Mounted {
|
|
186
|
+
host: HTMLElement;
|
|
187
|
+
targets: ReturnType<typeof useActionTargets>;
|
|
188
|
+
showTable: Ref<boolean>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mountPanel(shownRows: Ref<Row[]> = ref(rows)): Mounted {
|
|
192
|
+
let targets: ReturnType<typeof useActionTargets> | undefined;
|
|
193
|
+
const showTable = ref(true);
|
|
194
|
+
const Table = defineComponent({
|
|
195
|
+
setup() {
|
|
196
|
+
targets = useActionTargets({
|
|
197
|
+
api: vi.fn() as never,
|
|
198
|
+
pageId: "requests-page",
|
|
199
|
+
componentId: "requests",
|
|
200
|
+
handleApiError: vi.fn(),
|
|
201
|
+
refreshCallback: () => {
|
|
202
|
+
refreshes += 1;
|
|
203
|
+
},
|
|
204
|
+
rowNavigation: { rows: () => shownRows.value, rowIdKey: "_id" },
|
|
205
|
+
recordScope: { tableId: "requests", isSoleTableView: true },
|
|
206
|
+
});
|
|
207
|
+
return () => h("table");
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const host = document.createElement("div");
|
|
211
|
+
document.body.append(host);
|
|
212
|
+
app = createApp({
|
|
213
|
+
render: () => [h(SidePanelHost), showTable.value ? h(Table) : null],
|
|
214
|
+
});
|
|
215
|
+
app.component("UButton", ButtonStub);
|
|
216
|
+
app.component(ROW_PANEL_COMPONENT, RowSidePanel);
|
|
217
|
+
app.mount(host);
|
|
218
|
+
return { host, targets: targets!, showTable };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const settle = async () => {
|
|
222
|
+
await nextTick();
|
|
223
|
+
await nextTick();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const query = (host: ParentNode, selector: string) =>
|
|
227
|
+
host.querySelector<HTMLElement>(selector);
|
|
228
|
+
const title = (host: ParentNode) => query(host, "h2")?.textContent?.trim();
|
|
229
|
+
const position = (host: ParentNode) =>
|
|
230
|
+
query(host, "[data-dms-row-side-panel-position]")?.textContent?.trim();
|
|
231
|
+
const shownName = (host: ParentNode) =>
|
|
232
|
+
query(host, "[data-run]")?.textContent?.trim();
|
|
233
|
+
const press = (key: string, target: EventTarget = window) =>
|
|
234
|
+
target.dispatchEvent(
|
|
235
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
sharedState = new Map();
|
|
240
|
+
cookies = new Map();
|
|
241
|
+
panelMounts = 0;
|
|
242
|
+
refreshes = 0;
|
|
243
|
+
window.history.replaceState(null, "", "/requests");
|
|
244
|
+
installRuntime();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
closeRowSidePanel();
|
|
249
|
+
app?.unmount();
|
|
250
|
+
app = undefined;
|
|
251
|
+
document.body.innerHTML = "";
|
|
252
|
+
vi.unstubAllGlobals();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("sidePanel action target", () => {
|
|
256
|
+
it("opens a docked panel on the row, the DMS drawing its header", async () => {
|
|
257
|
+
const { host, targets } = mountPanel();
|
|
258
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
259
|
+
await settle();
|
|
260
|
+
expect(openPanelId()).toBe(ROW_SIDE_PANEL_ID);
|
|
261
|
+
expect(
|
|
262
|
+
query(host, "[data-dms-side-panel]")?.getAttribute("aria-label"),
|
|
263
|
+
).toBe("Request details");
|
|
264
|
+
expect(title(host)).toBe("Request details");
|
|
265
|
+
expect(position(host)).toBe("side_panel.position(1/3)");
|
|
266
|
+
expect(shownName(host)).toBe("First");
|
|
267
|
+
expect(useRowSidePanelContent().value?.rowKey).toBe("r1");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("words the position with the action's own label", async () => {
|
|
271
|
+
const { host, targets } = mountPanel();
|
|
272
|
+
targets.handleCustomRowAction(
|
|
273
|
+
panelAction({ positionLabel: "$api.drawer.position" }),
|
|
274
|
+
rows[1],
|
|
275
|
+
);
|
|
276
|
+
await settle();
|
|
277
|
+
expect(position(host)).toBe("$api.drawer.position(2/3)");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("titles the panel with the action's label when the target sets none", async () => {
|
|
281
|
+
const { host, targets } = mountPanel();
|
|
282
|
+
targets.handleCustomRowAction(panelAction({ title: undefined }), rows[0]);
|
|
283
|
+
await settle();
|
|
284
|
+
expect(title(host)).toBe("Request");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("closes a module's side panel: one panel at a time", async () => {
|
|
288
|
+
const { targets } = mountPanel();
|
|
289
|
+
useSidePanel("assistant").open();
|
|
290
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
291
|
+
await settle();
|
|
292
|
+
expect(openPanelId()).toBe(ROW_SIDE_PANEL_ID);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("row side panel navigation", () => {
|
|
297
|
+
it("steps with the buttons, swapping the component in place", async () => {
|
|
298
|
+
const { host, targets } = mountPanel();
|
|
299
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
300
|
+
await settle();
|
|
301
|
+
const prev = query(host, "[data-dms-row-side-panel-prev]");
|
|
302
|
+
expect(prev?.hasAttribute("disabled")).toBe(true);
|
|
303
|
+
query(host, "[data-dms-row-side-panel-next]")?.click();
|
|
304
|
+
await settle();
|
|
305
|
+
expect(shownName(host)).toBe("Second");
|
|
306
|
+
expect(position(host)).toBe("side_panel.position(2/3)");
|
|
307
|
+
expect(panelMounts).toBe(2);
|
|
308
|
+
expect(useRowSidePanelContent().value?.rowKey).toBe("r2");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("steps with J and K and stops at the bounds", async () => {
|
|
312
|
+
const { host, targets } = mountPanel();
|
|
313
|
+
targets.handleCustomRowAction(panelAction(), rows[1]);
|
|
314
|
+
await settle();
|
|
315
|
+
press("j");
|
|
316
|
+
await settle();
|
|
317
|
+
expect(shownName(host)).toBe("Third");
|
|
318
|
+
expect(
|
|
319
|
+
query(host, "[data-dms-row-side-panel-next]")?.hasAttribute("disabled"),
|
|
320
|
+
).toBe(true);
|
|
321
|
+
press("j");
|
|
322
|
+
await settle();
|
|
323
|
+
expect(shownName(host)).toBe("Third");
|
|
324
|
+
press("k");
|
|
325
|
+
press("k");
|
|
326
|
+
await settle();
|
|
327
|
+
expect(shownName(host)).toBe("First");
|
|
328
|
+
press("k");
|
|
329
|
+
await settle();
|
|
330
|
+
expect(position(host)).toBe("side_panel.position(1/3)");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("ignores J and K typed into a field", async () => {
|
|
334
|
+
const { host, targets } = mountPanel();
|
|
335
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
336
|
+
await settle();
|
|
337
|
+
const input = document.createElement("input");
|
|
338
|
+
document.body.append(input);
|
|
339
|
+
press("j", input);
|
|
340
|
+
await settle();
|
|
341
|
+
expect(shownName(host)).toBe("First");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("follows the rows the table shows", async () => {
|
|
345
|
+
const shown = ref([...rows]);
|
|
346
|
+
const { host, targets } = mountPanel(shown);
|
|
347
|
+
targets.handleCustomRowAction(panelAction(), rows[1]);
|
|
348
|
+
await settle();
|
|
349
|
+
shown.value = [rows[1]!];
|
|
350
|
+
await settle();
|
|
351
|
+
expect(position(host)).toBe("side_panel.position(1/1)");
|
|
352
|
+
shown.value = [rows[0]!];
|
|
353
|
+
await settle();
|
|
354
|
+
expect(position(host)).toBeUndefined();
|
|
355
|
+
expect(query(host, "[data-dms-row-side-panel-next]")).toBeNull();
|
|
356
|
+
expect(shownName(host)).toBe("Second");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("row side panel deep link", () => {
|
|
361
|
+
it("keeps the open row in the URL, following the steps", async () => {
|
|
362
|
+
const { targets } = mountPanel();
|
|
363
|
+
const action = panelAction({ deepLink: true });
|
|
364
|
+
expect(isDeepLinkAction(action)).toBe(true);
|
|
365
|
+
targets.handleCustomRowAction(action, rows[0]);
|
|
366
|
+
await settle();
|
|
367
|
+
expect(window.location.search).toBe("?record=r1");
|
|
368
|
+
press("j");
|
|
369
|
+
await settle();
|
|
370
|
+
expect(window.location.search).toBe("?record=r2");
|
|
371
|
+
closeRowSidePanel();
|
|
372
|
+
expect(window.location.search).toBe("");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("keeps the row in the URL by default, deepLink: false opting out", async () => {
|
|
376
|
+
expect(isDeepLinkAction(panelAction())).toBe(true);
|
|
377
|
+
expect(isDeepLinkAction(panelAction({ deepLink: false }))).toBe(false);
|
|
378
|
+
expect(isDeepLinkAction(panelAction({}, { deepLink: false }))).toBe(false);
|
|
379
|
+
expect(
|
|
380
|
+
isDeepLinkAction(panelAction({ deepLink: false }, { deepLink: true })),
|
|
381
|
+
).toBe(true);
|
|
382
|
+
const drawer = panelAction({
|
|
383
|
+
type: "drawer",
|
|
384
|
+
} as Partial<CustomRowAction["target"]>);
|
|
385
|
+
expect(isDeepLinkAction(drawer)).toBe(false);
|
|
386
|
+
|
|
387
|
+
const { targets } = mountPanel();
|
|
388
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
389
|
+
await settle();
|
|
390
|
+
expect(window.location.search).toBe("?record=r1");
|
|
391
|
+
closeRowSidePanel();
|
|
392
|
+
targets.handleCustomRowAction(panelAction({ deepLink: false }), rows[0]);
|
|
393
|
+
await settle();
|
|
394
|
+
expect(window.location.search).toBe("");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("leaves the next page's ?record= alone when it closes on the way out", async () => {
|
|
398
|
+
const { targets, showTable } = mountPanel();
|
|
399
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
400
|
+
await settle();
|
|
401
|
+
expect(window.location.search).toBe("?record=r1");
|
|
402
|
+
window.history.pushState(null, "", "/runs?record=run-5");
|
|
403
|
+
showTable.value = false;
|
|
404
|
+
await settle();
|
|
405
|
+
expect(useRowSidePanelContent().value).toBeNull();
|
|
406
|
+
expect(window.location.pathname).toBe("/runs");
|
|
407
|
+
expect(window.location.search).toBe("?record=run-5");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("leaves a ?record= another row wrote on the same page alone", async () => {
|
|
411
|
+
const { targets } = mountPanel();
|
|
412
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
413
|
+
await settle();
|
|
414
|
+
window.history.replaceState(null, "", "/requests?record=other");
|
|
415
|
+
closeRowSidePanel();
|
|
416
|
+
expect(window.location.search).toBe("?record=other");
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("deep link to a row the table does not list", () => {
|
|
421
|
+
const older: Row = { _id: "r9", name: "Older" };
|
|
422
|
+
const idOf = (row: Row) => row._id;
|
|
423
|
+
|
|
424
|
+
it("reads a row beyond the first page by id", async () => {
|
|
425
|
+
const getRow = vi.fn(async () => older);
|
|
426
|
+
expect(
|
|
427
|
+
await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow }),
|
|
428
|
+
).toBe(older);
|
|
429
|
+
expect(getRow).toHaveBeenCalledWith("r9");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("takes a listed row as it is, without a request", async () => {
|
|
433
|
+
const getRow = vi.fn(async () => older);
|
|
434
|
+
expect(
|
|
435
|
+
await resolveDeepLinkedRow("r2", { listed: rows, idOf, getRow }),
|
|
436
|
+
).toBe(rows[1]);
|
|
437
|
+
expect(getRow).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("opens nothing for a missing row (404) or without a row route", async () => {
|
|
441
|
+
const missing = vi.fn(async () => {
|
|
442
|
+
throw Object.assign(new Error("Not Found"), { statusCode: 404 });
|
|
443
|
+
});
|
|
444
|
+
expect(
|
|
445
|
+
await resolveDeepLinkedRow("gone", {
|
|
446
|
+
listed: rows,
|
|
447
|
+
idOf,
|
|
448
|
+
getRow: missing,
|
|
449
|
+
}),
|
|
450
|
+
).toBeUndefined();
|
|
451
|
+
const noRoute = async () => undefined;
|
|
452
|
+
expect(
|
|
453
|
+
await resolveDeepLinkedRow("r9", { listed: rows, idOf, getRow: noRoute }),
|
|
454
|
+
).toBeUndefined();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("opens the unlisted row without a position, J and K inert", async () => {
|
|
458
|
+
const { host, targets } = mountPanel();
|
|
459
|
+
targets.handleCustomRowAction(panelAction({ deepLink: true }), older);
|
|
460
|
+
await settle();
|
|
461
|
+
expect(shownName(host)).toBe("Older");
|
|
462
|
+
expect(position(host)).toBeUndefined();
|
|
463
|
+
expect(query(host, "[data-dms-row-side-panel-next]")).toBeNull();
|
|
464
|
+
expect(window.location.search).toBe("?record=r9");
|
|
465
|
+
press("j");
|
|
466
|
+
await settle();
|
|
467
|
+
expect(shownName(host)).toBe("Older");
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("closing the row side panel", () => {
|
|
472
|
+
it("closes from its button, unregistering the panel", async () => {
|
|
473
|
+
const { host, targets } = mountPanel();
|
|
474
|
+
targets.handleCustomRowAction(panelAction({ deepLink: true }), rows[0]);
|
|
475
|
+
await settle();
|
|
476
|
+
query(host, "[data-dms-row-side-panel-close]")?.click();
|
|
477
|
+
await settle();
|
|
478
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
479
|
+
expect(openPanelId()).toBeNull();
|
|
480
|
+
expect(useAppSidePanels().panels.value).toEqual([]);
|
|
481
|
+
expect(useRowSidePanelContent().value).toBeNull();
|
|
482
|
+
expect(window.location.search).toBe("");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("closes on Escape, unless a field or another layer takes the key", async () => {
|
|
486
|
+
const { host, targets } = mountPanel();
|
|
487
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
488
|
+
await settle();
|
|
489
|
+
const input = document.createElement("input");
|
|
490
|
+
document.body.append(input);
|
|
491
|
+
press("Escape", input);
|
|
492
|
+
const handled = new KeyboardEvent("keydown", {
|
|
493
|
+
key: "Escape",
|
|
494
|
+
cancelable: true,
|
|
495
|
+
});
|
|
496
|
+
handled.preventDefault();
|
|
497
|
+
window.dispatchEvent(handled);
|
|
498
|
+
await settle();
|
|
499
|
+
expect(query(host, "[data-dms-side-panel]")).not.toBeNull();
|
|
500
|
+
press("Escape");
|
|
501
|
+
await settle();
|
|
502
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("closes when another side panel opens, and when its table goes", async () => {
|
|
506
|
+
const { host, targets, showTable } = mountPanel();
|
|
507
|
+
targets.handleCustomRowAction(panelAction({ deepLink: true }), rows[0]);
|
|
508
|
+
await settle();
|
|
509
|
+
useSidePanel("assistant").open();
|
|
510
|
+
await settle();
|
|
511
|
+
expect(useRowSidePanelContent().value).toBeNull();
|
|
512
|
+
expect(window.location.search).toBe("");
|
|
513
|
+
expect(openPanelId()).toBe("assistant");
|
|
514
|
+
|
|
515
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
516
|
+
await settle();
|
|
517
|
+
showTable.value = false;
|
|
518
|
+
await settle();
|
|
519
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
520
|
+
expect(openPanelId()).toBeNull();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("closes and refreshes the table once the component saved", async () => {
|
|
524
|
+
const { host, targets } = mountPanel();
|
|
525
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
526
|
+
await settle();
|
|
527
|
+
query(host, "[data-save]")?.click();
|
|
528
|
+
await settle();
|
|
529
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
530
|
+
expect(refreshes).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("shows no empty panel for a stale cookie naming it", async () => {
|
|
534
|
+
useDmsCookie(SIDE_PANEL_COOKIE).value = {
|
|
535
|
+
openId: ROW_SIDE_PANEL_ID,
|
|
536
|
+
widths: {},
|
|
537
|
+
};
|
|
538
|
+
const { host } = mountPanel();
|
|
539
|
+
await settle();
|
|
540
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe("row click mode", () => {
|
|
545
|
+
type Mode = "single" | "double" | undefined;
|
|
546
|
+
|
|
547
|
+
// Whether `type` dispatched on the row's cell (or on `inner`, its content)
|
|
548
|
+
// runs the row action; read while the event is on the row, whose
|
|
549
|
+
// `currentTarget` the guards read.
|
|
550
|
+
const triggers = (
|
|
551
|
+
mode: Mode,
|
|
552
|
+
type: string,
|
|
553
|
+
init: MouseEventInit = {},
|
|
554
|
+
inner?: string,
|
|
555
|
+
): boolean => {
|
|
556
|
+
const row = document.createElement("tr");
|
|
557
|
+
const cell = document.createElement("td");
|
|
558
|
+
row.append(cell);
|
|
559
|
+
if (inner) cell.innerHTML = inner;
|
|
560
|
+
document.body.append(row);
|
|
561
|
+
let result = false;
|
|
562
|
+
row.addEventListener(type, (event) => {
|
|
563
|
+
result = isRowClickTrigger(mode, event as MouseEvent);
|
|
564
|
+
});
|
|
565
|
+
const target = cell.firstElementChild ?? cell;
|
|
566
|
+
target.dispatchEvent(new MouseEvent(type, { bubbles: true, ...init }));
|
|
567
|
+
row.remove();
|
|
568
|
+
return result;
|
|
569
|
+
};
|
|
570
|
+
const expectTrigger = (
|
|
571
|
+
mode: Mode,
|
|
572
|
+
type: string,
|
|
573
|
+
expected: boolean,
|
|
574
|
+
init: MouseEventInit = {},
|
|
575
|
+
inner?: string,
|
|
576
|
+
) => expect(triggers(mode, type, init, inner)).toBe(expected);
|
|
577
|
+
|
|
578
|
+
it("runs the row action on a double-click by default", () => {
|
|
579
|
+
expectTrigger(undefined, "dblclick", true);
|
|
580
|
+
expectTrigger(undefined, "click", false, { detail: 1 });
|
|
581
|
+
expectTrigger("double", "dblclick", true);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("runs it on a single click with rowClick: single", () => {
|
|
585
|
+
expectTrigger("single", "click", true, { detail: 1 });
|
|
586
|
+
expectTrigger("single", "dblclick", false);
|
|
587
|
+
expectTrigger("single", "click", false, { detail: 2 });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("leaves a single click on a control of the row to the control", () => {
|
|
591
|
+
expectTrigger(
|
|
592
|
+
"single",
|
|
593
|
+
"click",
|
|
594
|
+
false,
|
|
595
|
+
{ detail: 1 },
|
|
596
|
+
"<button>Menu</button>",
|
|
597
|
+
);
|
|
598
|
+
expectTrigger(
|
|
599
|
+
"single",
|
|
600
|
+
"click",
|
|
601
|
+
false,
|
|
602
|
+
{ detail: 1 },
|
|
603
|
+
'<a href="#">Link</a>',
|
|
604
|
+
);
|
|
605
|
+
expectTrigger("single", "click", true, { detail: 1 }, "<span>Text</span>");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("leaves a click ending a text selection alone", () => {
|
|
609
|
+
const text = document.createElement("p");
|
|
610
|
+
text.textContent = "GET /api/search";
|
|
611
|
+
document.body.append(text);
|
|
612
|
+
window.getSelection()?.selectAllChildren(text);
|
|
613
|
+
expectTrigger("single", "click", false, { detail: 1 });
|
|
614
|
+
window.getSelection()?.removeAllRanges();
|
|
615
|
+
text.remove();
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe("open row highlight", () => {
|
|
620
|
+
const tableSource = readFileSync(
|
|
621
|
+
resolve(__dirname, "../layers/dms-ui/app/build/components/table/Table.vue"),
|
|
622
|
+
"utf8",
|
|
623
|
+
);
|
|
624
|
+
const theme = new Function(
|
|
625
|
+
"tv",
|
|
626
|
+
"EYEBROW_CLASS",
|
|
627
|
+
"REORDER_HANDLE_CLASS",
|
|
628
|
+
`${tableSource.slice(
|
|
629
|
+
tableSource.indexOf("const PANEL_MATCH_BG"),
|
|
630
|
+
tableSource.indexOf("// Grid-like default"),
|
|
631
|
+
)}; return theme;`,
|
|
632
|
+
)(tv, EYEBROW_CLASS, REORDER_HANDLE_CLASS);
|
|
633
|
+
|
|
634
|
+
it("tints the open row and its pinned cells", () => {
|
|
635
|
+
const ui = theme();
|
|
636
|
+
expect(ui.row({})).toContain("data-[open=true]:bg-elevated");
|
|
637
|
+
expect(ui.rowCell({ pinned: "left" })).toContain(
|
|
638
|
+
"group-data-[open=true]:bg-elevated",
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("marks the open row and wires both pointer events to the click mode", () => {
|
|
643
|
+
expect(tableSource).toContain(
|
|
644
|
+
':data-open="isOpenRow(row.original) || undefined"',
|
|
645
|
+
);
|
|
646
|
+
expect(tableSource).toContain(
|
|
647
|
+
'@click="handleRowPointer($event, row.original)"',
|
|
648
|
+
);
|
|
649
|
+
expect(tableSource).toContain(
|
|
650
|
+
'@dblclick="handleRowPointer($event, row.original)"',
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
});
|