diff --git a/frontend/src/modules/events/components/eventColumns.ts b/frontend/src/modules/events/components/eventColumns.ts index 799ffa6..3a10344 100644 --- a/frontend/src/modules/events/components/eventColumns.ts +++ b/frontend/src/modules/events/components/eventColumns.ts @@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router' import DataTableColumnHeader from '@/shared/components/data/DataTableColumnHeader.vue' import { Badge } from '@/shared/ui/badge' import TagScrollArea from '@/modules/tags/components/TagScrollArea.vue' +import { eventsTableFilter } from '@/shared/utils/tableFilters' export function getEventColumns( onEdit: (event: Event) => void, @@ -34,6 +35,7 @@ export function getEventColumns( { accessorKey: 'name', enableHiding: false, + filterFn: eventsTableFilter, meta: { class: 'w-[18ch]', headerClass: 'w-[18ch]', diff --git a/frontend/src/modules/fields/components/fieldColumns.ts b/frontend/src/modules/fields/components/fieldColumns.ts index 892d4f4..05f0fc2 100644 --- a/frontend/src/modules/fields/components/fieldColumns.ts +++ b/frontend/src/modules/fields/components/fieldColumns.ts @@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/vue-table' import FieldsDataTableDropdown from '@/modules/fields/components/FieldsDataTableDropdown.vue' import { RouterLink } from 'vue-router' import DataTableColumnHeader from '@/shared/components/data/DataTableColumnHeader.vue' +import { fieldsTableFilter } from '@/shared/utils/tableFilters' export function getFieldColumns( onEdit: (field: Field) => void, @@ -31,6 +32,7 @@ export function getFieldColumns( { accessorKey: 'name', enableHiding: false, + filterFn: fieldsTableFilter, meta: { class: 'w-[18ch]', headerClass: 'w-[18ch]', diff --git a/frontend/src/modules/tags/pages/TagsPage.vue b/frontend/src/modules/tags/pages/TagsPage.vue index 8526bed..18d267e 100644 --- a/frontend/src/modules/tags/pages/TagsPage.vue +++ b/frontend/src/modules/tags/pages/TagsPage.vue @@ -2,7 +2,7 @@ import TagItem from '../components/TagItem.vue' import { tagApi } from '@/modules/tags/api' import type { Tag } from '@/modules/tags/types' -import { ref, onMounted, computed, defineAsyncComponent } from 'vue' +import { ref, onMounted, computed, defineAsyncComponent, watch } from 'vue' import { useAsyncTask } from '@/shared/composables/useAsyncTask' import type { TagFormValues } from '@/modules/tags/validation/tagSchema' import Header from '@/shared/components/layout/PageHeader.vue' @@ -11,6 +11,8 @@ import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' import { Icon } from '@iconify/vue' import ItemSkeleton from '@/shared/components/skeletons/ItemSkeleton.vue' +import { useDebounceFn } from '@vueuse/core' +import { filterTags } from '@/shared/utils/tableFilters' const DeleteModal = defineAsyncComponent(() => import('@/shared/components/modals/DeleteModal.vue')) const TagEditModal = defineAsyncComponent( @@ -21,6 +23,7 @@ const { showUpdated, showDeleted } = useEnhancedToast() const tags = ref([]) const searchQuery = ref('') +const debouncedSearchQuery = ref('') const { run, isLoading } = useAsyncTask() const { run: runDeleteTask, isLoading: isDeleting } = useAsyncTask() const { run: runUpdateTask, isLoading: isSaving } = useAsyncTask() @@ -31,14 +34,26 @@ const showDeleteModal = ref(false) const selectedTagId = ref(null) const editedTag = ref(null) +// Debounced update of search query +const debouncedUpdateSearch = useDebounceFn((query: string) => { + debouncedSearchQuery.value = query +}, 300) + +// Watch for search query changes and apply debounce +watch(searchQuery, newQuery => { + debouncedUpdateSearch(newQuery) +}) + +// Handle escape key to clear search +const handleSearchKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + searchQuery.value = '' + debouncedSearchQuery.value = '' + } +} + const filteredTags = computed(() => { - if (!searchQuery.value) return tags.value - const query = searchQuery.value.toLowerCase() - return tags.value.filter( - tag => - tag.id.toLowerCase().includes(query) || - (tag.description?.toLowerCase().includes(query) ?? false) - ) + return filterTags(tags.value, debouncedSearchQuery.value) }) const handleDelete = () => { @@ -86,6 +101,7 @@ onMounted(() => { placeholder="Search tags..." class="max-w-xs" :disabled="tags.length === 0" + @keydown="handleSearchKeydown" > diff --git a/frontend/src/shared/components/data/DataTableInputFilter.vue b/frontend/src/shared/components/data/DataTableInputFilter.vue index 3acab68..f384d8e 100644 --- a/frontend/src/shared/components/data/DataTableInputFilter.vue +++ b/frontend/src/shared/components/data/DataTableInputFilter.vue @@ -1,13 +1,55 @@ diff --git a/frontend/src/shared/utils/tableFilters.ts b/frontend/src/shared/utils/tableFilters.ts new file mode 100644 index 0000000..eb28876 --- /dev/null +++ b/frontend/src/shared/utils/tableFilters.ts @@ -0,0 +1,90 @@ +import type { FilterFn } from '@tanstack/vue-table' +import type { Event } from '@/modules/events/types' +import type { Field } from '@/modules/fields/types' +import type { Tag } from '@/modules/tags/types' + +/** + * Universal multi-field search function for any array of objects + * @param item The item to check + * @param fields Array of field names to search in (supports dot notation) + * @param searchQuery The search query string + * @returns true if item matches the search query + */ +export function searchMultiField(item: T, fields: string[], searchQuery: string): boolean { + if (!searchQuery || typeof searchQuery !== 'string') { + return true + } + + const searchText = searchQuery.toLowerCase().trim() + if (!searchText) return true + + return fields.some(field => { + const value = getNestedValue(item, field) + if (value === null || value === undefined) return false + + const stringValue = String(value).toLowerCase() + return stringValue.includes(searchText) + }) +} + +/** + * Filter an array using multi-field search + * @param data Array of items to filter + * @param fields Array of field names to search in + * @param searchQuery The search query string + * @returns Filtered array + */ +export function filterMultiField(data: T[], fields: string[], searchQuery: string): T[] { + if (!searchQuery?.trim()) return data + + return data.filter(item => searchMultiField(item, fields, searchQuery)) +} + +/** + * Creates a TanStack Table filter function from multi-field search + * @param fields Array of field names to search in + * @returns TanStack Table filter function + */ +export function createTableFilter(fields: string[]): FilterFn { + return (row, columnId, filterValue) => { + return searchMultiField(row.original, fields, filterValue as string) + } +} + +/** + * Gets a nested value from an object using dot notation + * @param obj The object to get the value from + * @param path The path to the value (e.g., 'user.name' or 'id') + * @returns The value at the path, or undefined if not found + */ +function getNestedValue(obj: T, path: string): unknown { + return path.split('.').reduce((current: unknown, key: string) => { + return (current as Record)?.[key] + }, obj) +} + +/** + * Pre-configured fields for different entities + */ +export const EVENTS_SEARCH_FIELDS = ['id', 'name', 'description'] +export const FIELDS_SEARCH_FIELDS = ['id', 'name', 'description'] +export const TAGS_SEARCH_FIELDS = ['id', 'description'] + +/** + * Pre-configured TanStack Table filters + */ +export const eventsTableFilter = createTableFilter(EVENTS_SEARCH_FIELDS) +export const fieldsTableFilter = createTableFilter(FIELDS_SEARCH_FIELDS) +export const tagsTableFilter = createTableFilter(TAGS_SEARCH_FIELDS) + +/** + * Pre-configured array filter functions + */ +export const filterEvents = (data: Event[], searchQuery: string): Event[] => + filterMultiField(data, EVENTS_SEARCH_FIELDS, searchQuery) + +export const filterFields = (data: Field[], searchQuery: string): Field[] => + filterMultiField(data, FIELDS_SEARCH_FIELDS, searchQuery) + +export const filterTags = (data: Tag[], searchQuery: string): Tag[] => + filterMultiField(data, TAGS_SEARCH_FIELDS, searchQuery)