mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
feat(packages/vue): add useOffsetPagination composable
This commit is contained in:
147
packages/vue/src/composables/useOffsetPagination/index.test.ts
Normal file
147
packages/vue/src/composables/useOffsetPagination/index.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
import { useOffsetPagination } from '.';
|
||||||
|
|
||||||
|
describe('useOffsetPagination', () => {
|
||||||
|
it('initialize with default values without options', () => {
|
||||||
|
const { currentPage, currentPageSize, totalPages, isFirstPage } = useOffsetPagination({});
|
||||||
|
|
||||||
|
expect(currentPage.value).toBe(1);
|
||||||
|
expect(currentPageSize.value).toBe(10);
|
||||||
|
expect(totalPages.value).toBe(Infinity);
|
||||||
|
expect(isFirstPage.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculate total pages correctly', () => {
|
||||||
|
const { totalPages } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||||
|
|
||||||
|
expect(totalPages.value).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update current page correctly', () => {
|
||||||
|
const { currentPage, next, previous, select } = useOffsetPagination({ total: 100, pageSize: 10 });
|
||||||
|
|
||||||
|
next();
|
||||||
|
expect(currentPage.value).toBe(2);
|
||||||
|
|
||||||
|
previous();
|
||||||
|
expect(currentPage.value).toBe(1);
|
||||||
|
|
||||||
|
select(5);
|
||||||
|
expect(currentPage.value).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle out of bounds increments correctly', () => {
|
||||||
|
const { currentPage, next, previous } = useOffsetPagination({ total: 10, pageSize: 5 });
|
||||||
|
|
||||||
|
next();
|
||||||
|
next();
|
||||||
|
next();
|
||||||
|
|
||||||
|
expect(currentPage.value).toBe(2);
|
||||||
|
|
||||||
|
previous();
|
||||||
|
previous();
|
||||||
|
previous();
|
||||||
|
|
||||||
|
expect(currentPage.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle page boundaries correctly', () => {
|
||||||
|
const { currentPage, isFirstPage, isLastPage } = useOffsetPagination({ total: 20, pageSize: 10 });
|
||||||
|
|
||||||
|
expect(currentPage.value).toBe(1);
|
||||||
|
expect(isFirstPage.value).toBe(true);
|
||||||
|
expect(isLastPage.value).toBe(false);
|
||||||
|
|
||||||
|
currentPage.value = 2;
|
||||||
|
|
||||||
|
expect(currentPage.value).toBe(2);
|
||||||
|
expect(isFirstPage.value).toBe(false);
|
||||||
|
expect(isLastPage.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('call onPageChange callback', async () => {
|
||||||
|
const onPageChange = vi.fn();
|
||||||
|
const { currentPage, next } = useOffsetPagination({ total: 100, pageSize: 10, onPageChange });
|
||||||
|
|
||||||
|
next();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPageChange).toHaveBeenCalledWith(expect.objectContaining({ currentPage: currentPage.value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('call onPageSizeChange callback', async () => {
|
||||||
|
const onPageSizeChange = vi.fn();
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const { currentPageSize } = useOffsetPagination({ total: 100, pageSize, onPageSizeChange });
|
||||||
|
|
||||||
|
pageSize.value = 20;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledWith(expect.objectContaining({ currentPageSize: currentPageSize.value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('call onPageCountChange callback', async () => {
|
||||||
|
const onTotalPagesChange = vi.fn();
|
||||||
|
const total = ref(100);
|
||||||
|
const { totalPages } = useOffsetPagination({ total, pageSize: 10, onTotalPagesChange });
|
||||||
|
|
||||||
|
total.value = 200;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTotalPagesChange).toHaveBeenCalledWith(expect.objectContaining({ totalPages: totalPages.value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle complex reactive options', async () => {
|
||||||
|
const total = ref(100);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
const onPageChange = vi.fn();
|
||||||
|
const onPageSizeChange = vi.fn();
|
||||||
|
const onTotalPagesChange = vi.fn();
|
||||||
|
|
||||||
|
const { currentPage, currentPageSize, totalPages } = useOffsetPagination({
|
||||||
|
total,
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onTotalPagesChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial values
|
||||||
|
expect(currentPage.value).toBe(1);
|
||||||
|
expect(currentPageSize.value).toBe(10);
|
||||||
|
expect(totalPages.value).toBe(10);
|
||||||
|
expect(onPageChange).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onTotalPagesChange).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
total.value = 300;
|
||||||
|
pageSize.value = 15;
|
||||||
|
page.value = 2;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Valid values after changes
|
||||||
|
expect(currentPage.value).toBe(2);
|
||||||
|
expect(currentPageSize.value).toBe(15);
|
||||||
|
expect(totalPages.value).toBe(20);
|
||||||
|
expect(onPageChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
page.value = 21;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Invalid values after changes
|
||||||
|
expect(currentPage.value).toBe(20);
|
||||||
|
expect(onPageChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/vue/src/composables/useOffsetPagination/index.ts
Normal file
124
packages/vue/src/composables/useOffsetPagination/index.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
|
||||||
|
import { useClamp } from '../useClamp';
|
||||||
|
|
||||||
|
// TODO: sync returned refs with passed refs
|
||||||
|
|
||||||
|
export interface UseOffsetPaginationOptions {
|
||||||
|
total?: MaybeRefOrGetter<number>;
|
||||||
|
pageSize?: MaybeRef<number>;
|
||||||
|
page?: MaybeRef<number>;
|
||||||
|
onPageChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||||
|
onPageSizeChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||||
|
onTotalPagesChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOffsetPaginationReturn {
|
||||||
|
currentPage: WritableComputedRef<number>;
|
||||||
|
currentPageSize: WritableComputedRef<number>;
|
||||||
|
totalPages: ComputedRef<number>;
|
||||||
|
isFirstPage: ComputedRef<boolean>;
|
||||||
|
isLastPage: ComputedRef<boolean>;
|
||||||
|
next: VoidFunction;
|
||||||
|
previous: VoidFunction;
|
||||||
|
select: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseOffsetPaginationInfinityReturn = Omit<UseOffsetPaginationReturn, 'isLastPage'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useOffsetPagination
|
||||||
|
* @category Utilities
|
||||||
|
* @description A composable function that provides pagination functionality for offset based pagination
|
||||||
|
*
|
||||||
|
* @param {UseOffsetPaginationOptions} options The options for the pagination
|
||||||
|
* @param {MaybeRefOrGetter<number>} options.total The total number of items
|
||||||
|
* @param {MaybeRef<number>} options.pageSize The number of items per page
|
||||||
|
* @param {MaybeRef<number>} options.page The current page
|
||||||
|
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageChange A callback that is called when the page changes
|
||||||
|
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageSizeChange A callback that is called when the page size changes
|
||||||
|
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onTotalPagesChange A callback that is called when the total number of pages changes
|
||||||
|
* @returns {UseOffsetPaginationReturn} The pagination object
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const {
|
||||||
|
* currentPage,
|
||||||
|
* currentPageSize,
|
||||||
|
* totalPages,
|
||||||
|
* isFirstPage,
|
||||||
|
* isLastPage,
|
||||||
|
* next,
|
||||||
|
* previous,
|
||||||
|
* select,
|
||||||
|
* } = useOffsetPagination({ total: 100, pageSize: 10, page: 1 });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const {
|
||||||
|
* currentPage,
|
||||||
|
* } = useOffsetPagination({
|
||||||
|
* total: 100,
|
||||||
|
* pageSize: 10,
|
||||||
|
* page: 1,
|
||||||
|
* onPageChange: ({ currentPage }) => console.log(currentPage),
|
||||||
|
* onPageSizeChange: ({ currentPageSize }) => console.log(currentPageSize),
|
||||||
|
* onTotalPagesChange: ({ totalPages }) => console.log(totalPages),
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useOffsetPagination(options: Omit<UseOffsetPaginationOptions, 'total'>): UseOffsetPaginationInfinityReturn;
|
||||||
|
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn;
|
||||||
|
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn {
|
||||||
|
const {
|
||||||
|
total = Number.POSITIVE_INFINITY,
|
||||||
|
pageSize = 10,
|
||||||
|
page = 1,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const currentPageSize = useClamp(pageSize, 1, Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(toValue(total) / toValue(currentPageSize))
|
||||||
|
));
|
||||||
|
|
||||||
|
const currentPage = useClamp(page, 1, totalPages);
|
||||||
|
|
||||||
|
const isFirstPage = computed(() => currentPage.value === 1);
|
||||||
|
const isLastPage = computed(() => currentPage.value === totalPages.value);
|
||||||
|
|
||||||
|
const next = () => currentPage.value++;
|
||||||
|
const previous = () => currentPage.value--;
|
||||||
|
const select = (page: number) => currentPage.value = page;
|
||||||
|
|
||||||
|
const returnValue = {
|
||||||
|
currentPage,
|
||||||
|
currentPageSize,
|
||||||
|
totalPages,
|
||||||
|
isFirstPage,
|
||||||
|
isLastPage,
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
select,
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: Don't forget to await nextTick() after calling next() or previous() to ensure the callback is called
|
||||||
|
|
||||||
|
if (options.onPageChange) {
|
||||||
|
watch(currentPage, () => {
|
||||||
|
options.onPageChange!(reactive(returnValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onPageSizeChange) {
|
||||||
|
watch(currentPageSize, () => {
|
||||||
|
options.onPageSizeChange!(reactive(returnValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onTotalPagesChange) {
|
||||||
|
watch(totalPages, () => {
|
||||||
|
options.onTotalPagesChange!(reactive(returnValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user