Implement AND/OR filter logic in useCategoryFilter and update MarketPage UI

- Added support for AND/OR filtering modes in the `useCategoryFilter` composable, allowing users to filter products based on all or any selected categories.
- Introduced reactive state management for filter mode and updated the filtering logic to accommodate the new functionality.
- Enhanced the MarketPage UI with a toggle for selecting filter modes, improving user experience and accessibility.
- Updated ARIA attributes for better screen reader support in the filter mode selection.

These changes significantly enhance the category filtering capabilities, providing users with more control over product visibility.

Refactor CreateProductDialog and MarketPage for improved category handling

- Updated CreateProductDialog to utilize `model-value` and `@update:model-value` for the CategoryInput component, enhancing reactivity in category selection.
- Enhanced MarketPage filtering logic to support AND/OR modes, allowing for more flexible product filtering based on selected categories.
- Improved category normalization and matching logic to ensure accurate filtering results.

These changes streamline the category management and filtering processes, providing users with a more intuitive experience when creating and finding products.
This commit is contained in:
padreug 2025-09-26 00:03:23 +02:00
parent bb761abe75
commit 39ecba581f
3 changed files with 106 additions and 24 deletions

View file

@ -105,13 +105,14 @@
</div> </div>
<!-- Categories --> <!-- Categories -->
<FormField v-slot="{ componentField }" name="categories"> <FormField v-slot="{ value, handleChange }" name="categories">
<FormItem> <FormItem>
<FormLabel>Categories</FormLabel> <FormLabel>Categories</FormLabel>
<FormDescription>Add categories to help customers find your product</FormDescription> <FormDescription>Add categories to help customers find your product</FormDescription>
<FormControl> <FormControl>
<CategoryInput <CategoryInput
v-bind="componentField" :model-value="value || []"
@update:model-value="handleChange"
:disabled="isCreating" :disabled="isCreating"
placeholder="Enter category (e.g., electronics, clothing, books...)" placeholder="Enter category (e.g., electronics, clothing, books...)"
:max-categories="10" :max-categories="10"

View file

@ -11,6 +11,7 @@ export interface CategoryFilterOptions {
caseSensitive?: boolean caseSensitive?: boolean
includeEmpty?: boolean includeEmpty?: boolean
minCount?: number minCount?: number
mode?: 'any' | 'all' // OR vs AND logic
} }
/** /**
@ -24,6 +25,9 @@ export function useCategoryFilter(
// Use Set for O(1) lookups instead of array // Use Set for O(1) lookups instead of array
const selectedCategories = ref<Set<string>>(new Set()) const selectedCategories = ref<Set<string>>(new Set())
// Filter mode state (reactive)
const filterMode = ref<'any' | 'all'>(options.mode || 'any')
// Computed property for all available categories with counts // Computed property for all available categories with counts
const allCategories = computed<CategoryItem[]>(() => { const allCategories = computed<CategoryItem[]>(() => {
const categoryMap = new Map<string, number>() const categoryMap = new Map<string, number>()
@ -49,7 +53,7 @@ export function useCategoryFilter(
.sort((a, b) => b.count - a.count) // Sort by popularity .sort((a, b) => b.count - a.count) // Sort by popularity
}) })
// Optimized product filtering with early returns and efficient lookups // Optimized product filtering with AND/OR logic
const filteredProducts = computed<Product[]>(() => { const filteredProducts = computed<Product[]>(() => {
const selectedSet = selectedCategories.value const selectedSet = selectedCategories.value
@ -58,26 +62,34 @@ export function useCategoryFilter(
return products.value return products.value
} }
// Convert selected categories to array for faster iteration in some cases
const selectedArray = Array.from(selectedSet)
return products.value.filter(product => { return products.value.filter(product => {
// Handle empty categories // Handle empty categories
if (!product.categories?.length) { if (!product.categories?.length) {
return options.includeEmpty || false return options.includeEmpty || false
} }
// Check if product has any selected category (optimized) // Normalize product categories
for (const cat of product.categories) { const productCategories = product.categories
if (cat && cat.trim()) { .filter(cat => cat && cat.trim())
const normalizedCategory = options.caseSensitive ? cat : cat.toLowerCase() .map(cat => options.caseSensitive ? cat : cat.toLowerCase())
if (selectedSet.has(normalizedCategory)) {
return true // Early return on first match if (productCategories.length === 0) {
} return options.includeEmpty || false
}
} }
return false // Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedSet.has(cat)
)
// Apply AND/OR logic
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedSet.size
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
}) })
}) })
@ -139,6 +151,14 @@ export function useCategoryFilter(
return selectedCategories.value.has(normalizedCategory) return selectedCategories.value.has(normalizedCategory)
} }
const setFilterMode = (mode: 'any' | 'all') => {
filterMode.value = mode
}
const toggleFilterMode = () => {
filterMode.value = filterMode.value === 'any' ? 'all' : 'any'
}
// Category statistics // Category statistics
const categoryStats = computed(() => ({ const categoryStats = computed(() => ({
totalCategories: allCategories.value.length, totalCategories: allCategories.value.length,
@ -150,6 +170,7 @@ export function useCategoryFilter(
return { return {
// State (readonly to prevent external mutation) // State (readonly to prevent external mutation)
selectedCategories: readonly(selectedCategories), selectedCategories: readonly(selectedCategories),
filterMode: readonly(filterMode),
allCategories, allCategories,
filteredProducts, filteredProducts,
selectedCount, selectedCount,
@ -163,7 +184,9 @@ export function useCategoryFilter(
removeCategory, removeCategory,
clearAllCategories, clearAllCategories,
selectMultipleCategories, selectMultipleCategories,
isSelected isSelected,
setFilterMode,
toggleFilterMode
} }
} }

View file

@ -61,9 +61,42 @@
aria-labelledby="category-filters-heading" aria-labelledby="category-filters-heading"
> >
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-4">
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700"> <h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
Browse by Category Browse by Category
</h3> </h3>
<!-- AND/OR Filter Mode Toggle -->
<div
v-if="selectedCategoriesCount > 1"
class="flex items-center gap-2"
role="group"
aria-label="Filter mode selection"
>
<span class="text-xs text-muted-foreground">Match:</span>
<Button
@click="setFilterMode('any')"
:variant="filterMode === 'any' ? 'default' : 'outline'"
size="sm"
class="h-6 px-2 text-xs"
:aria-pressed="filterMode === 'any'"
aria-label="Show products with any selected category"
>
Any
</Button>
<Button
@click="setFilterMode('all')"
:variant="filterMode === 'all' ? 'default' : 'outline'"
size="sm"
class="h-6 px-2 text-xs"
:aria-pressed="filterMode === 'all'"
aria-label="Show products with all selected categories"
>
All
</Button>
</div>
</div>
<Button <Button
v-if="selectedCategoriesCount > 0" v-if="selectedCategoriesCount > 0"
@click="clearAllCategoryFilters" @click="clearAllCategoryFilters"
@ -145,7 +178,11 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Filter class="w-4 h-4 text-accent-foreground/80" aria-hidden="true" /> <Filter class="w-4 h-4 text-accent-foreground/80" aria-hidden="true" />
<span class="text-sm font-medium text-accent-foreground"> <span class="text-sm font-medium text-accent-foreground">
Active Filters: Active Filters
<span v-if="selectedCategoriesCount > 1" class="text-xs opacity-75">
({{ filterMode === 'any' ? 'Any' : 'All' }} match):
</span>
<span v-else>:</span>
</span> </span>
<div class="flex gap-1" role="list" aria-label="Selected category filters"> <div class="flex gap-1" role="list" aria-label="Selected category filters">
<Badge <Badge
@ -224,8 +261,11 @@ const {
selectedCount: selectedCategoriesCount, selectedCount: selectedCategoriesCount,
selectedCategoryNames: selectedCategories, selectedCategoryNames: selectedCategories,
hasActiveFilters, hasActiveFilters,
filterMode,
toggleCategory, toggleCategory,
clearAllCategories: clearAllCategoryFilters, clearAllCategories: clearAllCategoryFilters,
setFilterMode,
toggleFilterMode,
categoryStats categoryStats
} = useCategoryFilter(computed(() => marketStore.products)) } = useCategoryFilter(computed(() => marketStore.products))
@ -277,17 +317,35 @@ const productsToDisplay = computed(() => {
? searchResults.value ? searchResults.value
: marketStore.products : marketStore.products
// Apply category filtering using our composable // Apply category filtering using our composable's proper AND/OR logic
if (!hasActiveFilters.value) { if (!hasActiveFilters.value) {
return baseProducts return baseProducts
} }
// Filter base products by selected categories // Use the composable's filtering logic which supports AND/OR modes
return baseProducts.filter(product => { return baseProducts.filter(product => {
if (!product.categories?.length) return false if (!product.categories?.length) return false
return product.categories.some(cat =>
selectedCategories.value.includes(cat.toLowerCase()) // Normalize product categories
const productCategories = product.categories
.filter(cat => cat && cat.trim())
.map(cat => cat.toLowerCase())
if (productCategories.length === 0) return false
// Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedCategories.value.includes(cat)
) )
// Apply AND/OR logic based on filter mode
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedCategories.value.length
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
}) })
}) })