diff --git a/packages/vue/src/composables/useOffsetPagination/index.test.ts b/packages/vue/src/composables/useOffsetPagination/index.test.ts new file mode 100644 index 0000000..5c7a948 --- /dev/null +++ b/packages/vue/src/composables/useOffsetPagination/index.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/packages/vue/src/composables/useOffsetPagination/index.ts b/packages/vue/src/composables/useOffsetPagination/index.ts new file mode 100644 index 0000000..93df385 --- /dev/null +++ b/packages/vue/src/composables/useOffsetPagination/index.ts @@ -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; + pageSize?: MaybeRef; + page?: MaybeRef; + onPageChange?: (returnValue: UnwrapNestedRefs) => unknown; + onPageSizeChange?: (returnValue: UnwrapNestedRefs) => unknown; + onTotalPagesChange?: (returnValue: UnwrapNestedRefs) => unknown; +} + +export interface UseOffsetPaginationReturn { + currentPage: WritableComputedRef; + currentPageSize: WritableComputedRef; + totalPages: ComputedRef; + isFirstPage: ComputedRef; + isLastPage: ComputedRef; + next: VoidFunction; + previous: VoidFunction; + select: (page: number) => void; +} + +export type UseOffsetPaginationInfinityReturn = Omit; + +/** + * @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} options.total The total number of items + * @param {MaybeRef} options.pageSize The number of items per page + * @param {MaybeRef} options.page The current page + * @param {(returnValue: UnwrapNestedRefs) => unknown} options.onPageChange A callback that is called when the page changes + * @param {(returnValue: UnwrapNestedRefs) => unknown} options.onPageSizeChange A callback that is called when the page size changes + * @param {(returnValue: UnwrapNestedRefs) => 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): 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; +}