import { reactive, watch, type Ref, onMounted } from 'vue' import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router' export type TableFilterValue = string | number | boolean | null export interface TableFilters { [key: string]: TableFilterValue } export interface TablePagination { page: number perPage: number } export interface TableSort { column: string | null order: 'asc' | 'desc' | null } export interface TableUrlStateOptions { filters?: TFilters pagination?: TablePagination sort?: TableSort searchQuery?: Ref dateRange?: Ref<[string, string] | null> preserveOtherParams?: boolean onRestored?: () => void } /** * Composable for syncing table state (filters, pagination, sorting, search) with URL query parameters * This ensures table state is preserved when navigating between pages */ export function useTableUrlState( options: TableUrlStateOptions = {}, ) { const route = useRoute() const router = useRouter() const filters = (options.filters ?? reactive({})) as TFilters const pagination = options.pagination ?? reactive({ page: 1, perPage: 10 }) const sort = options.sort ?? reactive({ column: null, order: null }) const searchQuery = options.searchQuery const dateRange = options.dateRange const preserveOtherParams = options.preserveOtherParams ?? true const onRestored = options.onRestored // Store default values const defaultPagination = { page: pagination.page, perPage: pagination.perPage } const defaultSort = { column: sort.column, order: sort.order } const defaultFilters = { ...filters } const mutableFilters = filters as TableFilters let isInitializing = true let skipNextUpdate = false let isRestoringFromUrl = false let isUpdatingUrl = false let suppressUpdates = false const replaceQuery = (query: LocationQueryRaw) => { isUpdatingUrl = true router.replace({ query }).finally(() => { isUpdatingUrl = false }) } /** * Temporarily suppress URL updates for a block of code. * @param fn - The function to execute with updates suppressed */ const withSuppressedUpdates = async (fn: () => Promise | void) => { suppressUpdates = true try { await fn() } finally { suppressUpdates = false } } /** * Parse URL query parameters and restore table state */ const restoreFromUrl = () => { isRestoringFromUrl = true const query = route.query // Restore pagination if (query.page) { const page = parseInt(query.page as string, 10) if (!isNaN(page) && page > 0) { pagination.page = page } } else { // Use default if not in URL pagination.page = defaultPagination.page } if (query.perPage) { const perPage = parseInt(query.perPage as string, 10) if (!isNaN(perPage) && perPage > 0) { pagination.perPage = perPage } } else { // Use default if not in URL pagination.perPage = defaultPagination.perPage } // Restore sorting if (query.sortColumn) { sort.column = query.sortColumn as string } else { // Use default if not in URL sort.column = defaultSort.column } if (query.sortOrder && (query.sortOrder === 'asc' || query.sortOrder === 'desc')) { sort.order = query.sortOrder as 'asc' | 'desc' } else { // Use default if not in URL sort.order = defaultSort.order } // Restore search query if (searchQuery) { if (query.search) { searchQuery.value = query.search as string } else { searchQuery.value = '' } } // Restore date range if (dateRange) { if (query.dateFrom && query.dateTo) { dateRange.value = [query.dateFrom as string, query.dateTo as string] } else { dateRange.value = null } } // Restore filters Object.keys(filters).forEach((key) => { const queryValue = query[key] if (queryValue !== undefined) { if (queryValue === 'true') { mutableFilters[key] = true } else if (queryValue === 'false') { mutableFilters[key] = false } else if (queryValue === 'null') { mutableFilters[key] = null } else { mutableFilters[key] = queryValue as TableFilterValue } } else { // Use default if not in URL mutableFilters[key] = defaultFilters[key] ?? null } }) isRestoringFromUrl = false if (!isInitializing && onRestored) { onRestored() } } /** * Update URL with current table state */ const updateUrl = () => { if (skipNextUpdate || isRestoringFromUrl || suppressUpdates) { skipNextUpdate = false return } const query: LocationQueryRaw = {} // Preserve other query params if needed if (preserveOtherParams) { Object.keys(route.query).forEach((key) => { // Don't preserve our managed params if ( !['page', 'perPage', 'sortColumn', 'sortOrder', 'search', 'dateFrom', 'dateTo'].includes( key, ) && !Object.keys(filters).includes(key) ) { query[key] = route.query[key] } }) } // Add pagination if (pagination.page !== defaultPagination.page) { query.page = pagination.page.toString() } if (pagination.perPage !== defaultPagination.perPage) { query.perPage = pagination.perPage.toString() } // Add sorting if (sort.column && sort.column !== defaultSort.column) { query.sortColumn = sort.column } if (sort.order && sort.order !== defaultSort.order) { query.sortOrder = sort.order } // Add search query if (searchQuery?.value) { query.search = searchQuery.value } // Add date range if (dateRange?.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) { query.dateFrom = dateRange.value[0] query.dateTo = dateRange.value[1] } // Add filters Object.keys(filters).forEach((key) => { const value = filters[key] if (value !== null && value !== undefined && value !== defaultFilters[key]) { query[key] = String(value) } }) // Update URL without triggering navigation replaceQuery(query) } /** * Reset all table state and URL parameters */ const resetState = () => { skipNextUpdate = true // Reset pagination pagination.page = 1 pagination.perPage = 10 // Reset sorting sort.column = null sort.order = null // Reset search if (searchQuery) { searchQuery.value = '' } // Reset date range if (dateRange) { dateRange.value = null } // Reset filters Object.keys(filters).forEach((key) => { mutableFilters[key] = null }) // Clear URL params (preserve other params if needed) const query: LocationQueryRaw = {} if (preserveOtherParams) { Object.keys(route.query).forEach((key) => { if ( !['page', 'perPage', 'sortColumn', 'sortOrder', 'search', 'dateFrom', 'dateTo'].includes( key, ) && !Object.keys(filters).includes(key) ) { query[key] = route.query[key] } }) } replaceQuery(query) } // Watch for route query changes (browser back/forward, direct navigation) watch( () => route.query, () => { if (isUpdatingUrl) { return } if (!isRestoringFromUrl && !isInitializing) { restoreFromUrl() } }, { deep: true }, ) // Watch for changes and update URL (after router is ready) onMounted(() => { router.isReady().then(() => { restoreFromUrl() isInitializing = false if (onRestored) { onRestored() } // Watch pagination watch( () => [pagination.page, pagination.perPage], () => { if (!isInitializing && !isRestoringFromUrl) { updateUrl() } }, { deep: true }, ) // Watch sorting watch( () => [sort.column, sort.order], () => { if (!isInitializing && !isRestoringFromUrl) { updateUrl() } }, { deep: true }, ) // Watch search query if (searchQuery) { watch(searchQuery, () => { if (!isInitializing && !isRestoringFromUrl) { updateUrl() } }) } // Watch date range if (dateRange) { watch(dateRange, () => { if (!isInitializing && !isRestoringFromUrl) { updateUrl() } }) } // Watch filters watch( filters, () => { if (!isInitializing && !isRestoringFromUrl) { updateUrl() } }, { deep: true }, ) }) }) return { filters, pagination, sort, resetState, updateUrl, restoreFromUrl, withSuppressedUpdates, } }