Skip to content

Commit 066d5ee

Browse files
authored
feat: implemented frontend full-text search for entities (#30)
1 parent b3ead25 commit 066d5ee

5 files changed

Lines changed: 162 additions & 14 deletions

File tree

frontend/src/modules/events/components/eventColumns.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'
77
import DataTableColumnHeader from '@/shared/components/data/DataTableColumnHeader.vue'
88
import { Badge } from '@/shared/ui/badge'
99
import TagScrollArea from '@/modules/tags/components/TagScrollArea.vue'
10+
import { eventsTableFilter } from '@/shared/utils/tableFilters'
1011

1112
export function getEventColumns(
1213
onEdit: (event: Event) => void,
@@ -34,6 +35,7 @@ export function getEventColumns(
3435
{
3536
accessorKey: 'name',
3637
enableHiding: false,
38+
filterFn: eventsTableFilter,
3739
meta: {
3840
class: 'w-[18ch]',
3941
headerClass: 'w-[18ch]',

frontend/src/modules/fields/components/fieldColumns.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/vue-table'
44
import FieldsDataTableDropdown from '@/modules/fields/components/FieldsDataTableDropdown.vue'
55
import { RouterLink } from 'vue-router'
66
import DataTableColumnHeader from '@/shared/components/data/DataTableColumnHeader.vue'
7+
import { fieldsTableFilter } from '@/shared/utils/tableFilters'
78

89
export function getFieldColumns(
910
onEdit: (field: Field) => void,
@@ -31,6 +32,7 @@ export function getFieldColumns(
3132
{
3233
accessorKey: 'name',
3334
enableHiding: false,
35+
filterFn: fieldsTableFilter,
3436
meta: {
3537
class: 'w-[18ch]',
3638
headerClass: 'w-[18ch]',

frontend/src/modules/tags/pages/TagsPage.vue

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import TagItem from '../components/TagItem.vue'
33
import { tagApi } from '@/modules/tags/api'
44
import type { Tag } from '@/modules/tags/types'
5-
import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
5+
import { ref, onMounted, computed, defineAsyncComponent, watch } from 'vue'
66
import { useAsyncTask } from '@/shared/composables/useAsyncTask'
77
import type { TagFormValues } from '@/modules/tags/validation/tagSchema'
88
import Header from '@/shared/components/layout/PageHeader.vue'
@@ -11,6 +11,8 @@ import { Input } from '@/shared/ui/input'
1111
import { Button } from '@/shared/ui/button'
1212
import { Icon } from '@iconify/vue'
1313
import ItemSkeleton from '@/shared/components/skeletons/ItemSkeleton.vue'
14+
import { useDebounceFn } from '@vueuse/core'
15+
import { filterTags } from '@/shared/utils/tableFilters'
1416
1517
const DeleteModal = defineAsyncComponent(() => import('@/shared/components/modals/DeleteModal.vue'))
1618
const TagEditModal = defineAsyncComponent(
@@ -21,6 +23,7 @@ const { showUpdated, showDeleted } = useEnhancedToast()
2123
2224
const tags = ref<Tag[]>([])
2325
const searchQuery = ref('')
26+
const debouncedSearchQuery = ref('')
2427
const { run, isLoading } = useAsyncTask()
2528
const { run: runDeleteTask, isLoading: isDeleting } = useAsyncTask()
2629
const { run: runUpdateTask, isLoading: isSaving } = useAsyncTask()
@@ -31,14 +34,26 @@ const showDeleteModal = ref(false)
3134
const selectedTagId = ref<string | null>(null)
3235
const editedTag = ref<Tag | null>(null)
3336
37+
// Debounced update of search query
38+
const debouncedUpdateSearch = useDebounceFn((query: string) => {
39+
debouncedSearchQuery.value = query
40+
}, 300)
41+
42+
// Watch for search query changes and apply debounce
43+
watch(searchQuery, newQuery => {
44+
debouncedUpdateSearch(newQuery)
45+
})
46+
47+
// Handle escape key to clear search
48+
const handleSearchKeydown = (event: KeyboardEvent) => {
49+
if (event.key === 'Escape') {
50+
searchQuery.value = ''
51+
debouncedSearchQuery.value = ''
52+
}
53+
}
54+
3455
const filteredTags = computed(() => {
35-
if (!searchQuery.value) return tags.value
36-
const query = searchQuery.value.toLowerCase()
37-
return tags.value.filter(
38-
tag =>
39-
tag.id.toLowerCase().includes(query) ||
40-
(tag.description?.toLowerCase().includes(query) ?? false)
41-
)
56+
return filterTags(tags.value, debouncedSearchQuery.value)
4257
})
4358
4459
const handleDelete = () => {
@@ -86,6 +101,7 @@ onMounted(() => {
86101
placeholder="Search tags..."
87102
class="max-w-xs"
88103
:disabled="tags.length === 0"
104+
@keydown="handleSearchKeydown"
89105
>
90106
</Input>
91107
</div>
Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,55 @@
11
<script setup lang="ts" generic="TData, TValue">
22
import type { Column } from '@tanstack/vue-table'
33
import { Input } from '@/shared/ui/input'
4+
import { ref, watch, onMounted } from 'vue'
5+
import { useDebounceFn } from '@vueuse/core'
46
57
interface DataTableInputFilterProps {
68
column: Column<TData, TValue>
79
placeholder: string
810
}
911
10-
defineProps<DataTableInputFilterProps>()
12+
const props = defineProps<DataTableInputFilterProps>()
13+
14+
// Local state for the input value
15+
const inputValue = ref<string>('')
16+
17+
// Debounced function to update the column filter
18+
const debouncedUpdateFilter = useDebounceFn((value: string) => {
19+
props.column.setFilterValue(value || undefined)
20+
}, 300)
21+
22+
// Watch for input changes and apply debounce
23+
watch(inputValue, newValue => {
24+
debouncedUpdateFilter(newValue)
25+
})
26+
27+
// Handle escape key to clear the filter
28+
const handleKeydown = (event: KeyboardEvent) => {
29+
if (event.key === 'Escape') {
30+
inputValue.value = ''
31+
props.column.setFilterValue(undefined)
32+
}
33+
}
34+
35+
// Initialize with current filter value
36+
onMounted(() => {
37+
const currentValue = props.column.getFilterValue() as string
38+
if (currentValue) {
39+
inputValue.value = currentValue
40+
}
41+
})
42+
43+
// Watch for external filter changes (if filter is cleared programmatically)
44+
watch(
45+
() => props.column.getFilterValue(),
46+
newValue => {
47+
const stringValue = (newValue as string) || ''
48+
if (stringValue !== inputValue.value) {
49+
inputValue.value = stringValue
50+
}
51+
}
52+
)
1153
</script>
1254

1355
<script lang="ts">
@@ -17,9 +59,5 @@ export default {
1759
</script>
1860

1961
<template>
20-
<Input
21-
:placeholder="placeholder"
22-
:model-value="column.getFilterValue() as string"
23-
@update:model-value="column.setFilterValue($event)"
24-
/>
62+
<Input v-model="inputValue" :placeholder="placeholder" @keydown="handleKeydown" />
2563
</template>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { FilterFn } from '@tanstack/vue-table'
2+
import type { Event } from '@/modules/events/types'
3+
import type { Field } from '@/modules/fields/types'
4+
import type { Tag } from '@/modules/tags/types'
5+
6+
/**
7+
* Universal multi-field search function for any array of objects
8+
* @param item The item to check
9+
* @param fields Array of field names to search in (supports dot notation)
10+
* @param searchQuery The search query string
11+
* @returns true if item matches the search query
12+
*/
13+
export function searchMultiField<T>(item: T, fields: string[], searchQuery: string): boolean {
14+
if (!searchQuery || typeof searchQuery !== 'string') {
15+
return true
16+
}
17+
18+
const searchText = searchQuery.toLowerCase().trim()
19+
if (!searchText) return true
20+
21+
return fields.some(field => {
22+
const value = getNestedValue(item, field)
23+
if (value === null || value === undefined) return false
24+
25+
const stringValue = String(value).toLowerCase()
26+
return stringValue.includes(searchText)
27+
})
28+
}
29+
30+
/**
31+
* Filter an array using multi-field search
32+
* @param data Array of items to filter
33+
* @param fields Array of field names to search in
34+
* @param searchQuery The search query string
35+
* @returns Filtered array
36+
*/
37+
export function filterMultiField<T>(data: T[], fields: string[], searchQuery: string): T[] {
38+
if (!searchQuery?.trim()) return data
39+
40+
return data.filter(item => searchMultiField(item, fields, searchQuery))
41+
}
42+
43+
/**
44+
* Creates a TanStack Table filter function from multi-field search
45+
* @param fields Array of field names to search in
46+
* @returns TanStack Table filter function
47+
*/
48+
export function createTableFilter<TData>(fields: string[]): FilterFn<TData> {
49+
return (row, columnId, filterValue) => {
50+
return searchMultiField(row.original, fields, filterValue as string)
51+
}
52+
}
53+
54+
/**
55+
* Gets a nested value from an object using dot notation
56+
* @param obj The object to get the value from
57+
* @param path The path to the value (e.g., 'user.name' or 'id')
58+
* @returns The value at the path, or undefined if not found
59+
*/
60+
function getNestedValue<T>(obj: T, path: string): unknown {
61+
return path.split('.').reduce((current: unknown, key: string) => {
62+
return (current as Record<string, unknown>)?.[key]
63+
}, obj)
64+
}
65+
66+
/**
67+
* Pre-configured fields for different entities
68+
*/
69+
export const EVENTS_SEARCH_FIELDS = ['id', 'name', 'description']
70+
export const FIELDS_SEARCH_FIELDS = ['id', 'name', 'description']
71+
export const TAGS_SEARCH_FIELDS = ['id', 'description']
72+
73+
/**
74+
* Pre-configured TanStack Table filters
75+
*/
76+
export const eventsTableFilter = createTableFilter<Event>(EVENTS_SEARCH_FIELDS)
77+
export const fieldsTableFilter = createTableFilter<Field>(FIELDS_SEARCH_FIELDS)
78+
export const tagsTableFilter = createTableFilter<Tag>(TAGS_SEARCH_FIELDS)
79+
80+
/**
81+
* Pre-configured array filter functions
82+
*/
83+
export const filterEvents = (data: Event[], searchQuery: string): Event[] =>
84+
filterMultiField(data, EVENTS_SEARCH_FIELDS, searchQuery)
85+
86+
export const filterFields = (data: Field[], searchQuery: string): Field[] =>
87+
filterMultiField(data, FIELDS_SEARCH_FIELDS, searchQuery)
88+
89+
export const filterTags = (data: Tag[], searchQuery: string): Tag[] =>
90+
filterMultiField(data, TAGS_SEARCH_FIELDS, searchQuery)

0 commit comments

Comments
 (0)