mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 17:43:36 +00:00
feat(grid): support custom page navigation
This commit is contained in:
@@ -2543,6 +2543,11 @@ const canGoNextPage = computed(() => {
|
||||
allRowsLoaded: allRowsLoaded.value,
|
||||
});
|
||||
});
|
||||
const maximumPage = computed(() => {
|
||||
const total = hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : allRowsLoaded.value ? props.result.rows.length : undefined;
|
||||
if (typeof total === "number" && Number.isFinite(total) && total >= 0) return Math.max(1, Math.ceil(total / pageSize.value));
|
||||
return canGoNextPage.value ? undefined : currentPage.value;
|
||||
});
|
||||
const canFetchNextInfiniteScrollSegment = computed(() =>
|
||||
canFetchNextDataGridSegment({
|
||||
hasMore: props.result.has_more,
|
||||
@@ -2713,6 +2718,21 @@ function nextPage() {
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
function jumpPage(page: number) {
|
||||
if (gridSurfaceBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) return;
|
||||
const targetPage = Math.min(page, maximumPage.value ?? page);
|
||||
if (targetPage === currentPage.value) return;
|
||||
if (allRowsLoaded.value) {
|
||||
currentPage.value = targetPage;
|
||||
resetGridVerticalScroll(true);
|
||||
return;
|
||||
}
|
||||
const offset = (targetPage - 1) * pageSize.value;
|
||||
if (!Number.isSafeInteger(offset)) return;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", offset, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
function infiniteScrollNextPage() {
|
||||
if (infiniteScrollLoading.value || props.loading) return;
|
||||
if (!canFetchNextInfiniteScrollSegment.value) {
|
||||
@@ -10014,6 +10034,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
:page-size-menu-items="pageSizeMenuItems"
|
||||
:export-menu-items="exportMenuItems"
|
||||
:current-page="currentPage"
|
||||
:max-page="maximumPage"
|
||||
:can-go-next-page="canGoNextPage"
|
||||
:can-jump-last-page="canJumpLastPage"
|
||||
@select-page-size="selectPageSizeMenuItem"
|
||||
@@ -10021,6 +10042,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@first-page="firstPage"
|
||||
@previous-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
@jump-page="jumpPage"
|
||||
@last-page="lastPage"
|
||||
@select-export="selectExportMenuItem"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -10,7 +11,7 @@ import DataGridExportMenu from "@/components/grid/DataGridExportMenu.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
paginationEnabled?: boolean;
|
||||
selectionSummary: { cellCount: number; rowCount: number } | null;
|
||||
@@ -22,6 +23,7 @@ withDefaults(
|
||||
pageSizeMenuItems: LightDropdownItem[];
|
||||
exportMenuItems: LightDropdownItem[];
|
||||
currentPage: number;
|
||||
maxPage?: number;
|
||||
canGoNextPage: boolean;
|
||||
canJumpLastPage: boolean;
|
||||
}>(),
|
||||
@@ -29,6 +31,13 @@ withDefaults(
|
||||
);
|
||||
|
||||
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
|
||||
const pageInput = ref(String(props.currentPage));
|
||||
const maximumInputPage = computed(() => {
|
||||
const pageSize = Math.max(1, Math.trunc(props.pageSize));
|
||||
const maximumSafeOffsetPage = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1);
|
||||
if (typeof props.maxPage !== "number" || !Number.isFinite(props.maxPage) || props.maxPage < 1) return maximumSafeOffsetPage;
|
||||
return Math.min(maximumSafeOffsetPage, Math.floor(props.maxPage));
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectPageSize: [value: string];
|
||||
@@ -36,9 +45,41 @@ const emit = defineEmits<{
|
||||
firstPage: [];
|
||||
previousPage: [];
|
||||
nextPage: [];
|
||||
jumpPage: [page: number];
|
||||
lastPage: [];
|
||||
selectExport: [value: string];
|
||||
}>();
|
||||
|
||||
watch(
|
||||
() => props.currentPage,
|
||||
(page) => {
|
||||
pageInput.value = String(page);
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading, wasLoading) => {
|
||||
if (wasLoading && !loading) pageInput.value = String(props.currentPage);
|
||||
},
|
||||
);
|
||||
|
||||
function applyPageInput() {
|
||||
const page = Number(pageInput.value);
|
||||
if (!Number.isSafeInteger(page) || page < 1) {
|
||||
pageInput.value = String(props.currentPage);
|
||||
return;
|
||||
}
|
||||
const targetPage = Math.min(page, maximumInputPage.value);
|
||||
pageInput.value = String(targetPage);
|
||||
if (targetPage !== props.currentPage) emit("jumpPage", targetPage);
|
||||
}
|
||||
|
||||
function handlePageInputKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation();
|
||||
if (event.key !== "Enter" || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
applyPageInput();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +129,17 @@ const emit = defineEmits<{
|
||||
</LightDropdown>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
|
||||
<Input
|
||||
v-model="pageInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="1"
|
||||
:max="maximumInputPage"
|
||||
:disabled="loading"
|
||||
:aria-label="t('grid.jumpToPage')"
|
||||
class="h-5 w-14 shrink-0 px-1 text-center text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@keydown="handlePageInputKeydown"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||
</template>
|
||||
|
||||
@@ -222,6 +222,49 @@ describe("DataGridPagination", () => {
|
||||
await mounted.setProps({ loading: true });
|
||||
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
|
||||
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
|
||||
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("jumps to an entered page and enforces page input boundaries", async () => {
|
||||
const jumpPage = vi.fn();
|
||||
const mounted = mountComponent(DataGridPagination, {
|
||||
selectionSummary: null,
|
||||
selectionSummarySumText: "",
|
||||
loading: false,
|
||||
infiniteScrollEnabled: false,
|
||||
infiniteScrollAllLoaded: false,
|
||||
pageSize: 100,
|
||||
customPageSizeInput: "",
|
||||
pageSizeMenuItems: [],
|
||||
exportMenuItems: [],
|
||||
currentPage: 3,
|
||||
maxPage: 12,
|
||||
canGoNextPage: true,
|
||||
canJumpLastPage: true,
|
||||
onJumpPage: jumpPage,
|
||||
});
|
||||
const pageInput = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage");
|
||||
|
||||
expect(pageInput.props.modelValue).toBe("3");
|
||||
pageInput.props["onUpdate:modelValue"]("8");
|
||||
await nextTick();
|
||||
const enter = dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
expect(enter.propagationStopped).toBe(true);
|
||||
expect(jumpPage).toHaveBeenLastCalledWith(8);
|
||||
|
||||
pageInput.props["onUpdate:modelValue"]("99");
|
||||
await nextTick();
|
||||
dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
expect(jumpPage).toHaveBeenLastCalledWith(12);
|
||||
|
||||
pageInput.props["onUpdate:modelValue"]("0");
|
||||
await nextTick();
|
||||
dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
await nextTick();
|
||||
const resetPageInput = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage");
|
||||
expect(jumpPage).toHaveBeenCalledTimes(2);
|
||||
expect(resetPageInput.props.modelValue).toBe("3");
|
||||
});
|
||||
|
||||
it("hides pagination controls when the data source does not support paging", () => {
|
||||
|
||||
@@ -1377,6 +1377,7 @@ export default {
|
||||
filterAllRows: "All rows",
|
||||
filterChangedRows: "Changed",
|
||||
page: "Page {page}",
|
||||
jumpToPage: "Jump to page",
|
||||
rowsPerPage: "Rows per page",
|
||||
customRowsPerPage: "Custom rows",
|
||||
applyPageSize: "Apply",
|
||||
|
||||
@@ -1515,6 +1515,7 @@ export default withEnglishFallback({
|
||||
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
|
||||
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
|
||||
xlsxHeaderComment: "Encabezado usando comentarios",
|
||||
jumpToPage: "Ir a la página",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
|
||||
|
||||
@@ -1513,6 +1513,7 @@ export default withEnglishFallback({
|
||||
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
|
||||
xlsxHeaderOriginal: "Intestazione con nome campo",
|
||||
xlsxHeaderComment: "Intestazione con commento",
|
||||
jumpToPage: "Vai alla pagina",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
|
||||
|
||||
@@ -1540,6 +1540,7 @@ export default withEnglishFallback({
|
||||
searchModeFilter: "フィルター",
|
||||
searchModeHighlight: "ハイライト",
|
||||
emptyStringValue: "空文字",
|
||||
jumpToPage: "ページ番号に移動",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||
|
||||
@@ -1282,6 +1282,7 @@ export default withEnglishFallback({
|
||||
filterAllRows: "전체 행",
|
||||
filterChangedRows: "변경됨",
|
||||
page: "{page}페이지",
|
||||
jumpToPage: "{page}페이지로 이동",
|
||||
rowsPerPage: "페이지당 행",
|
||||
customRowsPerPage: "사용자 지정 행",
|
||||
applyPageSize: "적용",
|
||||
|
||||
@@ -1515,6 +1515,7 @@ export default withEnglishFallback({
|
||||
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
|
||||
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
|
||||
xlsxHeaderComment: "Cabeçalho usa comentário",
|
||||
jumpToPage: "Ir para a página",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
|
||||
|
||||
@@ -1378,6 +1378,7 @@ export default withEnglishFallback({
|
||||
filterAllRows: "全部行",
|
||||
filterChangedRows: "变更项",
|
||||
page: "第 {page} 页",
|
||||
jumpToPage: "跳转到页码",
|
||||
rowsPerPage: "每页行数",
|
||||
customRowsPerPage: "自定义行数",
|
||||
applyPageSize: "应用",
|
||||
|
||||
@@ -1257,6 +1257,7 @@ export default withEnglishFallback({
|
||||
filterAllRows: "全部列",
|
||||
filterChangedRows: "變更列",
|
||||
page: "第 {page} 頁",
|
||||
jumpToPage: "跳至頁碼",
|
||||
rowsPerPage: "每頁筆數",
|
||||
customRowsPerPage: "自訂筆數",
|
||||
applyPageSize: "套用",
|
||||
|
||||
Reference in New Issue
Block a user