fix(grid): keep row copy actions consistent after refresh

This commit is contained in:
zipg
2026-08-05 01:10:57 +08:00
committed by GitHub
parent 27d72f8776
commit be2590347f
6 changed files with 138 additions and 7 deletions
@@ -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")');
});
});