feat: enhance MultiSelect and SearchBox components with improved filtering and search functionality

This commit is contained in:
Johnpaul
2025-08-20 19:38:53 +01:00
parent 8e36c347c2
commit ce30ea3417
3 changed files with 169 additions and 46 deletions

View File

@@ -34,17 +34,54 @@
</template>
<template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
:label="modelFilterLabel"
:options="modelOptions"
>
<template #icon>
<i-lucide:cpu />
</template>
</MultiSelect>
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap justify-between">
<!-- Filters -->
<div class="flex gap-2 flex-wrap">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
:label="modelFilterLabel"
:options="modelOptions"
:has-search-box="true"
:search-placeholder="
$t('templateWorkflows.searchModels', 'Search models...')
"
>
<template #icon>
<i-lucide:cpu />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:has-search-box="true"
:search-placeholder="
$t('templateWorkflows.searchUseCases', 'Search use cases...')
"
>
<template #icon>
<i-lucide:target />
</template>
</MultiSelect>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:has-search-box="true"
:search-placeholder="
$t('templateWorkflows.searchLicenses', 'Search licenses...')
"
>
<template #icon>
<i-lucide:file-text />
</template>
</MultiSelect>
</div>
<!-- Sort Options -->
<SingleSelect
@@ -57,25 +94,6 @@
<i-lucide:arrow-up-down />
</template>
</SingleSelect>
<!-- Filter Tags -->
<div
v-if="selectedModelObjects.length > 0"
class="flex flex-wrap gap-1 items-center"
>
<span class="text-sm text-neutral-600 dark:text-neutral-400">{{
$t('templateWorkflows.activeFilters', 'Filters:')
}}</span>
<button
v-for="modelObj in selectedModelObjects"
:key="modelObj.value"
class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
@click="removeModelFilter(modelObj.value)"
>
{{ modelObj.name }}
<i-lucide:x class="w-3 h-3" />
</button>
</div>
</div>
</template>
@@ -189,13 +207,19 @@ const navigationFilteredTemplates = computed(() => {
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
totalCount,
resetFilters,
removeModelFilter
resetFilters
// removeModelFilter,
// removeUseCaseFilter,
// removeLicenseFilter
} = useTemplateFiltering(navigationFilteredTemplates)
// Convert between string array and object array for MultiSelect component
@@ -208,13 +232,37 @@ const selectedModelObjects = computed({
}
})
const selectedUseCaseObjects = computed({
get() {
return selectedUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
},
set(value: { name: string; value: string }[]) {
selectedUseCases.value = value.map((item) => item.value)
}
})
const selectedLicenseObjects = computed({
get() {
return selectedLicenses.value.map((license) => ({
name: license,
value: license
}))
},
set(value: { name: string; value: string }[]) {
selectedLicenses.value = value.map((item) => item.value)
}
})
// Loading state
const isLoading = ref(true)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Model filter options
// Filter options
const modelOptions = computed(() =>
availableModels.value.map((model) => ({
name: model,
@@ -222,7 +270,21 @@ const modelOptions = computed(() =>
}))
)
// Model filter label
const useCaseOptions = computed(() =>
availableUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
)
const licenseOptions = computed(() =>
availableLicenses.value.map((license) => ({
name: license,
value: license
}))
)
// Filter labels
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
@@ -235,17 +297,41 @@ const modelFilterLabel = computed(() => {
}
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
})
const licenseFilterLabel = computed(() => {
if (selectedLicenseObjects.value.length === 0) {
return t('templateWorkflows.licenseFilter', 'License')
} else if (selectedLicenseObjects.value.length === 1) {
return selectedLicenseObjects.value[0].name
} else {
return t('templateWorkflows.licensesSelected', {
count: selectedLicenseObjects.value.length
})
}
})
// Sort options
const sortOptions = computed(() => [
{
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.alphabetical', 'A → Z'),
value: 'alphabetical'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
}
])
// Additional computed properties for TemplateWorkflowView

View File

@@ -2,7 +2,7 @@
<div class="relative inline-block">
<MultiSelect
v-model="selectedItems"
:options="options"
:options="filteredOptions"
option-label="name"
unstyled
:placeholder="label"
@@ -72,7 +72,9 @@
class="text-xs text-bold text-white"
/>
</div>
<span>{{ slotProps.option.name }}</span>
<span class="truncate" :title="slotProps.option.name">{{
slotProps.option.name
}}</span>
</div>
</template>
</MultiSelect>
@@ -88,6 +90,7 @@
</template>
<script setup lang="ts">
import Fuse from 'fuse.js'
import MultiSelect, {
MultiSelectPassThroughMethodOptions
} from 'primevue/multiselect'
@@ -128,6 +131,36 @@ const selectedItems = defineModel<Option[]>({
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: ['name', 'value'],
threshold: 0.3,
includeScore: true
}
// Create Fuse instance
const fuse = computed(() => new Fuse(options, fuseOptions))
// Filtered options based on search
const filteredOptions = computed(() => {
if (!hasSearchBox || !searchQuery.value?.trim()) {
return options
}
const searchResults = fuse.value.search(searchQuery.value)
const matchingOptions = searchResults.map((result) => result.item)
// Always include selected items, even if they don't match the search
const selectedValues = selectedItems.value.map((item) => item.value)
const selectedOptionsNotInResults = options.filter(
(option) =>
selectedValues.includes(option.value) &&
!matchingOptions.some((matching) => matching.value === option.value)
)
return [...selectedOptionsNotInResults, ...matchingOptions]
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
@@ -157,9 +190,10 @@ const pt = computed(() => ({
}),
// Overlay & list visuals unchanged
overlay:
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700 shadow-lg max-h-64 overflow-hidden',
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
class:
'flex flex-col gap-1 p-0 list-none border-none text-xs max-h-52 overflow-y-auto'
},
// Option row hover tone identical
option:

View File

@@ -13,13 +13,16 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed, defineModel } from 'vue'
import { computed } from 'vue'
const { placeHolder, hasBorder = false } = defineProps<{
placeHolder?: string
hasBorder?: boolean
}>()
const searchQuery = defineModel<string>('')
const searchQuery = defineModel<string>({
default: ''
})
const wrapperStyle = computed(() => {
return hasBorder
@@ -30,4 +33,4 @@ const wrapperStyle = computed(() => {
const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
})
</script>
</script>