mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 02:40:11 +00:00
fix(grid): keep row copy actions consistent after refresh
This commit is contained in:
@@ -5534,6 +5534,8 @@ const {
|
||||
copyText,
|
||||
copyCell,
|
||||
copyRow,
|
||||
copyRowCount,
|
||||
canCopyRow,
|
||||
copyAll,
|
||||
copyWithExtractor,
|
||||
previewWithExtractor,
|
||||
@@ -7113,6 +7115,7 @@ watch(
|
||||
}
|
||||
clearCellSelection();
|
||||
clearRowSelection();
|
||||
invalidateSyntheticContextSelection();
|
||||
closeCellDetails();
|
||||
closeDetailDialogs();
|
||||
if (shouldPreserveTranspose) {
|
||||
@@ -8298,7 +8301,11 @@ function copySubmenu(): ContextMenuItem {
|
||||
if (contextColumn.value) {
|
||||
items.push({ label: t("grid.copyCell"), action: copyCell });
|
||||
}
|
||||
items.push({ label: isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow"), action: copyRow });
|
||||
items.push({
|
||||
label: copyRowCount.value > 1 ? t("grid.copyRows", { count: copyRowCount.value }) : t("grid.copyRow"),
|
||||
action: copyRow,
|
||||
disabled: () => !canCopyRow.value,
|
||||
});
|
||||
items.push({ label: "", separator: true });
|
||||
items.push(...buildExtractorContextItems());
|
||||
items.push({ label: "", separator: true });
|
||||
|
||||
@@ -86,7 +86,8 @@ function onScroll(e: Event) {
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
if (["Alt", "Control", "Meta", "Shift"].includes(e.key)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
@@ -97,12 +98,12 @@ watch(show, (val) => {
|
||||
contextMenuRegistration?.setOpen(val);
|
||||
if (val) {
|
||||
document.addEventListener("pointerdown", onPointerDownOutside, true);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
document.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
} else {
|
||||
document.removeEventListener("pointerdown", onPointerDownOutside, true);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
}
|
||||
@@ -263,7 +264,7 @@ onBeforeUnmount(() => {
|
||||
contextMenuRegistration?.dispose();
|
||||
contextMenuRegistration = null;
|
||||
document.removeEventListener("pointerdown", onPointerDownOutside, true);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,38 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CustomContextMenu lifecycle", () => {
|
||||
it("removes the capture keydown listener when unmounted while open", async () => {
|
||||
const documentAdd = vi.spyOn(document, "addEventListener");
|
||||
const documentRemove = vi.spyOn(document, "removeEventListener");
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
CustomContextMenu,
|
||||
{ items: [{ label: "Inspect" }] },
|
||||
{
|
||||
default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu }, "Target"),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
const app = createApp(root);
|
||||
app.mount(container);
|
||||
|
||||
container.querySelector("#context-target")?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
const keydownListener = callsFor(documentAdd, "keydown").at(-1)?.[1];
|
||||
expect(keydownListener).toBeTruthy();
|
||||
|
||||
app.unmount();
|
||||
await nextTick();
|
||||
|
||||
expect(documentRemove.mock.calls).toContainEqual(["keydown", keydownListener, true]);
|
||||
});
|
||||
|
||||
it("uses one shared listener set across repeated bulk mount cycles", async () => {
|
||||
const documentAdd = vi.spyOn(document, "addEventListener");
|
||||
const documentRemove = vi.spyOn(document, "removeEventListener");
|
||||
@@ -97,6 +129,41 @@ describe("CustomContextMenu lifecycle", () => {
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("closes the menu when an application shortcut is pressed", async () => {
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
CustomContextMenu,
|
||||
{ items: [{ label: "Refresh row" }] },
|
||||
{
|
||||
default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu, onKeydown: (event: KeyboardEvent) => event.stopPropagation() }, "Target"),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
const app = createApp(root);
|
||||
app.mount(container);
|
||||
|
||||
const target = container.querySelector("#context-target");
|
||||
target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("Refresh row");
|
||||
|
||||
target?.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true, bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("Refresh row");
|
||||
|
||||
target?.dispatchEvent(new KeyboardEvent("keydown", { key: "r", metaKey: true, bubbles: true }));
|
||||
await nextTick();
|
||||
expect(document.body.textContent).not.toContain("Refresh row");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("renders checked state after the menu label", async () => {
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
|
||||
@@ -111,9 +111,10 @@ function createExportState(
|
||||
hasColumnSelection = false,
|
||||
visibleColumnIndexes?: number[],
|
||||
isSyntheticContext = false,
|
||||
contextRowId?: number | null,
|
||||
) {
|
||||
const rows = (rowDataList ?? [rowData ?? columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))]).map((data, index) => ({ ...row(data), id: index + 1 }));
|
||||
const item = rows[0]!;
|
||||
const resolvedContextRowId = contextRowId === undefined ? (rows[0]?.id ?? null) : contextRowId;
|
||||
const selectedRowIds = ref(new Set(selectedRowIdValues));
|
||||
const options: UseDataGridExportOptions = {
|
||||
columns: computed(() => columns),
|
||||
@@ -136,7 +137,7 @@ function createExportState(
|
||||
selectedCells: computed(() => selectedCellMatrix ?? selectedCellsOverride ?? { columns: [], rows: [] }),
|
||||
selectedCellMatrix: computed(() => selectedCellMatrix ?? null),
|
||||
selectedRange: computed(() => null),
|
||||
contextCell: ref({ rowId: item.id, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }),
|
||||
contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }),
|
||||
contextSelectionIsSynthetic: ref(isSyntheticContext),
|
||||
getRowItem: (rowId) => rows.find((candidate) => candidate.id === rowId),
|
||||
selectedRowIds,
|
||||
@@ -160,6 +161,42 @@ describe("useDataGridExport prepared row statements", () => {
|
||||
clearDataGridClipboardCopy();
|
||||
});
|
||||
|
||||
it("disables row copy when the result has no rows", () => {
|
||||
const state = createExportState(editableTable, ["id", "name"], undefined, undefined, undefined, [], [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, null);
|
||||
|
||||
expect(state.canCopyRow.value).toBe(false);
|
||||
});
|
||||
|
||||
it("disables row copy when rows exist but none is selected or targeted", () => {
|
||||
const state = createExportState(editableTable, ["id", "name"], undefined, [1, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, null);
|
||||
|
||||
expect(state.canCopyRow.value).toBe(false);
|
||||
});
|
||||
|
||||
it("enables row copy for a valid context row without a prior selection", () => {
|
||||
const state = createExportState(editableTable, ["id", "name"], undefined, [1, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1);
|
||||
|
||||
expect(state.canCopyRow.value).toBe(true);
|
||||
});
|
||||
|
||||
it("counts only visible non-draft rows selected for copying", () => {
|
||||
const first = { ...row([1, "Ada"]), sourceIndex: 0 };
|
||||
const draft = { ...row([2, "Draft"]), id: 2, sourceIndex: 1, isDraft: true };
|
||||
const state = createMongoExportState({
|
||||
columns: ["id", "name"],
|
||||
item: first,
|
||||
items: [first, draft],
|
||||
mongoDocuments: [
|
||||
{ id: 1, name: "Ada" },
|
||||
{ id: 2, name: "Draft" },
|
||||
],
|
||||
selectedRowIds: new Set([1, 2, 999]),
|
||||
});
|
||||
|
||||
expect(state.copyRowCount.value).toBe(1);
|
||||
expect(state.canCopyRow.value).toBe(true);
|
||||
});
|
||||
|
||||
it("builds SQL UPDATE from only selected writable columns while retaining a hidden primary key", async () => {
|
||||
const item = row([7, "Ada", true]);
|
||||
const matrix: CellSelectionMatrix = {
|
||||
|
||||
@@ -363,6 +363,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
return item && !item.isDraft ? [item] : [];
|
||||
}
|
||||
|
||||
const copyRowCount = computed(() => targetedRows().length);
|
||||
const canCopyRow = computed(() => copyRowCount.value > 0);
|
||||
|
||||
function selectionInsertData(): CopyInsertData | null {
|
||||
const matrix = selectedCellMatrix.value;
|
||||
if (!matrix) return null;
|
||||
@@ -1308,6 +1311,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
copyText,
|
||||
copyCell,
|
||||
copyRow,
|
||||
copyRowCount,
|
||||
canCopyRow,
|
||||
copyAll,
|
||||
copyWithExtractor,
|
||||
previewWithExtractor,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("DataGrid context menu state", () => {
|
||||
it("clears the context target whenever the result is replaced", () => {
|
||||
expect(dataGridSource).toMatch(/watch\(\s*\(\) => props\.result,[\s\S]*?clearRowSelection\(\);\s*invalidateSyntheticContextSelection\(\);[\s\S]*?exitTransaction\(\);/);
|
||||
});
|
||||
|
||||
it("uses the actual copyable row count in the copy menu", () => {
|
||||
expect(dataGridSource).toContain('label: copyRowCount.value > 1 ? t("grid.copyRows", { count: copyRowCount.value }) : t("grid.copyRow")');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user