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:
parent
bb761abe75
commit
39ecba581f
3 changed files with 106 additions and 24 deletions
|
|
@ -105,13 +105,14 @@
|
|||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<FormField v-slot="{ componentField }" name="categories">
|
||||
<FormField v-slot="{ value, handleChange }" name="categories">
|
||||
<FormItem>
|
||||
<FormLabel>Categories</FormLabel>
|
||||
<FormDescription>Add categories to help customers find your product</FormDescription>
|
||||
<FormControl>
|
||||
<CategoryInput
|
||||
v-bind="componentField"
|
||||
:model-value="value || []"
|
||||
@update:model-value="handleChange"
|
||||
:disabled="isCreating"
|
||||
placeholder="Enter category (e.g., electronics, clothing, books...)"
|
||||
:max-categories="10"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface CategoryFilterOptions {
|
|||
caseSensitive?: boolean
|
||||
includeEmpty?: boolean
|
||||
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
|
||||
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
|
||||
const allCategories = computed<CategoryItem[]>(() => {
|
||||
const categoryMap = new Map<string, number>()
|
||||
|
|
@ -49,7 +53,7 @@ export function useCategoryFilter(
|
|||
.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 selectedSet = selectedCategories.value
|
||||
|
||||
|
|
@ -58,26 +62,34 @@ export function useCategoryFilter(
|
|||
return products.value
|
||||
}
|
||||
|
||||
// Convert selected categories to array for faster iteration in some cases
|
||||
const selectedArray = Array.from(selectedSet)
|
||||
|
||||
return products.value.filter(product => {
|
||||
// Handle empty categories
|
||||
if (!product.categories?.length) {
|
||||
return options.includeEmpty || false
|
||||
}
|
||||
|
||||
// Check if product has any selected category (optimized)
|
||||
for (const cat of product.categories) {
|
||||
if (cat && cat.trim()) {
|
||||
const normalizedCategory = options.caseSensitive ? cat : cat.toLowerCase()
|
||||
if (selectedSet.has(normalizedCategory)) {
|
||||
return true // Early return on first match
|
||||
}
|
||||
}
|
||||
// Normalize product categories
|
||||
const productCategories = product.categories
|
||||
.filter(cat => cat && cat.trim())
|
||||
.map(cat => options.caseSensitive ? cat : cat.toLowerCase())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const setFilterMode = (mode: 'any' | 'all') => {
|
||||
filterMode.value = mode
|
||||
}
|
||||
|
||||
const toggleFilterMode = () => {
|
||||
filterMode.value = filterMode.value === 'any' ? 'all' : 'any'
|
||||
}
|
||||
|
||||
// Category statistics
|
||||
const categoryStats = computed(() => ({
|
||||
totalCategories: allCategories.value.length,
|
||||
|
|
@ -150,6 +170,7 @@ export function useCategoryFilter(
|
|||
return {
|
||||
// State (readonly to prevent external mutation)
|
||||
selectedCategories: readonly(selectedCategories),
|
||||
filterMode: readonly(filterMode),
|
||||
allCategories,
|
||||
filteredProducts,
|
||||
selectedCount,
|
||||
|
|
@ -163,7 +184,9 @@ export function useCategoryFilter(
|
|||
removeCategory,
|
||||
clearAllCategories,
|
||||
selectMultipleCategories,
|
||||
isSelected
|
||||
isSelected,
|
||||
setFilterMode,
|
||||
toggleFilterMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,9 +61,42 @@
|
|||
aria-labelledby="category-filters-heading"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
|
||||
Browse by Category
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
|
||||
Browse by Category
|
||||
</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
|
||||
v-if="selectedCategoriesCount > 0"
|
||||
@click="clearAllCategoryFilters"
|
||||
|
|
@ -145,7 +178,11 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<Filter class="w-4 h-4 text-accent-foreground/80" aria-hidden="true" />
|
||||
<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>
|
||||
<div class="flex gap-1" role="list" aria-label="Selected category filters">
|
||||
<Badge
|
||||
|
|
@ -224,8 +261,11 @@ const {
|
|||
selectedCount: selectedCategoriesCount,
|
||||
selectedCategoryNames: selectedCategories,
|
||||
hasActiveFilters,
|
||||
filterMode,
|
||||
toggleCategory,
|
||||
clearAllCategories: clearAllCategoryFilters,
|
||||
setFilterMode,
|
||||
toggleFilterMode,
|
||||
categoryStats
|
||||
} = useCategoryFilter(computed(() => marketStore.products))
|
||||
|
||||
|
|
@ -277,17 +317,35 @@ const productsToDisplay = computed(() => {
|
|||
? searchResults.value
|
||||
: marketStore.products
|
||||
|
||||
// Apply category filtering using our composable
|
||||
// Apply category filtering using our composable's proper AND/OR logic
|
||||
if (!hasActiveFilters.value) {
|
||||
return baseProducts
|
||||
}
|
||||
|
||||
// Filter base products by selected categories
|
||||
// Use the composable's filtering logic which supports AND/OR modes
|
||||
return baseProducts.filter(product => {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue