Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/modules/events/components/eventColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,6 +35,7 @@ export function getEventColumns(
{
accessorKey: 'name',
enableHiding: false,
filterFn: eventsTableFilter,
meta: {
class: 'w-[18ch]',
headerClass: 'w-[18ch]',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/modules/fields/components/fieldColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -31,6 +32,7 @@ export function getFieldColumns(
{
accessorKey: 'name',
enableHiding: false,
filterFn: fieldsTableFilter,
meta: {
class: 'w-[18ch]',
headerClass: 'w-[18ch]',
Expand Down
32 changes: 24 additions & 8 deletions frontend/src/modules/tags/pages/TagsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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(
Expand All @@ -21,6 +23,7 @@ const { showUpdated, showDeleted } = useEnhancedToast()

const tags = ref<Tag[]>([])
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
const { run, isLoading } = useAsyncTask()
const { run: runDeleteTask, isLoading: isDeleting } = useAsyncTask()
const { run: runUpdateTask, isLoading: isSaving } = useAsyncTask()
Expand All @@ -31,14 +34,26 @@ const showDeleteModal = ref(false)
const selectedTagId = ref<string | null>(null)
const editedTag = ref<Tag | null>(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 = () => {
Expand Down Expand Up @@ -86,6 +101,7 @@ onMounted(() => {
placeholder="Search tags..."
class="max-w-xs"
:disabled="tags.length === 0"
@keydown="handleSearchKeydown"
>
</Input>
</div>
Expand Down
50 changes: 44 additions & 6 deletions frontend/src/shared/components/data/DataTableInputFilter.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,55 @@
<script setup lang="ts" generic="TData, TValue">
import type { Column } from '@tanstack/vue-table'
import { Input } from '@/shared/ui/input'
import { ref, watch, onMounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'

interface DataTableInputFilterProps {
column: Column<TData, TValue>
placeholder: string
}

defineProps<DataTableInputFilterProps>()
const props = defineProps<DataTableInputFilterProps>()

// Local state for the input value
const inputValue = ref<string>('')

// Debounced function to update the column filter
const debouncedUpdateFilter = useDebounceFn((value: string) => {
props.column.setFilterValue(value || undefined)
}, 300)

// Watch for input changes and apply debounce
watch(inputValue, newValue => {
debouncedUpdateFilter(newValue)
})

// Handle escape key to clear the filter
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
inputValue.value = ''
props.column.setFilterValue(undefined)
}
}

// Initialize with current filter value
onMounted(() => {
const currentValue = props.column.getFilterValue() as string
if (currentValue) {
inputValue.value = currentValue
}
})

// Watch for external filter changes (if filter is cleared programmatically)
watch(
() => props.column.getFilterValue(),
newValue => {
const stringValue = (newValue as string) || ''
if (stringValue !== inputValue.value) {
inputValue.value = stringValue
}
}
)
</script>

<script lang="ts">
Expand All @@ -17,9 +59,5 @@ export default {
</script>

<template>
<Input
:placeholder="placeholder"
:model-value="column.getFilterValue() as string"
@update:model-value="column.setFilterValue($event)"
/>
<Input v-model="inputValue" :placeholder="placeholder" @keydown="handleKeydown" />
</template>
90 changes: 90 additions & 0 deletions frontend/src/shared/utils/tableFilters.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<T>(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<TData>(fields: string[]): FilterFn<TData> {
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<T>(obj: T, path: string): unknown {
return path.split('.').reduce((current: unknown, key: string) => {
return (current as Record<string, unknown>)?.[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<Event>(EVENTS_SEARCH_FIELDS)
export const fieldsTableFilter = createTableFilter<Field>(FIELDS_SEARCH_FIELDS)
export const tagsTableFilter = createTableFilter<Tag>(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)