Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

zenstack-filter

npmlicensetypes

Headless, type-safe filter system for ZenStack 3. Derive filters from your schema, turn a user's active filters into a Prisma-style where input, and — optionally — persist them per scope with a small set of React hooks.

Community package — not affiliated with or endorsed by the ZenStack team.

Why

  • Schema-drivenFilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like "customer.organization.name" resolve relations automatically.
  • Type-safe — the model is bound before the config is checked, so every where value is narrowed to its filter's type. No any leaking into your query builders.
  • Headless — the core (build, generate, operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull in react.
  • Persistence is optional — use the pure buildWhere core on its own, or opt into per-scope persistence and saved views via hooks.

Table of contents

Install

npm install zenstack-filter

Peer dependencies (install the ones you use):

npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react
PeerRangeRequired for
@zenstackhq/orm^3types
@zenstackhq/schema^3schema helpers
react>=18hook entry points only (optional)

Concepts

A few terms recur throughout the API:

TermWhat it is
Filter setA named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks.
FilterDefA single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path.
ActiveFilterOne filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where.
whereThe Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }).
ViewA named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters.

The flow is always the same: define a set → collect active filters → build a where. Persistence and views are an optional layer the React hooks add on top of that core.

Quick start

import{createFilterSystem}from"zenstack-filter";import{buildWhere}from"zenstack-filter/build";import{schema}from"./zenstack/schema";// your generated ZenStack schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=filterFactory.Invoice({fields: {status: true,total: true,"customer.name": true,// relation path — resolved automatically},});// `activeFilters` is whatever the user has applied, e.g. from your UI:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });

Defining a filter set

A set is configured with fields (schema-derived) and virtual (custom filters that aren't a single field).

constinvoiceFilters=filterFactory.Invoice({// Optional stable key — required if you register two sets on the same model.key: "invoices",fields: {// `true` accepts the schema-inferred type, operators, and label.status: true,"customer.name": true,// …or override per field.total: {label: "Amount",type: "number"},priority: {type: "select",options: [{value: "low",label: "Low"},{value: "high",label: "High"},],},},// Virtual filters: not derived from one field. The `where` callback gets the// typed value and returns a fully-typed where input for the model.virtual: {overdue: {label: "Overdue",type: "select",options: [{value: "yes",label: "Overdue only"}],where: ()=>({dueDate: {lt: newDate().toISOString()},status: "OPEN"}),},},});

Filter types:text · number · select · multiselect · date · dateRange · choice. Each has a default operator and a set of valid operators — see Operators.

React hooks

The hooks add persistence on top of the core. They expect a ZenStack-generated client for your Filter (and FilterView) model — see Required schema.

useFilter — filter state + persistence

"use client";import{useFilter}from"zenstack-filter/useFilter";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={control.clearFilters}>Clear all</button>{/* render `invoices.data` */}</>);}

useFilter returns { where, control }:

FieldWhat it is
wherePrisma-style where input, ready for findMany.
control.availableFiltersFilterDef[] — what can be applied (filterable by filterAvailable).
control.activeFiltersActiveFilter[] — what is currently applied.
control.applyFilter(f)Persist (upsert) an active filter.
control.removeFilter(f)Remove a single active filter.
control.clearFilters()Remove all filters in the current bucket.
control.hasErrorstrue if the last persistence mutation failed.

Options: client (required), scope, enabled (gate persistence on/off), viewId (null working state — the default — or a view id to edit that view live), and filterAvailable (visibility predicate for the palette).

useFilterViews — saved views

import{useFilterViews}from"zenstack-filter/useFilterViews";const{ views, activeView, saveAsNewView, renameView, deleteView }=useFilterViews(invoiceFilters,{
viewClient,// ZenStack client for the FilterView modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { userId },
activeViewId,// the view currently open in your UI});

Point useFilter's viewId at activeView?.id to read and edit that view's rows directly — open views are edited live, not copied into the working state.

useFilterOptions — resolving option lists

useFilterOptions(def) resolves a filter's static array or loader options to { options, loading } for rendering a dropdown. For large datasets, supply a search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource provides createInfiniteSearch to wire infinite scroll onto an useInfiniteQuery-style hook.

The hooks persist values as-isapplyFilter writes whatever you hand it (it only skips filters flagged disabled). Validate in your editor UI before calling applyFilter.

Required schema

Persistence (the useFilter / useFilterViews hooks) reads and writes two models in your ZenStack schema: Filter and FilterView. The package owns a fixed set of columns — id, filterSet, identifier, operator, value, viewId, createdAt, updatedAt on Filter, and id, name, filterSet, createdAt, updatedAt on FilterView.

You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (buildWhere, createFilterSystem) without the persistence hooks, you don't need these models at all.

This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:

model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}

Generating the models

Instead of writing the models by hand, register the bundled plugin in your schema.zmodel. On the next zen generate it scaffolds the two models into a ZModel file you then import:

plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel'// relative to the schema; default: filter.zmodel
filterModel = 'Filter'// optional, default: Filter
viewModel = 'FilterView'// optional, default: FilterView
}
npx zen generate

Then import the generated file once:

import'./generated/filter'

The file is written only once — regeneration is skipped as soon as the models exist in your schema (whether scaffolded or hand-written). So after the first run the file is yours: add scope fields, tighten the @@allow policy, add relations, and re-run zen generate freely without losing changes. The scaffold ships with @@allow('all', true) and a TODO — restrict it before going to production.

OptionDefaultDescription
outputfilter.zmodelTarget file, relative to the schema directory
filterModelFilterName of the generated filter model
viewModelFilterViewName of the generated filter-view model

The plugin needs @zenstackhq/sdk and @zenstackhq/language — both come with a ZenStack 3 install. If you rename filterModel, pass the same name to createFilterSystem({ schema, filterModel: 'SavedFilter' }) — it flows through FilterSet into the hooks so the typed scope stays bound to the right model.

Operators

Each filter type ships with a default operator and a set of valid ones. Override them per field via fields.<name>.operators / defaultOperator, or import the helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor, operatorsByType, …).

TypeDefault operator
textcontains
numberequal
selectequal
multiselectcontains
dateequal
dateRangeequal
choiceequal

Available operators: equal, notEqual, greater, less, contains, notContains.

date and dateRange pass their values through to Prisma untouched — you decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …). Note that a date-only less/lte bound compares against midnight and excludes the rest of that day; pass an end-of-day or exclusive next-day bound to include the whole day.

Localizing operator labels (i18n)

Each generated FilterOperatorDef carries a value (a stable key like "equal") and a label. The defaults are English (is, contains, …). To translate them, pass translateOperator to createFilterSystem — it receives the operator key and returns the localized label:

import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});

The function is called once per operator while a set's FilterDefs are generated, so labels reflect the locale active at generation time. A live locale switch re-translates only once the defs are regenerated — fine for apps where the language is fixed at load (or a reload on switch is acceptable). Field-level operators you supply with an explicit label keep that label; only operators without one are passed through translateOperator.

Entry points

The core is framework-agnostic; the React pieces are isolated so non-React consumers never pull in react.

ImportWhat it is
zenstack-filtercreateFilterSystem, filterFactory
zenstack-filter/buildbuildWhere — active filters → where input
zenstack-filter/typesshared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …)
zenstack-filter/operatorsfilter operators + helpers
zenstack-filter/generategenerateFilterDefs / findFilterDef — list/look up a set's filters
zenstack-filter/useFilterReact: filter state + persistence
zenstack-filter/useFilterViewsReact: saved filter views
zenstack-filter/useFilterOptionsReact: async option loading
zenstack-filter/infiniteSourceReact: infinite option source
zenstack-filter/pluginZenStack plugin: scaffold the persistence models

Extending filter metadata

FilterMeta is empty by default. Augment it to attach project-specific keys (icons, groups, …) to field overrides and virtual filters — the package itself never reads them:

declare module "zenstack-filter/types"{interfaceFilterMeta{icon?: string;group?: string;}}

License

MIT © Cedrik Meis

About

Headless, type-safe filter system for ZenStack 3 — schema-driven FilterDefs, where-builder, persisted filters per scope.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages