From a31ee131494f657cb469e8a0295387cc2266b7e2 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 15 May 2026 11:38:32 +0200 Subject: [PATCH 1/7] refactor(frontend): introduce useDataTableUrlSync and enhance useDataTable for controlled state --- .../src/shared/composables/useDataTable.ts | 36 ++++-- .../shared/composables/useDataTableUrlSync.ts | 104 ++++++++++++++++++ 2 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 frontend/src/shared/composables/useDataTableUrlSync.ts diff --git a/frontend/src/shared/composables/useDataTable.ts b/frontend/src/shared/composables/useDataTable.ts index 681ba2d..9bb5107 100644 --- a/frontend/src/shared/composables/useDataTable.ts +++ b/frontend/src/shared/composables/useDataTable.ts @@ -1,10 +1,11 @@ -import { ref } from 'vue' +import { ref, type Ref } from 'vue' import type { ColumnDef, SortingState, ColumnFiltersState, VisibilityState, PaginationState, + Updater, } from '@tanstack/vue-table' import { getCoreRowModel, @@ -17,26 +18,36 @@ import { } from '@tanstack/vue-table' import { valueUpdater } from '@/shared/utils/general' -type SortingOption = { - id: string - desc: boolean -} - type UseDataTableOptions = { data: () => TData[] columns: () => ColumnDef[] - defaultSorting?: SortingOption[] + // Optional controlled state + sorting?: Ref + columnFilters?: Ref + onSortingChange?: (updater: Updater) => void + onColumnFiltersChange?: (updater: Updater) => void + // Default values for internal state + defaultSorting?: SortingState defaultPageSize?: number } export function useDataTable({ data, columns, + sorting: externalSorting, + columnFilters: externalColumnFilters, + onSortingChange: externalOnSortingChange, + onColumnFiltersChange: externalOnColumnFiltersChange, defaultSorting = [], defaultPageSize = 10, }: UseDataTableOptions) { - const sorting = ref(defaultSorting ?? []) - const columnFilters = ref([]) + // Use external state if provided, otherwise create internal state + const internalSorting = ref(defaultSorting) + const internalColumnFilters = ref([]) + + const sorting = externalSorting || internalSorting + const columnFilters = externalColumnFilters || internalColumnFilters + const columnVisibility = ref({}) const rowSelection = ref({}) const pagination = ref({ @@ -57,8 +68,11 @@ export function useDataTable({ getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), - onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting), - onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters), + onSortingChange: + externalOnSortingChange || (updaterOrValue => valueUpdater(updaterOrValue, internalSorting)), + onColumnFiltersChange: + externalOnColumnFiltersChange || + (updaterOrValue => valueUpdater(updaterOrValue, internalColumnFilters)), onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility), onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection), onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination), diff --git a/frontend/src/shared/composables/useDataTableUrlSync.ts b/frontend/src/shared/composables/useDataTableUrlSync.ts new file mode 100644 index 0000000..288ae0c --- /dev/null +++ b/frontend/src/shared/composables/useDataTableUrlSync.ts @@ -0,0 +1,104 @@ +import { computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import type { Updater, SortingState, ColumnFiltersState } from '@tanstack/vue-table' + +export interface SyncConfig { + columnFilters?: Record // Table Column ID -> URL Query Param Config + sorting?: { + sortParam: string + orderParam: string + } +} + +export function useDataTableUrlSync(config: SyncConfig) { + const route = useRoute() + const router = useRouter() + + const sorting = computed(() => { + if (!config.sorting) return [] + const sort = route.query[config.sorting.sortParam] as string + const order = route.query[config.sorting.orderParam] as string + if (sort && order) { + return [{ id: sort, desc: order === 'desc' }] + } + return [] + }) + + const columnFilters = computed(() => { + if (!config.columnFilters) return [] + return Object.entries(config.columnFilters) + .map(([columnId, filterConfig]) => { + const value = route.query[filterConfig.param] + if (value === undefined || value === null || value === '') return null + + let filterValue: unknown = value + if (filterConfig.type === 'array') { + filterValue = String(value).split(',').filter(Boolean) + } + + return { + id: columnId, + value: filterValue, + } + }) + .filter((filter): filter is { id: string; value: unknown } => filter !== null) + }) + + const onSortingChange = (updaterOrValue: Updater) => { + const newSorting = + typeof updaterOrValue === 'function' ? updaterOrValue(sorting.value) : updaterOrValue + + const query = { ...route.query } + if (config.sorting) { + if (newSorting.length > 0) { + query[config.sorting.sortParam] = newSorting[0].id + query[config.sorting.orderParam] = newSorting[0].desc ? 'desc' : 'asc' + } else { + delete query[config.sorting.sortParam] + delete query[config.sorting.orderParam] + } + } + router.replace({ query }) + } + + const onColumnFiltersChange = (updaterOrValue: Updater) => { + const newFilters = + typeof updaterOrValue === 'function' ? updaterOrValue(columnFilters.value) : updaterOrValue + + const query = { ...route.query } + if (config.columnFilters) { + Object.values(config.columnFilters).forEach(filterConfig => { + delete query[filterConfig.param] + }) + newFilters.forEach(filter => { + const filterConfig = config.columnFilters![filter.id] + if ( + filterConfig && + filter.value !== undefined && + filter.value !== null && + filter.value !== '' + ) { + if (Array.isArray(filter.value)) { + if (filter.value.length > 0) { + query[filterConfig.param] = filter.value.join(',') + } + } else { + query[filterConfig.param] = String(filter.value) + } + } + }) + } + router.replace({ query }) + } + + return { + state: { + sorting, + columnFilters, + }, + updaters: { + onSortingChange, + onColumnFiltersChange, + }, + } +} From 3dc94aad9c7c33c210acbbb4dbc732f6ceda0616 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 15 May 2026 11:38:38 +0200 Subject: [PATCH 2/7] refactor(frontend): migrate Tags module to new URL synchronization --- .../modules/tags/components/TagsDataGrid.vue | 33 ++++++++++++++++--- frontend/src/modules/tags/pages/TagsPage.vue | 20 ++++++++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/frontend/src/modules/tags/components/TagsDataGrid.vue b/frontend/src/modules/tags/components/TagsDataGrid.vue index 4ee1705..abd65fd 100644 --- a/frontend/src/modules/tags/components/TagsDataGrid.vue +++ b/frontend/src/modules/tags/components/TagsDataGrid.vue @@ -1,25 +1,49 @@ @@ -30,8 +54,7 @@ const handleSearchKeydown = (event: KeyboardEvent) => {
import('@/shared/components/modals/DeleteModal.vue')) @@ -24,7 +24,18 @@ const editedTag = ref(null) const showEditModal = ref(false) const showDeleteModal = ref(false) -const urlFilters = useUrlFilters(tagsFiltersConfig) +const { state, updaters } = useDataTableUrlSync({ + columnFilters: { search: { param: 'search' } }, +}) + +const searchValue = computed(() => { + const filter = state.columnFilters.value.find(f => f.id === 'search') + return (filter?.value as string) || '' +}) + +const handleSearchUpdate = (value: string) => { + updaters.onColumnFiltersChange([{ id: 'search', value }]) +} const { data: tags, isLoading } = useQuery({ queryKey: ['tags'], @@ -70,7 +81,7 @@ const selectDeleteTag = (tag: Tag) => { } const filteredTags = computed(() => { - return filterTags(tags.value ?? [], urlFilters.filters.search) + return filterTags(tags.value ?? [], searchValue.value) }) @@ -81,7 +92,8 @@ const filteredTags = computed(() => { From 8f720dbe279c191c8a5a9643e9d0c265c289cc2d Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 15 May 2026 11:38:42 +0200 Subject: [PATCH 3/7] refactor(frontend): migrate Events module to new URL synchronization --- .../events/components/EventsDataTable.vue | 23 +++++++++---------- .../src/modules/events/pages/EventsPage.vue | 15 +++++++++--- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/modules/events/components/EventsDataTable.vue b/frontend/src/modules/events/components/EventsDataTable.vue index 60df1f7..2122283 100644 --- a/frontend/src/modules/events/components/EventsDataTable.vue +++ b/frontend/src/modules/events/components/EventsDataTable.vue @@ -6,11 +6,11 @@ import { Button } from '@/shared/ui/button' import DataTablePagination from '@/shared/components/data/DataTablePagination.vue' import DataTable from '@/shared/components/data/DataTable.vue' import DataTableLayout from '@/shared/components/data/DataTableLayout.vue' -import { useDataTableWithUrlQuery } from '@/shared/composables/useDataTableWithUrlQuery' +import { useDataTable } from '@/shared/composables/useDataTable' import DataTableSkeleton from '@/shared/components/skeletons/DataTableSkeleton.vue' import DataTableSingleSelectFilter from '@/shared/components/data/DataTableSingleSelectFilter.vue' import type { Tag } from '@/modules/tags/types' -import type { UrlFiltersReturn, EventsUrlFilters } from '@/shared/types/urlFilters' +import type { useDataTableUrlSync } from '@/shared/composables/useDataTableUrlSync' const props = defineProps<{ columns: ColumnDef[] @@ -18,18 +18,17 @@ const props = defineProps<{ tags: Tag[] isLoading: boolean isLoadingTags: boolean - urlFilters: UrlFiltersReturn + urlSync: ReturnType }>() -const { table } = useDataTableWithUrlQuery( - { - data: () => props.data, - columns: () => props.columns, - defaultSorting: [{ id: 'id', desc: true }], - }, - props.urlFilters, - 'events' -) +const { table } = useDataTable({ + data: () => props.data, + columns: () => props.columns, + sorting: props.urlSync.state.sorting, + columnFilters: props.urlSync.state.columnFilters, + onSortingChange: props.urlSync.updaters.onSortingChange, + onColumnFiltersChange: props.urlSync.updaters.onColumnFiltersChange, +})