feat(grid): add pagination shortcut mappings

Closes #4743
This commit is contained in:
t8y2 committed 2026-08-24 20:59:29 +00:00
1 parent cd18b7a3db
commit f621e40655
15 files changed
+457 -2

No files matched your search

+30 -1
View File
@@ -182,7 +182,20 @@ import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/d
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
import { resolveDataGridNewRowCellPlaceholder } from "@/lib/dataGrid/dataGridDefaultPlaceholder";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isGoToColumnShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import {
isCancelSearchShortcut,
isCopyCurrentRowShortcut,
isDeleteCurrentRowShortcut,
isFocusSearchShortcut,
isGoToColumnShortcut,
isGoToFirstPageShortcut,
isGoToLastPageShortcut,
isGoToNextPageShortcut,
isGoToPreviousPageShortcut,
isModRShortcut,
isSaveShortcut,
isToggleTransposeShortcut,
} from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
@@ -3742,6 +3755,21 @@ async function lastPage() {
}
}
function handleGridPaginationShortcut(event: KeyboardEvent): boolean {
if (!props.paginationEnabled || gridPaginationBusy.value || infiniteScrollEnabled.value) return false;
const shortcuts = settingsStore.editorSettings.shortcuts;
let navigate: (() => void) | undefined;
if (currentPage.value > 1 && isGoToFirstPageShortcut(event, shortcuts)) navigate = firstPage;
else if (currentPage.value > 1 && isGoToPreviousPageShortcut(event, shortcuts)) navigate = prevPage;
else if (canGoNextPage.value && isGoToNextPageShortcut(event, shortcuts)) navigate = nextPage;
else if (canJumpLastPage.value && isGoToLastPageShortcut(event, shortcuts)) navigate = lastPage;
if (!navigate) return false;
event.preventDefault();
event.stopPropagation();
void navigate();
return true;
}
async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string } | undefined> {
if (props.countSql) return { sql: props.countSql, schema: props.schema };
if (props.tableMeta) {
@@ -8987,6 +9015,7 @@ async function onGridKeydown(event: KeyboardEvent) {
goToColumnOpen.value = true;
return;
}
if (!targetAllowsNativeClipboard && handleGridPaginationShortcut(event)) return;
if (isFocusSearchShortcut(event)) {
event.preventDefault();
focusSearch();
@@ -0,0 +1,279 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { ShortcutActionId } from "@/lib/editor/shortcutRegistry";
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120]),
totalWidth: ref(120),
columnVars: ref({ "--total-w": "120px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const dataGridSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8");
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
const shortcutCases = [
{ actionId: "goToFirstPage", key: "F1", offset: 0, functionName: "firstPage" },
{ actionId: "goToPreviousPage", key: "F2", offset: 100, functionName: "prevPage" },
{ actionId: "goToNextPage", key: "F3", offset: 300, functionName: "nextPage" },
{ actionId: "goToLastPage", key: "F4", offset: 400, functionName: "lastPage" },
] as const;
const RecycleScroller = defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
attrs,
props.items.map((item) => slots.default?.({ item })),
);
},
});
function mountGrid(
options: {
pageOffset?: number;
totalRowCount?: number;
loading?: boolean;
paginationEnabled?: boolean;
infiniteScroll?: boolean;
empty?: boolean;
configurePaginationShortcuts?: boolean;
} = {},
) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({
dataGridRenderMode: "canvas",
infiniteScroll: options.infiniteScroll ?? false,
shortcuts: {
...settingsStore.editorSettings.shortcuts,
...(options.configurePaginationShortcuts === false
? {}
: {
goToFirstPage: "Alt+F1",
goToPreviousPage: "Alt+F2",
goToNextPage: "Alt+F3",
goToLastPage: "Alt+F4",
}),
},
});
const result = markRaw<QueryResult>({
columns: ["id"],
rows: options.empty ? [] : [[1]],
affected_rows: 0,
execution_time_ms: 0,
});
const paginate = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result,
databaseType: "mysql",
context: "table-data",
pageLimit: 100,
pageOffset: options.pageOffset ?? 200,
totalRowCount: options.totalRowCount ?? 500,
loading: options.loading ?? false,
paginationEnabled: options.paginationEnabled ?? true,
onPaginate: paginate,
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.component("RecycleScroller", RecycleScroller);
app.mount(host);
const mounted = { app, host };
mountedApps.push(mounted);
return { host, paginate };
}
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function gridRoot(host: HTMLElement): HTMLElement {
const root = host.querySelector<HTMLElement>("[data-grid-root]");
if (!root) throw new Error("Data grid root not found");
return root;
}
function dispatchShortcut(target: HTMLElement, key: string): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, altKey: true, key });
target.dispatchEvent(event);
return event;
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGrid pagination shortcuts", () => {
it.each(shortcutCases)("runs $functionName through the configured $actionId shortcut", async ({ key, offset }) => {
const { host, paginate } = mountGrid();
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
root.focus();
const event = dispatchShortcut(root, key);
await settle();
expect(event.defaultPrevented).toBe(true);
expect(bubbled).not.toHaveBeenCalled();
expect(paginate).toHaveBeenCalledOnce();
expect(paginate.mock.calls[0]?.slice(0, 2)).toEqual([offset, 100]);
});
it.each([
["goToFirstPage", "F1"],
["goToPreviousPage", "F2"],
] as const)("does not consume %s on the first page", async (_actionId, key) => {
const { host, paginate } = mountGrid({ pageOffset: 0 });
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const event = dispatchShortcut(root, key);
await settle();
expect(event.defaultPrevented).toBe(false);
expect(bubbled).toHaveBeenCalledOnce();
expect(paginate).not.toHaveBeenCalled();
});
it.each([
["goToNextPage", "F3"],
["goToLastPage", "F4"],
] as const)("does not consume %s on the last page", async (_actionId, key) => {
const { host, paginate } = mountGrid({ pageOffset: 400 });
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const event = dispatchShortcut(root, key);
await settle();
expect(event.defaultPrevented).toBe(false);
expect(bubbled).toHaveBeenCalledOnce();
expect(paginate).not.toHaveBeenCalled();
});
it.each([
["loading", { loading: true }],
["disabled pagination", { paginationEnabled: false }],
["infinite scroll", { infiniteScroll: true }],
["no available pages", { pageOffset: 0, totalRowCount: 0, empty: true }],
] as const)("does not consume pagination shortcuts during %s", async (_label, options) => {
const { host, paginate } = mountGrid(options);
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
for (const { key } of shortcutCases) {
const event = dispatchShortcut(root, key);
expect(event.defaultPrevented).toBe(false);
}
await settle();
expect(bubbled).toHaveBeenCalledTimes(shortcutCases.length);
expect(paginate).not.toHaveBeenCalled();
});
it("does not trigger or consume pagination shortcuts from editable targets", async () => {
const { host, paginate } = mountGrid();
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const targets = [document.createElement("input"), document.createElement("textarea"), document.createElement("div"), document.createElement("div")];
targets[2]!.setAttribute("contenteditable", "true");
targets[3]!.setAttribute("role", "textbox");
for (const target of targets) {
root.append(target);
const event = dispatchShortcut(target, "F3");
expect(event.defaultPrevented).toBe(false);
}
await settle();
expect(bubbled).toHaveBeenCalledTimes(targets.length);
expect(paginate).not.toHaveBeenCalled();
});
it("routes shortcuts to the existing pagination functions and preserves PageUp/PageDown navigation", () => {
expect(dataGridSource).toMatch(/if \(!targetAllowsNativeClipboard && handleGridPaginationShortcut\(event\)\) return;/);
for (const { actionId, functionName } of shortcutCases) {
expect(dataGridSource).toContain(`is${actionId[0]!.toUpperCase()}${actionId.slice(1)}Shortcut(event, shortcuts)`);
expect(dataGridSource).toContain(`navigate = ${functionName}`);
}
expect(dataGridSource).toContain('event.key === "PageUp" && navigateSelectedCell("pageUp", event.shiftKey)');
expect(dataGridSource).toContain('event.key === "PageDown" && navigateSelectedCell("pageDown", event.shiftKey)');
});
it("keeps legacy settings without pagination mappings keyboard-neutral", async () => {
const { host, paginate } = mountGrid({ configurePaginationShortcuts: false });
await settle();
const root = gridRoot(host);
const bubbled = vi.fn();
host.addEventListener("keydown", bubbled);
const event = dispatchShortcut(root, "F3");
await settle();
expect(event.defaultPrevented).toBe(false);
expect(bubbled).toHaveBeenCalledOnce();
expect(paginate).not.toHaveBeenCalled();
const actionIds: ShortcutActionId[] = shortcutCases.map(({ actionId }) => actionId);
expect(actionIds).toHaveLength(4);
});
});
+4
View File
@@ -6519,6 +6519,10 @@ export default {
shortcutCopyCurrentRow: "Copy current data row",
shortcutDeleteCurrentRow: "Delete current data row",
shortcutGoToColumn: "Go to column",
shortcutGoToFirstPage: "First page",
shortcutGoToPreviousPage: "Previous page",
shortcutGoToNextPage: "Next page",
shortcutGoToLastPage: "Last page",
shortcutNewQuery: "New query",
shortcutOpenSettings: "Open settings",
shortcutCloseTab: "Close tab",
+4
View File
@@ -6190,6 +6190,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "Copiar fila de datos actual",
shortcutDeleteCurrentRow: "Eliminar fila de datos actual",
shortcutGoToColumn: "Ir a la columna",
shortcutGoToFirstPage: "Primera página",
shortcutGoToPreviousPage: "Página anterior",
shortcutGoToNextPage: "Página siguiente",
shortcutGoToLastPage: "Última página",
shortcutNewQuery: "Nueva consulta",
shortcutOpenSettings: "Abrir configuración",
shortcutCloseTab: "Cerrar pestaña",
+4
View File
@@ -6190,6 +6190,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "Copia riga dati corrente",
shortcutDeleteCurrentRow: "Elimina riga dati corrente",
shortcutGoToColumn: "Vai alla colonna",
shortcutGoToFirstPage: "Prima pagina",
shortcutGoToPreviousPage: "Pagina precedente",
shortcutGoToNextPage: "Pagina successiva",
shortcutGoToLastPage: "Ultima pagina",
shortcutNewQuery: "Nuova query",
shortcutOpenSettings: "Apri impostazioni",
shortcutCloseTab: "Chiudi scheda",
+4
View File
@@ -6204,6 +6204,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "現在のデータ行をコピー",
shortcutDeleteCurrentRow: "現在のデータ行を削除",
shortcutGoToColumn: "列へ移動",
shortcutGoToFirstPage: "最初のページ",
shortcutGoToPreviousPage: "前のページ",
shortcutGoToNextPage: "次のページ",
shortcutGoToLastPage: "最後のページ",
shortcutNewQuery: "新しいクエリ",
shortcutOpenSettings: "設定を開く",
shortcutCloseTab: "タブを閉じる",
+4
View File
@@ -5901,6 +5901,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "현재 데이터 행 복사",
shortcutDeleteCurrentRow: "현재 데이터 행 삭제",
shortcutGoToColumn: "컬럼으로 이동",
shortcutGoToFirstPage: "첫 페이지",
shortcutGoToPreviousPage: "이전 페이지",
shortcutGoToNextPage: "다음 페이지",
shortcutGoToLastPage: "마지막 페이지",
shortcutNewQuery: "새 쿼리",
shortcutOpenSettings: "설정 열기",
shortcutCloseTab: "탭 닫기",
+4
View File
@@ -6192,6 +6192,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "Copiar linha de dados atual",
shortcutDeleteCurrentRow: "Excluir linha de dados atual",
shortcutGoToColumn: "Ir para a coluna",
shortcutGoToFirstPage: "Primeira página",
shortcutGoToPreviousPage: "Página anterior",
shortcutGoToNextPage: "Próxima página",
shortcutGoToLastPage: "Última página",
shortcutNewQuery: "Nova consulta",
shortcutOpenSettings: "Abrir configurações",
shortcutCloseTab: "Fechar aba",
+4
View File
@@ -6505,6 +6505,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "复制当前数据行",
shortcutDeleteCurrentRow: "删除当前数据行",
shortcutGoToColumn: "跳转列",
shortcutGoToFirstPage: "第一页",
shortcutGoToPreviousPage: "上一页",
shortcutGoToNextPage: "下一页",
shortcutGoToLastPage: "末页",
shortcutNewQuery: "新建查询",
shortcutOpenSettings: "打开设置",
shortcutCloseTab: "关闭标签页",
+4
View File
@@ -5497,6 +5497,10 @@ export default withEnglishFallback({
shortcutCopyCurrentRow: "複製目前資料列",
shortcutDeleteCurrentRow: "刪除目前資料列",
shortcutGoToColumn: "跳轉欄",
shortcutGoToFirstPage: "第一頁",
shortcutGoToPreviousPage: "上一頁",
shortcutGoToNextPage: "下一頁",
shortcutGoToLastPage: "末頁",
shortcutNewQuery: "建立查詢",
shortcutOpenSettings: "開啟設定",
shortcutCloseTab: "關閉分頁",
@@ -1,5 +1,17 @@
import { describe, expect, it } from "vitest";
import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, isGoToColumnShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut } from "@/lib/editor/keyboardShortcuts";
import {
eventToModifierOnlyShortcut,
eventToShortcut,
isExecuteSqlInNewResultTabShortcut,
isGoToColumnShortcut,
isGoToFirstPageShortcut,
isGoToLastPageShortcut,
isGoToNextPageShortcut,
isGoToPreviousPageShortcut,
matchesModifierOnlyShortcut,
matchesShortcut,
tabSwitcherDirectionFromShortcut,
} from "@/lib/editor/keyboardShortcuts";
import { formatShortcutDisplay, isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay";
describe("keyboard shortcut matching", () => {
@@ -94,6 +106,21 @@ describe("keyboard shortcut matching", () => {
expect(isGoToColumnShortcut({ key: "g", ctrlKey: true }, { goToColumn: "" })).toBe(false);
expect(isGoToColumnShortcut({ key: "g", ctrlKey: true, isComposing: true }, { goToColumn: "Mod+G" })).toBe(false);
});
it.each([
["goToFirstPage", isGoToFirstPageShortcut, "F1"],
["goToPreviousPage", isGoToPreviousPageShortcut, "F2"],
["goToNextPage", isGoToNextPageShortcut, "F3"],
["goToLastPage", isGoToLastPageShortcut, "F4"],
] as const)("matches only the configured pagination shortcut for %s", (actionId, matcher, key) => {
const shortcuts = { [actionId]: `Alt+${key}` };
expect(matcher({ key, altKey: true }, shortcuts)).toBe(true);
expect(matcher({ key }, shortcuts)).toBe(false);
expect(matcher({ key: "F8", altKey: true }, shortcuts)).toBe(false);
expect(matcher({ key, altKey: true, isComposing: true }, shortcuts)).toBe(false);
expect(matcher({ key, altKey: true })).toBe(false);
});
});
describe("tabSwitcherDirectionFromShortcut", () => {
@@ -25,6 +25,43 @@ describe("shortcutRegistry editor actions", () => {
];
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection", "viewTableDdl"];
it("registers pagination navigation as unassigned grid shortcuts", () => {
const paginationActions = [
["goToFirstPage", "settings.shortcutGoToFirstPage"],
["goToPreviousPage", "settings.shortcutGoToPreviousPage"],
["goToNextPage", "settings.shortcutGoToNextPage"],
["goToLastPage", "settings.shortcutGoToLastPage"],
] as const;
for (const [id, labelKey] of paginationActions) {
expect(SHORTCUT_DEFINITIONS.find((item) => item.id === id)).toMatchObject({ id, labelKey, scope: "grid", defaultShortcut: "" });
expect(DEFAULT_SHORTCUT_SETTINGS[id]).toBe("");
}
});
it("normalizes missing, legacy, cleared, and configured pagination shortcuts", () => {
const missing = normalizeShortcutSettings();
const legacy = normalizeShortcutSettings({ goToColumn: "Mod+G" });
const configured = normalizeShortcutSettings({ goToFirstPage: "Alt+F1", goToPreviousPage: "Alt+F2", goToNextPage: "Alt+F3", goToLastPage: "Alt+F4" });
for (const actionId of ["goToFirstPage", "goToPreviousPage", "goToNextPage", "goToLastPage"] as const) {
expect(missing[actionId]).toBe("");
expect(legacy[actionId]).toBe("");
}
expect(configured.goToFirstPage).toBe("Alt+F1");
expect(configured.goToPreviousPage).toBe("Alt+F2");
expect(configured.goToNextPage).toBe("Alt+F3");
expect(configured.goToLastPage).toBe("Alt+F4");
expect(configured.goToColumn).toBe("");
});
it("detects pagination shortcut conflicts in the grid scope", () => {
const shortcuts = normalizeShortcutSettings({ goToFirstPage: "Alt+F1", goToPreviousPage: "Alt+F1" });
expect(findShortcutConflict("goToFirstPage", shortcuts.goToFirstPage, shortcuts)).toBe("goToPreviousPage");
expect(findShortcutConflict("goToFirstPage", "Mod+F", shortcuts)).toBeNull();
});
it("registers go to column as an unassigned grid shortcut", () => {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "goToColumn");
@@ -211,6 +211,22 @@ export function isGoToColumnShortcut(event: ShortcutLikeEvent, shortcuts?: Parti
return matchesShortcut(event, actionShortcut("goToColumn", shortcuts));
}
export function isGoToFirstPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("goToFirstPage", shortcuts));
}
export function isGoToPreviousPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("goToPreviousPage", shortcuts));
}
export function isGoToNextPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("goToNextPage", shortcuts));
}
export function isGoToLastPageShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("goToLastPage", shortcuts));
}
export function isCancelSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("cancelSearch", shortcuts));
}
@@ -31,6 +31,10 @@ export type ShortcutActionId =
| "copyCurrentRow"
| "deleteCurrentRow"
| "goToColumn"
| "goToFirstPage"
| "goToPreviousPage"
| "goToNextPage"
| "goToLastPage"
| "newQuery"
| "openSettings"
| "closeTab"
@@ -287,6 +291,30 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "grid",
defaultShortcut: "",
},
{
id: "goToFirstPage",
labelKey: "settings.shortcutGoToFirstPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToPreviousPage",
labelKey: "settings.shortcutGoToPreviousPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToNextPage",
labelKey: "settings.shortcutGoToNextPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "goToLastPage",
labelKey: "settings.shortcutGoToLastPage",
scope: "grid",
defaultShortcut: "",
},
{
id: "newQuery",
labelKey: "settings.shortcutNewQuery",