Build a product catalog with filtering
In this tutorial, you will build a product catalog with faceted navigation. This is a common pattern for e-commerce sites where users filter products by category, price, brand, and other attributes.
Time required: 25 minutes
What you will build: A product catalog that supports filtering by category, price range, brand and tags, with dynamic facet counts that update as users apply filters, and a small class that manages the user's filter selections.
What is faceted navigation?
Faceted navigation lets users narrow results by several attributes at once, with a live count next to each option; dcupl computes those counts with catalog.fn.facets(), which you will use throughout this tutorial. The pattern itself is in Faceted search.
Prerequisites
- Completed the Build your first dcupl application tutorial
- A supported Node.js version, see Compatibility (
node -vto check) - Basic TypeScript/JavaScript knowledge
Step 1: set up the project
Create a new project directory and install dcupl (for yarn and pnpm, see the Quick start):
mkdir product-catalog
cd product-catalog
npm init -y
npm install @dcupl/coreCreate the main application file:
import { Dcupl } from '@dcupl/core';
async function main() {
const dcupl = new Dcupl();
// We will build the catalog here
}
main();Step 2: define the product model
A product catalog needs a model that captures all filterable attributes. Define the model, and a Product type for the query results:
import { Dcupl } from '@dcupl/core';
type Product = {
key: string;
name: string;
description: string;
category: string;
brand: string;
price: number;
rating: number;
inStock: boolean;
tags: string[];
};
async function main() {
const dcupl = new Dcupl();
// Define the Product model with filterable properties
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'description', type: 'string' },
{ key: 'category', type: 'string' },
{ key: 'brand', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'rating', type: 'float' },
{ key: 'inStock', type: 'boolean' },
{ key: 'tags', type: 'Array<string>' },
],
});
}
main();Notice the tags property uses Array<string> type. This allows products to have multiple tags like "new", "sale", "featured".
The Product type is for TypeScript: Step 4 passes it to dcupl.lists.create<Product>(), so the items you read from the list are typed.
Step 3: load product data
Add a realistic product dataset after dcupl.models.set(), inside main(). In production, this data would come from an API or a file:
// Load product data
dcupl.data.set(
[
// Electronics
{
key: 'p1',
name: 'Laptop Pro 15',
description: 'High-performance laptop',
category: 'Electronics',
brand: 'TechCorp',
price: 1299.99,
rating: 4.8,
inStock: true,
tags: ['featured', 'new'],
},
{
key: 'p2',
name: 'Wireless Mouse',
description: 'Ergonomic wireless mouse',
category: 'Electronics',
brand: 'TechCorp',
price: 49.99,
rating: 4.5,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p3',
name: 'USB-C Hub',
description: '7-in-1 USB-C hub',
category: 'Electronics',
brand: 'ConnectPro',
price: 79.99,
rating: 4.2,
inStock: true,
tags: [],
},
{
key: 'p4',
name: 'Mechanical Keyboard',
description: 'RGB mechanical keyboard',
category: 'Electronics',
brand: 'TypeMaster',
price: 149.99,
rating: 4.7,
inStock: false,
tags: ['popular'],
},
{
key: 'p5',
name: 'Monitor 27"',
description: '4K IPS monitor',
category: 'Electronics',
brand: 'ViewTech',
price: 449.99,
rating: 4.6,
inStock: true,
tags: ['featured'],
},
// Clothing
{
key: 'p6',
name: 'Cotton T-Shirt',
description: '100% organic cotton',
category: 'Clothing',
brand: 'EcoWear',
price: 29.99,
rating: 4.4,
inStock: true,
tags: ['sale', 'eco-friendly'],
},
{
key: 'p7',
name: 'Denim Jeans',
description: 'Classic fit jeans',
category: 'Clothing',
brand: 'DenimCo',
price: 79.99,
rating: 4.3,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p8',
name: 'Running Shoes',
description: 'Lightweight running shoes',
category: 'Clothing',
brand: 'SportyFeet',
price: 129.99,
rating: 4.8,
inStock: true,
tags: ['new', 'popular'],
},
{
key: 'p9',
name: 'Winter Jacket',
description: 'Waterproof winter jacket',
category: 'Clothing',
brand: 'OutdoorGear',
price: 199.99,
rating: 4.5,
inStock: false,
tags: ['seasonal'],
},
{
key: 'p10',
name: 'Wool Sweater',
description: 'Merino wool sweater',
category: 'Clothing',
brand: 'EcoWear',
price: 89.99,
rating: 4.6,
inStock: true,
tags: ['eco-friendly'],
},
// Home & Garden
{
key: 'p11',
name: 'Coffee Maker',
description: 'Programmable coffee maker',
category: 'Home & Garden',
brand: 'BrewMaster',
price: 99.99,
rating: 4.4,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p12',
name: 'Plant Pot Set',
description: 'Ceramic pot set of 3',
category: 'Home & Garden',
brand: 'GreenThumb',
price: 34.99,
rating: 4.1,
inStock: true,
tags: ['sale'],
},
{
key: 'p13',
name: 'LED Desk Lamp',
description: 'Adjustable LED lamp',
category: 'Home & Garden',
brand: 'LightWorks',
price: 59.99,
rating: 4.7,
inStock: true,
tags: ['featured'],
},
{
key: 'p14',
name: 'Air Purifier',
description: 'HEPA air purifier',
category: 'Home & Garden',
brand: 'CleanAir',
price: 249.99,
rating: 4.5,
inStock: true,
tags: ['new'],
},
{
key: 'p15',
name: 'Kitchen Scale',
description: 'Digital kitchen scale',
category: 'Home & Garden',
brand: 'CookPro',
price: 24.99,
rating: 4.2,
inStock: true,
tags: [],
},
],
{ model: 'Product' },
);
await dcupl.init();
console.log('Product catalog initialized');Steps 4 to 13 add more code inside main(), each below the previous step.
Step 4: create the product list
Create a list that you will use for all queries:
// Create the main product list
const productList = dcupl.lists.create<Product>({ modelKey: 'Product' });
// Display initial stats
console.log('\n--- Catalog Overview ---');
console.log('Total products:', productList.catalog.query.count());Run the app:
npx tsx catalog.tsOutput:
Product catalog initialized
--- Catalog Overview ---
Total products: 15Step 5: build faceted navigation
Facets are the counts shown next to filter options. For example, "Electronics (5)" tells users there are 5 electronics products.
// Get facets for all filterable attributes
function displayFacets() {
console.log('\n--- Available Filters ---');
// Category facets
console.log('\nCategories:');
productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// Brand facets, sorted by name
console.log('\nBrands:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// In-stock facet
console.log('\nAvailability:');
productList.catalog.fn.facets({ attribute: 'inStock' }).forEach((f) => {
const label = f.value === true ? 'In Stock' : 'Out of Stock';
console.log(` [ ] ${label} (${f.size})`);
});
// Tags facets: one entry per tag (products with tags: [] add none)
console.log('\nTags:');
productList.catalog.fn.facets({ attribute: 'tags' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
}
displayFacets();Output:
--- Available Filters ---
Categories:
[ ] Electronics (5)
[ ] Clothing (5)
[ ] Home & Garden (5)
Brands:
[ ] BrewMaster (1)
[ ] CleanAir (1)
[ ] ConnectPro (1)
[ ] CookPro (1)
[ ] DenimCo (1)
[ ] EcoWear (2)
[ ] GreenThumb (1)
[ ] LightWorks (1)
[ ] OutdoorGear (1)
[ ] SportyFeet (1)
[ ] TechCorp (2)
[ ] TypeMaster (1)
[ ] ViewTech (1)
Availability:
[ ] In Stock (13)
[ ] Out of Stock (2)
Tags:
[ ] featured (3)
[ ] new (3)
[ ] bestseller (3)
[ ] popular (2)
[ ] sale (2)
[ ] eco-friendly (2)
[ ] seasonal (1)Facet values come in the order dcupl first meets them. Pass sort ('value-asc', 'value-desc', 'size-desc' or 'size-asc') for a fixed order, as the brand facet does. Products with tags: [] add no entry: an empty array has no values to count.
Step 6: filter by category
Now simulate a user selecting "Electronics":
// User selects "Electronics" category
console.log('\n--- User selects: Electronics ---');
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// Show filtered products
console.log('\nFiltered products:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price} (${p.brand})`);
});
// The category facet keeps every category and marks the selected one
console.log('\nCategories:');
productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
console.log(` ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});
// Facets of other attributes count only the electronics
console.log('\nBrands in Electronics:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});Output:
--- User selects: Electronics ---
Filtered products:
Laptop Pro 15 - $1299.99 (TechCorp)
Wireless Mouse - $49.99 (TechCorp)
USB-C Hub - $79.99 (ConnectPro)
Mechanical Keyboard - $149.99 (TypeMaster)
Monitor 27" - $449.99 (ViewTech)
Categories:
[x] Electronics (5)
[ ] Clothing (5)
[ ] Home & Garden (5)
Brands in Electronics:
[ ] ConnectPro (1)
[ ] TechCorp (2)
[ ] TypeMaster (1)
[ ] ViewTech (1)Notice how the brand facet now counts only electronics products. The category facet still lists all three categories with their full counts, and selected: true marks Electronics: when counting, a facet leaves out the conditions on its own attribute, so users can switch to or add another category.
addCondition stores the condition in a query group keyed by its attribute (category), and that is the group facets() leaves out by default. If you put a selection into a group with a custom key, pass that key as excludeGroup to facets().
Step 7: add price range filter
Add a price range filter on top of the category filter. Conditions that addCondition adds on the same attribute are combined with OR (price >= 50 OR price <= 200 would match every product), so put both bounds into one 'and' group keyed price:
// User adds price filter: $50 - $200
console.log('\n--- User adds: Price $50 - $200 ---');
productList.catalog.query.addGroup({
groupKey: 'price',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 50 },
{ attribute: 'price', operator: 'lte', value: 200 },
],
});
console.log('\nFiltered products (Electronics, $50-$200):');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
console.log('\nMatching count:', productList.catalog.query.count());Output:
--- User adds: Price $50 - $200 ---
Filtered products (Electronics, $50-$200):
USB-C Hub - $79.99
Mechanical Keyboard - $149.99
Matching count: 2Step 8: filter by multiple values
Allow users to select multiple brands. First, clear filters and start fresh:
// Clear all filters
productList.catalog.query.clear();
console.log('\n--- New search: Multiple brands ---');
// Filter to show products from TechCorp OR EcoWear
// Use 'in' operator for multiple values
productList.catalog.query.addCondition({
attribute: 'brand',
operator: 'in',
value: ['TechCorp', 'EcoWear'],
});
console.log('\nProducts from TechCorp or EcoWear:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - ${p.brand}`);
});Output:
--- New search: Multiple brands ---
Products from TechCorp or EcoWear:
Laptop Pro 15 - TechCorp
Wireless Mouse - TechCorp
Cotton T-Shirt - EcoWear
Wool Sweater - EcoWearStep 9: filter by tags
Filter products that have specific tags:
productList.catalog.query.clear();
console.log('\n--- Filter by tag: featured ---');
// On an array property, eq matches when any element equals the value
productList.catalog.query.addCondition({
attribute: 'tags',
operator: 'eq',
value: 'featured',
});
console.log('\nFeatured products:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
// Require a second tag: two conditions on 'tags' would be combined with OR,
// so replace the 'tags' group with one 'and' group
console.log('\n--- Must also be tagged: new ---');
productList.catalog.query.removeGroup('tags');
productList.catalog.query.addGroup({
groupKey: 'tags',
groupType: 'and',
queries: [
{ attribute: 'tags', operator: 'eq', value: 'featured' },
{ attribute: 'tags', operator: 'eq', value: 'new' },
],
});
console.log('\nFeatured AND new:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - ${p.tags.join(', ')}`);
});Output:
--- Filter by tag: featured ---
Featured products:
Laptop Pro 15 - $1299.99
Monitor 27" - $449.99
LED Desk Lamp - $59.99
--- Must also be tagged: new ---
Featured AND new:
Laptop Pro 15 - featured, newStep 10: get price statistics
Show price range and average for filtered results:
productList.catalog.query.clear();
// Filter to Electronics
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// Get price statistics: `types` selects what to calculate
const priceStats = productList.catalog.fn.aggregate({
attribute: 'price',
types: ['min', 'max', 'avg'],
});
// min, max and avg are missing when no product matches, hence the defaults
const { min = 0, max = 0, avg = 0 } = priceStats;
console.log('\n--- Electronics Price Statistics ---');
console.log(` Min: $${min.toFixed(2)}`);
console.log(` Max: $${max.toFixed(2)}`);
console.log(` Average: $${avg.toFixed(2)}`);Output:
--- Electronics Price Statistics ---
Min: $49.99
Max: $1299.99
Average: $405.99Step 11: sort products
Add sorting options for the catalog:
productList.catalog.query.clear();
console.log('\n--- Sorting Examples ---');
// Sort by price (low to high)
console.log('\nBy price (low to high):');
productList.catalog.query
.items({
sort: { attributes: ['price'], order: ['ASC'] },
count: 5,
})
.forEach((p) => {
console.log(` $${p.price} - ${p.name}`);
});
// Sort by rating (high to low)
console.log('\nBy rating (high to low):');
productList.catalog.query
.items({
sort: { attributes: ['rating'], order: ['DESC'] },
count: 5,
})
.forEach((p) => {
console.log(` ${p.rating} stars - ${p.name}`);
});
// Sort by multiple attributes: category, then price
console.log('\nBy category, then price:');
productList.catalog.query
.items({
sort: { attributes: ['category', 'price'], order: ['ASC', 'ASC'] },
})
.forEach((p) => {
console.log(` ${p.category} | $${p.price} - ${p.name}`);
});Output:
--- Sorting Examples ---
By price (low to high):
$24.99 - Kitchen Scale
$29.99 - Cotton T-Shirt
$34.99 - Plant Pot Set
$49.99 - Wireless Mouse
$59.99 - LED Desk Lamp
By rating (high to low):
4.8 stars - Laptop Pro 15
4.8 stars - Running Shoes
4.7 stars - Mechanical Keyboard
4.7 stars - LED Desk Lamp
4.6 stars - Monitor 27"
By category, then price:
Clothing | $29.99 - Cotton T-Shirt
Clothing | $79.99 - Denim Jeans
Clothing | $89.99 - Wool Sweater
Clothing | $129.99 - Running Shoes
Clothing | $199.99 - Winter Jacket
Electronics | $49.99 - Wireless Mouse
Electronics | $79.99 - USB-C Hub
Electronics | $149.99 - Mechanical Keyboard
Electronics | $449.99 - Monitor 27"
Electronics | $1299.99 - Laptop Pro 15
Home & Garden | $24.99 - Kitchen Scale
Home & Garden | $34.99 - Plant Pot Set
Home & Garden | $59.99 - LED Desk Lamp
Home & Garden | $99.99 - Coffee Maker
Home & Garden | $249.99 - Air PurifierStep 12: combine everything
Here is a realistic filter scenario combining multiple conditions:
productList.catalog.query.clear();
console.log('\n--- Complete Filter Example ---');
console.log('Filters: Clothing OR Home & Garden, In Stock, Under $100');
// Multiple categories
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'in',
value: ['Clothing', 'Home & Garden'],
});
// Must be in stock
productList.catalog.query.addCondition({
attribute: 'inStock',
operator: 'eq',
value: true,
});
// Price under $100
productList.catalog.query.addCondition({
attribute: 'price',
operator: 'lt',
value: 100,
});
// Display results with sorting
const results = productList.catalog.query.items({
sort: { attributes: ['price'], order: ['ASC'] },
});
console.log(`\nFound ${results.length} products:\n`);
results.forEach((p) => {
console.log(` ${p.name}`);
console.log(` Category: ${p.category} | Brand: ${p.brand}`);
console.log(` Price: $${p.price} | Rating: ${p.rating} stars`);
console.log('');
});
// Show remaining filter options
console.log('Brands available in filtered results:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});Output:
--- Complete Filter Example ---
Filters: Clothing OR Home & Garden, In Stock, Under $100
Found 7 products:
Kitchen Scale
Category: Home & Garden | Brand: CookPro
Price: $24.99 | Rating: 4.2 stars
Cotton T-Shirt
Category: Clothing | Brand: EcoWear
Price: $29.99 | Rating: 4.4 stars
Plant Pot Set
Category: Home & Garden | Brand: GreenThumb
Price: $34.99 | Rating: 4.1 stars
LED Desk Lamp
Category: Home & Garden | Brand: LightWorks
Price: $59.99 | Rating: 4.7 stars
Denim Jeans
Category: Clothing | Brand: DenimCo
Price: $79.99 | Rating: 4.3 stars
Wool Sweater
Category: Clothing | Brand: EcoWear
Price: $89.99 | Rating: 4.6 stars
Coffee Maker
Category: Home & Garden | Brand: BrewMaster
Price: $99.99 | Rating: 4.4 stars
Brands available in filtered results:
[ ] BrewMaster (1)
[ ] CookPro (1)
[ ] DenimCo (1)
[ ] EcoWear (2)
[ ] GreenThumb (1)
[ ] LightWorks (1)For booleans, eq matches the exact value. The isTruthy operator matches any truthy value instead (for example 1 or a non-empty string), and with value: false any falsy one.
Step 13: build a reusable filter manager
A real filter UI keeps the user's selections and rebuilds the query whenever one changes. The FacetedSearch class below does that: one 'and' group per attribute, keyed by the attribute, so a minimum and a maximum on price must both hold and every facet still leaves out its own attribute.
Change the import at the top of catalog.ts to also import the DcuplList type:
import { Dcupl, type DcuplList } from '@dcupl/core';Add the class at the top level of catalog.ts, below the Product type (not inside main()):
type FilterOperator = 'eq' | 'in' | 'gte' | 'lte';
interface Selection {
attribute: string;
values: Array<string | number | boolean>;
operator: FilterOperator;
}
// Keeps the user's selections and turns them into the list's query
class FacetedSearch {
private selections = new Map<string, Selection>();
constructor(private list: DcuplList<Product>) {}
// Select values of an attribute (an empty array clears it)
setFacet(attribute: string, values: Array<string | number | boolean>) {
if (values.length === 0) {
this.selections.delete(attribute);
} else {
this.selections.set(attribute, { attribute, values, operator: 'in' });
}
this.applyFilters();
}
// Set a range on a numeric attribute (leave a bound out to drop it)
setRange(attribute: string, min?: number, max?: number) {
this.selections.delete(`${attribute}:min`);
this.selections.delete(`${attribute}:max`);
if (min !== undefined) this.selections.set(`${attribute}:min`, { attribute, values: [min], operator: 'gte' });
if (max !== undefined) this.selections.set(`${attribute}:max`, { attribute, values: [max], operator: 'lte' });
this.applyFilters();
}
clearAll() {
this.selections.clear();
this.list.catalog.query.clear();
}
private applyFilters() {
this.list.catalog.query.clear();
for (const selection of this.selections.values()) {
// One value: eq (or the range operator). Several values: in
const condition =
selection.values.length === 1
? {
attribute: selection.attribute,
operator: selection.operator === 'in' ? 'eq' : selection.operator,
value: selection.values[0],
}
: { attribute: selection.attribute, operator: 'in' as const, value: selection.values };
// One 'and' group per attribute, keyed by the attribute: a min and a max on
// 'price' must both hold, and a facet leaves its own attribute's group out
this.list.catalog.query.addGroup({
groupKey: selection.attribute,
groupType: 'and',
queries: [condition],
});
}
}
getResults() {
return this.list.catalog.query.items();
}
getCount() {
return this.list.catalog.query.count();
}
getFacets(attribute: string) {
return this.list.catalog.fn.facets({ attribute });
}
getActiveFilters() {
return [...this.selections.values()].map((s) =>
s.operator === 'in' ? `${s.attribute}: ${s.values.join(', ')}` : `${s.attribute} ${s.operator} ${s.values[0]}`,
);
}
}Then use it at the end of main():
// Reset and demonstrate the reusable class
productList.catalog.query.clear();
console.log('\n--- Using FacetedSearch ---');
const search = new FacetedSearch(productList);
// User selects a category, then two brands
search.setFacet('category', ['Electronics']);
console.log(`\nAfter selecting Electronics: ${search.getCount()} results`);
search.setFacet('brand', ['TechCorp', 'ViewTech']);
console.log(`After selecting TechCorp and ViewTech: ${search.getCount()} results`);
// The brand facet keeps its unselected values and marks the selected ones
console.log('\nBrand options:');
search.getFacets('brand').forEach((f) => {
console.log(` ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});
// User sets a price range
search.setRange('price', 50, 500);
console.log(`\nAfter price $50 - $500: ${search.getCount()} results`);
console.log('Active filters:', search.getActiveFilters().join('; '));
search.getResults().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
// Clear and start over
search.clearAll();
console.log(`\nAfter clearing all: ${search.getCount()} results`);Output:
--- Using FacetedSearch ---
After selecting Electronics: 5 results
After selecting TechCorp and ViewTech: 3 results
Brand options:
[x] TechCorp (2)
[ ] ConnectPro (1)
[ ] TypeMaster (1)
[x] ViewTech (1)
After price $50 - $500: 1 results
Active filters: category: Electronics; brand: TechCorp, ViewTech; price gte 50; price lte 500
Monitor 27" - $449.99
After clearing all: 15 resultsComplete code
Here is the complete product catalog application, Steps 1 to 13 in one file:
import { Dcupl, type DcuplList } from '@dcupl/core';
type Product = {
key: string;
name: string;
description: string;
category: string;
brand: string;
price: number;
rating: number;
inStock: boolean;
tags: string[];
};
type FilterOperator = 'eq' | 'in' | 'gte' | 'lte';
interface Selection {
attribute: string;
values: Array<string | number | boolean>;
operator: FilterOperator;
}
// Keeps the user's selections and turns them into the list's query
class FacetedSearch {
private selections = new Map<string, Selection>();
constructor(private list: DcuplList<Product>) {}
// Select values of an attribute (an empty array clears it)
setFacet(attribute: string, values: Array<string | number | boolean>) {
if (values.length === 0) {
this.selections.delete(attribute);
} else {
this.selections.set(attribute, { attribute, values, operator: 'in' });
}
this.applyFilters();
}
// Set a range on a numeric attribute (leave a bound out to drop it)
setRange(attribute: string, min?: number, max?: number) {
this.selections.delete(`${attribute}:min`);
this.selections.delete(`${attribute}:max`);
if (min !== undefined) this.selections.set(`${attribute}:min`, { attribute, values: [min], operator: 'gte' });
if (max !== undefined) this.selections.set(`${attribute}:max`, { attribute, values: [max], operator: 'lte' });
this.applyFilters();
}
clearAll() {
this.selections.clear();
this.list.catalog.query.clear();
}
private applyFilters() {
this.list.catalog.query.clear();
for (const selection of this.selections.values()) {
// One value: eq (or the range operator). Several values: in
const condition =
selection.values.length === 1
? {
attribute: selection.attribute,
operator: selection.operator === 'in' ? 'eq' : selection.operator,
value: selection.values[0],
}
: { attribute: selection.attribute, operator: 'in' as const, value: selection.values };
// One 'and' group per attribute, keyed by the attribute: a min and a max on
// 'price' must both hold, and a facet leaves its own attribute's group out
this.list.catalog.query.addGroup({
groupKey: selection.attribute,
groupType: 'and',
queries: [condition],
});
}
}
getResults() {
return this.list.catalog.query.items();
}
getCount() {
return this.list.catalog.query.count();
}
getFacets(attribute: string) {
return this.list.catalog.fn.facets({ attribute });
}
getActiveFilters() {
return [...this.selections.values()].map((s) =>
s.operator === 'in' ? `${s.attribute}: ${s.values.join(', ')}` : `${s.attribute} ${s.operator} ${s.values[0]}`,
);
}
}
async function main() {
const dcupl = new Dcupl();
// Define the Product model with filterable properties
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'description', type: 'string' },
{ key: 'category', type: 'string' },
{ key: 'brand', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'rating', type: 'float' },
{ key: 'inStock', type: 'boolean' },
{ key: 'tags', type: 'Array<string>' },
],
});
// Load product data
dcupl.data.set(
[
// Electronics
{
key: 'p1',
name: 'Laptop Pro 15',
description: 'High-performance laptop',
category: 'Electronics',
brand: 'TechCorp',
price: 1299.99,
rating: 4.8,
inStock: true,
tags: ['featured', 'new'],
},
{
key: 'p2',
name: 'Wireless Mouse',
description: 'Ergonomic wireless mouse',
category: 'Electronics',
brand: 'TechCorp',
price: 49.99,
rating: 4.5,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p3',
name: 'USB-C Hub',
description: '7-in-1 USB-C hub',
category: 'Electronics',
brand: 'ConnectPro',
price: 79.99,
rating: 4.2,
inStock: true,
tags: [],
},
{
key: 'p4',
name: 'Mechanical Keyboard',
description: 'RGB mechanical keyboard',
category: 'Electronics',
brand: 'TypeMaster',
price: 149.99,
rating: 4.7,
inStock: false,
tags: ['popular'],
},
{
key: 'p5',
name: 'Monitor 27"',
description: '4K IPS monitor',
category: 'Electronics',
brand: 'ViewTech',
price: 449.99,
rating: 4.6,
inStock: true,
tags: ['featured'],
},
// Clothing
{
key: 'p6',
name: 'Cotton T-Shirt',
description: '100% organic cotton',
category: 'Clothing',
brand: 'EcoWear',
price: 29.99,
rating: 4.4,
inStock: true,
tags: ['sale', 'eco-friendly'],
},
{
key: 'p7',
name: 'Denim Jeans',
description: 'Classic fit jeans',
category: 'Clothing',
brand: 'DenimCo',
price: 79.99,
rating: 4.3,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p8',
name: 'Running Shoes',
description: 'Lightweight running shoes',
category: 'Clothing',
brand: 'SportyFeet',
price: 129.99,
rating: 4.8,
inStock: true,
tags: ['new', 'popular'],
},
{
key: 'p9',
name: 'Winter Jacket',
description: 'Waterproof winter jacket',
category: 'Clothing',
brand: 'OutdoorGear',
price: 199.99,
rating: 4.5,
inStock: false,
tags: ['seasonal'],
},
{
key: 'p10',
name: 'Wool Sweater',
description: 'Merino wool sweater',
category: 'Clothing',
brand: 'EcoWear',
price: 89.99,
rating: 4.6,
inStock: true,
tags: ['eco-friendly'],
},
// Home & Garden
{
key: 'p11',
name: 'Coffee Maker',
description: 'Programmable coffee maker',
category: 'Home & Garden',
brand: 'BrewMaster',
price: 99.99,
rating: 4.4,
inStock: true,
tags: ['bestseller'],
},
{
key: 'p12',
name: 'Plant Pot Set',
description: 'Ceramic pot set of 3',
category: 'Home & Garden',
brand: 'GreenThumb',
price: 34.99,
rating: 4.1,
inStock: true,
tags: ['sale'],
},
{
key: 'p13',
name: 'LED Desk Lamp',
description: 'Adjustable LED lamp',
category: 'Home & Garden',
brand: 'LightWorks',
price: 59.99,
rating: 4.7,
inStock: true,
tags: ['featured'],
},
{
key: 'p14',
name: 'Air Purifier',
description: 'HEPA air purifier',
category: 'Home & Garden',
brand: 'CleanAir',
price: 249.99,
rating: 4.5,
inStock: true,
tags: ['new'],
},
{
key: 'p15',
name: 'Kitchen Scale',
description: 'Digital kitchen scale',
category: 'Home & Garden',
brand: 'CookPro',
price: 24.99,
rating: 4.2,
inStock: true,
tags: [],
},
],
{ model: 'Product' },
);
await dcupl.init();
console.log('Product catalog initialized');
// Step 4: Create the product list
// Create the main product list
const productList = dcupl.lists.create<Product>({ modelKey: 'Product' });
// Display initial stats
console.log('\n--- Catalog Overview ---');
console.log('Total products:', productList.catalog.query.count());
// Step 5: Build faceted navigation
// Get facets for all filterable attributes
function displayFacets() {
console.log('\n--- Available Filters ---');
// Category facets
console.log('\nCategories:');
productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// Brand facets, sorted by name
console.log('\nBrands:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// In-stock facet
console.log('\nAvailability:');
productList.catalog.fn.facets({ attribute: 'inStock' }).forEach((f) => {
const label = f.value === true ? 'In Stock' : 'Out of Stock';
console.log(` [ ] ${label} (${f.size})`);
});
// Tags facets: one entry per tag (products with tags: [] add none)
console.log('\nTags:');
productList.catalog.fn.facets({ attribute: 'tags' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
}
displayFacets();
// Step 6: Filter by category
// User selects "Electronics" category
console.log('\n--- User selects: Electronics ---');
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// Show filtered products
console.log('\nFiltered products:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price} (${p.brand})`);
});
// The category facet keeps every category and marks the selected one
console.log('\nCategories:');
productList.catalog.fn.facets({ attribute: 'category' }).forEach((f) => {
console.log(` ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});
// Facets of other attributes count only the electronics
console.log('\nBrands in Electronics:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// Step 7: Add a price range
// User adds price filter: $50 - $200
console.log('\n--- User adds: Price $50 - $200 ---');
productList.catalog.query.addGroup({
groupKey: 'price',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 50 },
{ attribute: 'price', operator: 'lte', value: 200 },
],
});
console.log('\nFiltered products (Electronics, $50-$200):');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
console.log('\nMatching count:', productList.catalog.query.count());
// Step 8: Filter by multiple values
// Clear all filters
productList.catalog.query.clear();
console.log('\n--- New search: Multiple brands ---');
// Filter to show products from TechCorp OR EcoWear
// Use 'in' operator for multiple values
productList.catalog.query.addCondition({
attribute: 'brand',
operator: 'in',
value: ['TechCorp', 'EcoWear'],
});
console.log('\nProducts from TechCorp or EcoWear:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - ${p.brand}`);
});
// Step 9: Filter by tags
productList.catalog.query.clear();
console.log('\n--- Filter by tag: featured ---');
// On an array property, eq matches when any element equals the value
productList.catalog.query.addCondition({
attribute: 'tags',
operator: 'eq',
value: 'featured',
});
console.log('\nFeatured products:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
// Require a second tag: two conditions on 'tags' would be combined with OR,
// so replace the 'tags' group with one 'and' group
console.log('\n--- Must also be tagged: new ---');
productList.catalog.query.removeGroup('tags');
productList.catalog.query.addGroup({
groupKey: 'tags',
groupType: 'and',
queries: [
{ attribute: 'tags', operator: 'eq', value: 'featured' },
{ attribute: 'tags', operator: 'eq', value: 'new' },
],
});
console.log('\nFeatured AND new:');
productList.catalog.query.items().forEach((p) => {
console.log(` ${p.name} - ${p.tags.join(', ')}`);
});
// Step 10: Get price statistics
productList.catalog.query.clear();
// Filter to Electronics
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// Get price statistics: `types` selects what to calculate
const priceStats = productList.catalog.fn.aggregate({
attribute: 'price',
types: ['min', 'max', 'avg'],
});
// min, max and avg are missing when no product matches, hence the defaults
const { min = 0, max = 0, avg = 0 } = priceStats;
console.log('\n--- Electronics Price Statistics ---');
console.log(` Min: $${min.toFixed(2)}`);
console.log(` Max: $${max.toFixed(2)}`);
console.log(` Average: $${avg.toFixed(2)}`);
// Step 11: Sort products
productList.catalog.query.clear();
console.log('\n--- Sorting Examples ---');
// Sort by price (low to high)
console.log('\nBy price (low to high):');
productList.catalog.query
.items({
sort: { attributes: ['price'], order: ['ASC'] },
count: 5,
})
.forEach((p) => {
console.log(` $${p.price} - ${p.name}`);
});
// Sort by rating (high to low)
console.log('\nBy rating (high to low):');
productList.catalog.query
.items({
sort: { attributes: ['rating'], order: ['DESC'] },
count: 5,
})
.forEach((p) => {
console.log(` ${p.rating} stars - ${p.name}`);
});
// Sort by multiple attributes: category, then price
console.log('\nBy category, then price:');
productList.catalog.query
.items({
sort: { attributes: ['category', 'price'], order: ['ASC', 'ASC'] },
})
.forEach((p) => {
console.log(` ${p.category} | $${p.price} - ${p.name}`);
});
// Step 12: Combine everything
productList.catalog.query.clear();
console.log('\n--- Complete Filter Example ---');
console.log('Filters: Clothing OR Home & Garden, In Stock, Under $100');
// Multiple categories
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'in',
value: ['Clothing', 'Home & Garden'],
});
// Must be in stock
productList.catalog.query.addCondition({
attribute: 'inStock',
operator: 'eq',
value: true,
});
// Price under $100
productList.catalog.query.addCondition({
attribute: 'price',
operator: 'lt',
value: 100,
});
// Display results with sorting
const results = productList.catalog.query.items({
sort: { attributes: ['price'], order: ['ASC'] },
});
console.log(`\nFound ${results.length} products:\n`);
results.forEach((p) => {
console.log(` ${p.name}`);
console.log(` Category: ${p.category} | Brand: ${p.brand}`);
console.log(` Price: $${p.price} | Rating: ${p.rating} stars`);
console.log('');
});
// Show remaining filter options
console.log('Brands available in filtered results:');
productList.catalog.fn.facets({ attribute: 'brand', sort: 'value-asc' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.size})`);
});
// Step 13: Use the FacetedSearch class
// Reset and demonstrate the reusable class
productList.catalog.query.clear();
console.log('\n--- Using FacetedSearch ---');
const search = new FacetedSearch(productList);
// User selects a category, then two brands
search.setFacet('category', ['Electronics']);
console.log(`\nAfter selecting Electronics: ${search.getCount()} results`);
search.setFacet('brand', ['TechCorp', 'ViewTech']);
console.log(`After selecting TechCorp and ViewTech: ${search.getCount()} results`);
// The brand facet keeps its unselected values and marks the selected ones
console.log('\nBrand options:');
search.getFacets('brand').forEach((f) => {
console.log(` ${f.selected ? '[x]' : '[ ]'} ${f.value} (${f.size})`);
});
// User sets a price range
search.setRange('price', 50, 500);
console.log(`\nAfter price $50 - $500: ${search.getCount()} results`);
console.log('Active filters:', search.getActiveFilters().join('; '));
search.getResults().forEach((p) => {
console.log(` ${p.name} - $${p.price}`);
});
// Clear and start over
search.clearAll();
console.log(`\nAfter clearing all: ${search.getCount()} results`);
}
main();What you learned
In this tutorial, you learned how to:
- Define models with various property types including arrays
- Load a realistic product dataset
- Build faceted navigation with dynamic counts, sorted facet values and
selectedflags - Combine filter conditions (AND across attributes, an
'and'group for a price range) - Filter by multiple values using the
inoperator - Filter by array properties (tags), and require several values with an
'and'group - Get price statistics and aggregations
- Sort results by single and multiple attributes
- Combine filters for complex queries
- Manage filter selections in a reusable class
Key patterns
Faceted Navigation
// Get counts for filter UI
const facets = list.catalog.fn.facets({ attribute: 'category' });
// Returns: [{ key: 'Electronics', value: 'Electronics', size: 5, selected: false, ... }, ...]Price Range Filter
// Both bounds in one 'and' group (addCondition would combine them with OR)
list.catalog.query.addGroup({
groupKey: 'price',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 50 },
{ attribute: 'price', operator: 'lte', value: 200 },
],
});Multiple Values
list.catalog.query.addCondition({
attribute: 'brand',
operator: 'in',
value: ['Brand1', 'Brand2', 'Brand3'],
});Clear and Reapply
list.catalog.query.clear(); // Remove all filters (and sort/paging options)
// Apply new filtersCommon issues
Facets show wrong counts
Facet counts respect the list's current query, except the conditions on the faceted attribute itself: with Electronics selected, the brand facet counts only electronics, while the category facet still counts every category. Values with zero matches are left out (excludeZeros defaults to true). If you want global counts, use a list without conditions.
Price range matches every product
addCondition combines conditions on the same attribute with OR. That suits multiple values (two eq conditions on category match either category, like one in condition), but a range needs both bounds to hold:
// Wrong: price >= 50 OR price <= 200 matches everything
query.addCondition({ attribute: 'price', operator: 'gte', value: 50 });
query.addCondition({ attribute: 'price', operator: 'lte', value: 200 });
// Correct: one 'and' group
query.addGroup({
groupKey: 'price',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 50 },
{ attribute: 'price', operator: 'lte', value: 200 },
],
});Array property filtering
When filtering array properties like tags, the eq operator checks if the array contains the value:
// Product has tags: ['new', 'featured']
query.addCondition({ attribute: 'tags', operator: 'eq', value: 'new' }); // Matches!To require several values, put them into one 'and' group (Step 9).