Option-Based-Product-Filters
14 - Option-Based Product Filters
Written for AI agents. See Log Methodology Note below for details.
Status: Implemented
Background
The product search component currently supports filtering by:
- Price range — computed from search API aggregations
- Categories — loaded from the Wix Categories API
- In-stock — simple boolean filter
Many stores also need to filter by product options — e.g., filter by Color=Red or Size=M. The Wix Catalog V3 searchProducts API supports filtering by option name and choice values:
// Filter products with color "Black"
filter: {
'options.name': { $hasSome: ['Color'] },
'options.choicesSettings.choices.name': { $hasSome: ['Black'] },
}
Problem
The search API can aggregate option names and choice names, but it returns them as flat lists — there's no way to know which choice belongs to which option from the aggregation alone.
optionNames: ["Color", "Size"]
choiceNames: ["Red", "Blue", "S", "M", "L", "XL"]
// Which choices belong to which option? Unknown from aggregation.
We need structured filter data: Color → [Red, Blue, ...], Size → [S, M, L, XL].
Design
Two-Source Approach
Combine two data sources to build structured option filters:
Customizations API (
customizationsV3.queryCustomizations) — returns all product options with their choices, organized by option. Loaded once on service startup, cached.Search API aggregations (
optionNames+choiceNames) — returns which option names and choice names actually exist for products in the current query/category. Already part ofsearchProducts.
Cross-referencing these gives us structured filters scoped to the current results:
Customizations (all options):
Color → [Red, Blue, Green, Yellow, Black, White]
Size → [XS, S, M, L, XL, XXL]
Search aggregation (current category):
optionNames: [Color, Size]
choiceNames: [Red, Blue, S, M, L]
Result (filtered):
Color → [Red, Blue]
Size → [S, M, L]
Customizations Loading
Load customizations once on service startup and cache on the WixStoresService:
export interface WixStoresService {
// ... existing ...
/** Cached product customizations (options with choices). Lazily loaded. */
getCustomizations(): Promise<Customization[]>;
}
Search Aggregation Changes
Add optionNames and choiceNames aggregations to the existing searchProducts aggregation list:
const aggregations = [
// ... existing price aggregations ...
{
fieldPath: 'options.name',
name: 'optionNames',
type: 'VALUE',
value: { limit: 20, sortType: 'VALUE', sortDirection: 'DESC' },
},
{
fieldPath: 'options.choicesSettings.choices.name',
name: 'choiceNames',
type: 'VALUE',
value: { limit: 50, sortType: 'VALUE', sortDirection: 'DESC' },
},
];
Building Structured Filters
Cross-reference customizations with aggregation results:
function getAvailableProductOptions(
aggregationResults: AggregationResults[],
customizations: Customization[],
): ProductOptionFilter[] {
// Extract option names and choice names with counts from aggregation
const optionEntries = extractValuesWithCounts(aggregationResults, 'optionNames');
const choiceEntries = extractValuesWithCounts(aggregationResults, 'choiceNames');
const optionNames = new Set(optionEntries.map((e) => e.value));
const choiceCounts = new Map(choiceEntries.map((e) => [e.value.toLowerCase(), e.count]));
return (
customizations
// Only PRODUCT_OPTION type, only options present in aggregation
.filter((c) => c.customizationType === 'PRODUCT_OPTION' && optionNames.has(c.name))
.map((c) => ({
id: c._id,
name: c.name,
renderType: c.customizationRenderType,
choices: c.choicesSettings.choices
.filter((ch) => choiceCounts.has(ch.name.toLowerCase()))
.map((ch) => ({
id: ch._id,
name: ch.name,
colorCode: ch.colorCode,
productCount: choiceCounts.get(ch.name.toLowerCase()) ?? 0,
}))
// Sort by product count descending (most used first)
.sort((a, b) => b.productCount - a.productCount),
}))
.filter((o) => o.choices.length > 0)
);
}
Contract Changes
Add optionFilters to the product-search contract filters:
- tag: optionFilters
type: sub-contract
repeated: true
trackBy: optionId
description: Filter by product options (e.g., Color, Size)
tags:
- tag: optionId
type: data
dataType: string
- tag: optionName
type: data
dataType: string
- tag: optionRenderType
type: variant
dataType: enum (TEXT_CHOICES | SWATCH_CHOICES)
- tag: choices
type: sub-contract
repeated: true
trackBy: choiceId
tags:
- tag: choiceId
type: data
dataType: string
- tag: choiceName
type: data
dataType: string
- tag: colorCode
type: data
dataType: string
description: HEX color code (for swatch rendering)
- tag: productCount
type: data
dataType: number
phase: fast+interactive
description: Number of products with this choice in current results
- tag: isSelected
type: variant
dataType: boolean
phase: fast+interactive
- tag: choiceCheckbox
type: interactive
elementType: HTMLInputElement
description: Checkbox to toggle this choice filter
Search Filter Application
When option filters are selected, use OR semantics within each option (e.g., Color=Red OR Blue):
// For each option with selected choices, filter products that have ANY of the selected values
const optionFilters = selectedOptions.map((option) => ({
$and: [
{ 'options.name': { $hasSome: [option.name] } },
{ 'options.choicesSettings.choices.name': { $hasSome: option.selectedChoices } },
],
}));
// Multiple options are AND-ed together (e.g., Color=Red AND Size=M)
Data Flow
Service Startup:
customizationsV3.queryCustomizations() → cached on service
Slow Phase:
(customizations already cached)
Fast Phase (searchProducts):
aggregations include optionNames + choiceNames
→ cross-reference with cached customizations
→ structured optionFilters in view state
Interactive Phase:
user clicks choice → update isSelected → trigger search with option filter
Implementation Plan
Phase 1: Customizations on Service
- Add
customizationsV3client toWixStoresService - Add
getCustomizations()with lazy loading and caching - Load
PRODUCT_OPTIONcustomizations only
Phase 2: Search Aggregation
- Add
optionNamesandchoiceNamesaggregations tosearchProducts - Return aggregation results alongside products
Phase 3: Contract
- Add
optionFilterssub-contract to product-search filters - Regenerate types
Phase 4: Mapper
- Build
getAvailableProductOptions()from aggregation + customizations - Sort choices by product count descending (most used first)
- Include product count per choice from aggregation data
- Populate
optionFiltersin search output
Phase 5: Interactive — Filter Selection
- Choice checkbox toggles
isSelected - Trigger search with option filter applied (OR within option, AND across options)
- URL persistence for selected options
Trade-offs
Pros
- Structured option filters (choices grouped by option)
- Scoped to current results (only shows relevant options/choices)
- Customizations loaded once (cached), aggregations are lightweight
- Supports both swatch and text rendering
Cons
- Requires two data sources (customizations + aggregation)
- Customizations cache may become stale (options added/removed)
- Aggregation limits (20 option names, 50 choice names) may truncate large catalogs
Known Limitations
Choice name collision across options. The choiceNames VALUE aggregation returns a flat list across all options. If two different options share a choice name (e.g., Size has "M" and Material has "M"), their counts are combined into a single aggregation entry. The customization cross-reference correctly assigns each choice to the right option, but the productCount may be inflated for colliding names. A nested aggregation (VALUE on options.name → VALUE on choices) would fix this, but it's unclear whether the Wix search API supports nesting on these repeated sub-fields. In practice, choice name collisions across options are rare.
No per-category product counts. The Wix search API does not support VALUE aggregation on allCategoriesInfo.categories._id. This means we cannot get per-category product counts from the search results. Categories remain a static list without counts or disabled state — unlike option filter choices which do get live counts via the choiceNames aggregation.
Answers
- Choice sorting: By product count descending — most used choices appear first. The aggregation returns counts per value, and choices are sorted from highest to lowest count.
- Product counts: Yes — the aggregation
values.resultsincludes acountfield per value. Exposed asproductCounton each choice. - Multi-selection: OR within an option (Color=Red OR Blue), AND across options (Color=Red AND Size=M).
Implementation Results
Files Modified
| File | Change |
|---|---|
packages/wix-stores/lib/utils/wix-store-api.ts |
Added getCustomizationsV3Client() singleton factory |
packages/wix-stores/lib/services/wix-stores-service.ts |
Added customizations client + getCustomizations() lazy-cached method |
packages/wix-stores/lib/actions/stores-actions.ts |
Added option aggregations, filter input/output types, getAvailableProductOptions() |
packages/wix-stores/lib/contracts/product-search.jay-contract |
Added optionFilters sub-contract to filters |
packages/wix-stores/lib/components/product-search.ts |
Interactive: checkbox toggle, URL persistence, search with options, clear filters |
packages/wix-stores/lib/actions/search-products.jay-action |
Updated input/output schemas with optionFilters |
Deviations from Design
- Contract
isSelectedtag: Changed fromtype: varianttotype: [data, interactive]withelementType: HTMLInputElement— makes it a checkbox that can be bound to an<input>element and also carries data. Removed explicitphase: fast+interactivesince theinteractivetype implicitly sets this. - Removed
choiceCheckboxtag: TheisSelectedtag withtype: [data, interactive]serves both as the data carrier and the interactive element, so a separatechoiceCheckboxtag is unnecessary. queryCustomizations()call: Called without arguments instead ofqueryCustomizations({})— the{}argument matched the wrong overload (query overload returning a Promise instead of builder overload returningCustomizationsQueryBuilder).- URL encoding: Option filter URL param uses
encodeURIComponentfor option names and choice names to handle special characters safely.
Verification
yarn definitions— generates updated.d.tsfiles successfullynpx tsc --noEmit— 0 type errors
Static Filter Lists with Live Counts
Problem
The current implementation runs all aggregations against the fully-filtered result set. When a user selects Color=Red, the Size choices update to only show sizes available in red products — and Color choices also collapse to only show "Red". Filter values disappear mid-browsing, which is disorienting.
Design
Different filter types need different aggregation strategies:
Price filter — always reflects the fully-filtered set. When the user narrows by category or option, the price range should shrink to match. This is the current behavior and is correct.
Option choices — the list of values stays static (established from the initial unfiltered search). Counts update to reflect the current filtered set, and choices with count=0 become disabled. Users see the full option landscape at all times but know which combinations are empty.
Categories — static list without live counts. The Wix search API does not support aggregation on category IDs, so we cannot get per-category product counts. Categories remain a simple checkbox list.
Behavior
Initial load (no filters):
Color: Red (15), Blue (12), Green (8), Black (5)
Size: S (10), M (18), L (14), XL (3)
User selects Color=Red:
Color: Red (15), Blue (12), Green (8), Black (5) ← unchanged, from base list
Size: S (4), M (8), L (3), XL (0 disabled) ← counts from filtered aggregation
User also selects Size=M:
Color: Red (8), Blue (5), Green (3), Black (0 disabled) ← counts reflect Size=M
Size: S (4), M (8), L (3), XL (0 disabled) ← counts reflect Color=Red
Implementation Approach
Base list: Established from the slow phase's unfiltered search. The slow phase always runs searchProducts with no user filters (only the base category scope if any). This result is already carried forward to the fast phase via preloadedResult. The option filters and category counts from this unfiltered result become the canonical base lists — even when the fast phase runs a second filtered search due to URL params.
Filtered counts: Every subsequent search (fast phase with URL filters, or interactive phase) still returns aggregations. The component merges filtered counts into the base list:
- Choice present in aggregation → update
productCount - Choice absent from aggregation → set
productCount = 0,isDisabled = true
No extra API calls: The base list comes from the slow phase search that already runs unconditionally. The fast and interactive phases merge counts from their filtered searches into it.
Contract Changes
Add isDisabled to option filter choices:
# In optionFilters > choices:
- tag: isDisabled
type: data
dataType: boolean
phase: fast+interactive
description: Whether this choice has no matching products (count=0)
Note: Categories do not get productCount or isDisabled — the Wix search API does not support aggregation on category IDs.
Data Flow
Slow phase (always unfiltered, only base category scope):
searchProducts(no filters) → aggregations
→ base optionFilters list (full choices with unfiltered counts)
Stored in preloadedResult carry-forward
Fast phase:
If no URL filters → use preloadedResult directly, base lists = preloaded
If URL filters → run filtered searchProducts
→ merge filtered option counts into base lists from preloadedResult
→ choices absent from filtered aggregation → count=0, disabled
Price range: always from the current (possibly filtered) aggregation
Categories: static list, no counts
Interactive phase (user applies filters):
searchProducts → new aggregations
Merge new counts into base option filter lists (carried forward from slow):
- for each base choice:
find matching entry in new aggregation
update productCount (or 0 if absent)
set isDisabled = (productCount === 0)
Price range: use new aggregation directly (not merged)
Categories: unchanged (no counts available)
Files Modified
product-search.jay-contract— addedisDisabledto option choicesproduct-search.ts— store base option filter lists from slow phase, merge filtered counts on subsequent searches- No category count aggregation (Wix API limitation)
Static Filter Lists — Implementation Results
Implemented for option filters only. Categories do not get live counts (Wix API limitation).
- Carry-forward: Added
baseOptionFilters: ProductOptionFilter[]to bothSearchSlowCarryForwardandSearchFastCarryForward - Merge helper:
buildOptionFiltersViewState()merges filtered counts into the static base list, settingisDisabled = (count === 0) - Interactive phase: After each
performSearch, option filter counts and disabled state are updated viasetFilterswhile preserving user selections - Choice sort order: Sorted by product count descending (most used first)
- Categories: Static list without counts — the Wix search API does not support VALUE aggregation on
allCategoriesInfo.categories._id
Verification: yarn definitions succeeds, npx tsc --noEmit — 0 type errors.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.