Hooks

useDocyrusPivotFilter

Wire a Docyrus data source to a `<PivotFilter>` strip — auto-detects the field type, runs a pivot/aggregate query, transforms results into pill items, and emits filter rules ready to merge into a grid query.

Client Only

Installation

pnpm dlx @docyrus/cli add @docyrus/hooks-use-docyrus-pivot-filter
Required Packages(4 packages)
pnpm add @docyrus/app-utils @docyrus/api-client @tanstack/react-query date-fns

This hook is distributed as source. It expects an authenticated RestApiClient from @docyrus/api-client and a QueryClientProvider from @tanstack/react-query somewhere above your component tree.

Overview

useDocyrusPivotFilter is the Docyrus-backed companion to PivotFilter. It does for the pivot filter what useDocyrusKanban does for kanban boards:

  • loads the data source metadata (expand=enums)
  • auto-detects the field strategy from the field's type
  • builds a native Docyrus pivot query (columns: '<field>(id, name, icon, color)' for relations, columns: '<bucket>@<field>' for dates) — no pivot.matrix, no alias quirks
  • transforms the API response into ready-to-render PivotFilterItems
  • generates date buckets for date / dateTime fields
  • enriches list pills with icon / color directly from the join (no FE lookup for enums)
  • exposes a filterRule matching the active selection — feed it back into your data grid filter
  • provides a settings popover content node ready to slot into <PivotFilter>

For multi-pivot pages (e.g. "filter by status AND priority"), reach for <DocyrusPivotFilterGroup> below — it stacks N strips and handles bidirectional cross-filtering for free.

If your page already uses useDocyrusDataGrid, prefer its built-in pivotFilters option — the grid hook renders the strips above its toolbar automatically, AND-merges selections into the items query, and feeds the grid's own filter chain back into the pivots as activeFilters. No glue required:

const grid = useDocyrusDataGrid({
  client,
  appSlug: 'base',
  dataSourceSlug: 'task',
  pivotFilters: [
    { fieldSlug: 'status' },
    { fieldSlug: 'priority' }
  ]
});

// grid.toolbar already contains the pivot strips on top.

Supported field types

The hook reads field.type and picks a strategy:

Field typeStrategyPill contentFilter rule emitted
field-select, field-status, field-radioGrouplistid + name + icon + color from joined tenant_enum{ field, operator: '=', value: id }
field-relationlistid + name from related data source (icon / color silently dropped){ field, operator: '=', value: id }
field-userSelectuseravatar (from caller's user list) + name from joined tenant_user{ field, operator: '=', value: userId }
field-datedate (date-only)bucket label + range{ operator: 'between', value: [start+00:00, end+00:00] } — wall-clock dates pinned to UTC
field-dateTimedate (with time)bucket label + range{ operator: 'between', value: [startLocalISO, endLocalISO] } — preserves the user's local timezone offset
field-multiSelect, field-tagSelect, field-userMultiSelectunsupported—none (the backend's applyPivot currently rejects these as axis fields, so the query is skipped)

Empty (null) values are surfaced as a synthetic EMPTY pill that emits { operator: 'empty', value: null }.

field-date vs field-dateTime timezone handling

A field-date represents a calendar day with no time component. When the user picks the "13 May" bucket in Istanbul (UTC+3), they mean the calendar day 2026-05-13, not "2026-05-13T00:00:00+03:00" (which is 2026-05-12T21:00:00Z in UTC and could silently shift the filter to the previous day on a UTC backend). The hook handles this by stamping date-only bucket bounds as +00:00 — preserving the wall-clock numbers (2026-05-13T00:00:00+00:00).

For field-dateTime, the local offset is preserved (2026-05-13T00:00:00+03:00), matching the way Docyrus stores datetime values.

This matches the Vue KvPivotFilter behavior verbatim.

Usage

Status filter above a grid

'use client';

import { useState } from 'react';

import { useDocyrusAuth } from '@docyrus/signin';

import { PivotFilter } from '@docyrus/ui/components/pivot-filter';
import {
  useDocyrusDataGrid
} from '@docyrus/ui/library/hooks/use-docyrus-data-grid';
import {
  useDocyrusPivotFilter
} from '@docyrus/ui/library/hooks/use-docyrus-pivot-filter';

export function OrderListWithStatusFilter() {
  const { client } = useDocyrusAuth();

  if (!client) return null;

  const grid = useDocyrusDataGrid({
    client,
    appSlug: 'sales',
    dataSourceSlug: 'order'
  });

  const pivot = useDocyrusPivotFilter({
    client,
    appSlug: 'sales',
    dataSourceSlug: 'order',
    fieldSlug: 'status',
    defaultFilters: grid.resolvedListParams.filters
  });

  return (
    <div className="space-y-3">
      <PivotFilter {...pivot.pivotFilterProps} />
      {grid.toolbar}
      {grid.table}
    </div>
  );
}

pivot.filterRule carries the rule the grid needs to apply — merge it into your grid's filter to actually narrow the rows.

Date pivot (days of week)

const pivot = useDocyrusPivotFilter({
  client,
  appSlug: 'support',
  dataSourceSlug: 'ticket',
  fieldSlug: 'created_at',
  defaultDateBucket: 'days_of_week'
});

return (
  <>
    <PivotFilter {...pivot.pivotFilterProps} />
    {/* The settings popover lets the user switch to days_of_month, months_of_year, etc. */}
  </>
);

The hook always renders the date strip as Before · [buckets] · Upcoming · Not Set, so users can grab the long tail outside the current window without losing the count.

User filter (avatars)

const pivot = useDocyrusPivotFilter({
  client,
  appSlug: 'project',
  dataSourceSlug: 'task',
  fieldSlug: 'assignee'
});

For userMultiSelect the hook walks each user array and re-sums the aggregate per-user in the FE, so a record assigned to three users contributes the same stat to all three pills.

Custom calculation (sum / avg)

The default aggregate is count of id. To pivot by sum of an amount field:

const pivot = useDocyrusPivotFilter({
  client,
  appSlug: 'sales',
  dataSourceSlug: 'invoice',
  fieldSlug: 'status',
  calculation: { func: 'sum', field: 'amount' }
});

The settings popover surfaces all numeric / money / duration fields and lets the user pick the function (sum, avg, min, max). Hide the popover with disableSettings: true when you want to lock the calculation.

Feeding selection back to a grid

Combine filterRule with the grid's defaultFilters / filters:

const grid = useDocyrusDataGrid({
  client,
  appSlug: 'sales',
  dataSourceSlug: 'invoice',
  filters: pivot.filterRule
    ? { combinator: 'and', rules: pivot.filterRule }
    : undefined
});

When the user resets to the "All" pill, filterRule flips to null and the grid sees no filter.

Query payload

The hook uses Docyrus' native pivot syntax — columns: '<field>(...)' for relation joins or columns: '<bucket>@<field>' for date buckets. Combined with calculations, the backend auto-joins the related table and auto-groups by the joined keys. No pivot.matrix is involved.

// List strategy (field-select / field-status / field-radioGroup / field-relation)
{
  "columns": "status(id, name, icon, color)",
  "filters": { "combinator": "and", "rules": [/* defaultFilters + activeFilters + parentPivotFilters */] },
  "calculations": [{ "field": "id", "func": "count", "name": "COUNT_OF_id" }]
}
// Response: [{ status: { id, name, icon, color }, COUNT_OF_id }, ...]
// User strategy (field-userSelect)
{
  "columns": "assignee(id, name)",
  "filters": { ... },
  "calculations": [...]
}
// Response: [{ assignee: { id, name }, COUNT_OF_id }, ...]
// Date strategy (field-date / field-dateTime)
{
  "columns": "days_of_week@start_date",
  "filters": { ... },
  "calculations": [...]
}
// Response: [{ "days_of_week@start_date": "2026-05-13" | "OLDER" | "UPCOMING" | null, COUNT_OF_id }, ...]

The backend recognizes 8 date bucket functions natively: hours_of_today, days_of_week, days_of_month, weeks_of_month, weeks_of_quarter, months_of_quarter, months_of_year, quarters_of_year. Each emits a CASE expression with OLDER / UPCOMING sentinels for records outside the current window plus the formatted bucket key for in-range records, and the response groups by that result.

Filter merging

Three filter inputs are merged with combinator: and:

  1. defaultFilters — base filter, e.g. the saved view's filter.
  2. activeFilters — the user's current filter on the consuming surface (grid, map, etc.). Passing this lets the pill counts react when the user filters the grid.
  3. parentPivotFilters — for nested pivot filters, the rule contributed by the parent (e.g. when you stack a status pivot above a user pivot).

Pass null / undefined for any you don't need.

Selection reset on parent change: when parentPivotFilters changes, the current selection may point to a bucket that no longer exists in the new result set. The hook resets selectedItemId to null automatically — matching Vue KvPivotFilter's behavior. defaultFilters and activeFilters changes do not reset the selection (they just refetch counts).

Date buckets

Eight buckets are supported. Each generates a fixed window relative to the referenceDate option (defaults to new Date()):

BucketWindowPill labels
hours_of_todaytoday00 → 23
days_of_weekcurrent ISO weekMON … SUN
days_of_monthcurrent month1 … 31
weeks_of_monthcurrent monthW01 … W05
weeks_of_quartercurrent quarterW01 … W13
months_of_quartercurrent quarterJan, Feb, Mar (or matching quarter months)
months_of_yearcurrent yearJan … Dec
quarters_of_yearcurrent yearQ1 … Q4

The hook always prepends a BEFORE pill (records older than the window) and appends UPCOMING (newer) plus EMPTY (null date). BEFORE and UPCOMING emit < / > filter rules using the bucket boundary so the user can still drill into the long tail.

Change the bucket via the settings popover or programmatically via setDateBucket(bucket). Changing the bucket resets the selection.

Settings popover

The hook builds a ready-to-render settings popover (passed through pivotFilterProps.settingsContent). Contents depend on the field type:

  • For date fields — a radio group with the eight buckets above.
  • All field types — a "COUNT of records" preset button plus a function (sum / avg / min / max) + field selector populated from the data source's numeric / money / duration fields.

Pass disableSettings: true to hide the gear button entirely.

Loading & error

isLoading and isFetching mirror the data source + items queries combined. error returns the first failed query.

The component renders skeleton pills while the items query is in flight; the underlying data source metadata is reused across pivots that target the same appSlug + dataSourceSlug via the shared TanStack Query cache key (['docyrus', 'dataSource', appSlug, dataSourceSlug]).

API Reference

useDocyrusPivotFilter(options)

OptionTypeDefaultDescription
clientRestApiClient—Authenticated Docyrus API client.
appSlugstring—Target Docyrus app slug.
dataSourceSlugstring—Target data source slug.
fieldSlugstring—Slug of the field to pivot on.
calculationPivotFilterCalculation | nullnull (count of id)Aggregate function + field.
defaultDateBucketPivotFilterDateBucket'days_of_week'Initial bucket for date / dateTime fields.
defaultFiltersunknown—Base filter merged into every query (e.g. saved view).
activeFiltersunknown—Current user filter — drives pill counts.
parentPivotFiltersunknown—Filter contributed by a parent pivot filter (nested setups).
hideZeroValuesbooleanfalseForwarded to <PivotFilter>.
defaultSelectedItemIdstring | nullnullInitial selection.
referenceDateDatenew Date()Reference point used to compute date buckets.
disableSettingsbooleanfalseHide the gear popover entirely. The hook stays role-agnostic; gate the popover yourself from the caller (e.g. only pass disableSettings: !isAdmin).
onConfigurationChange(config: { dateBucket; calculation }) => void—Fires whenever the user changes the bucket or the calculation. Use it to persist the user's preference back to a saved view / preferences API. Matches Vue KvPivotFilter's configuration event.
staleTimenumber30000TanStack Query staleTime (ms).
enabledbooleantrueSkip fetching when false.
dataSourceExpandstring | false'enums'expand query param for the data-source schema fetch (so select/status fields carry their option metadata). Pass false/'' to omit expand for backends that don't support it — e.g. core/tenant system data sources.

Return value

PropertyTypeDescription
pivotFilterPropsPivotFilterPropsReady-to-spread props for <PivotFilter>. Wires items, selectedItemId, onSelect, total, loading, hideZeroValues, onRefresh, settingsContent.
itemsArray<PivotFilterItem>All items derived from the API response.
totalnumberAggregated stat across all items.
selectedItemIdstring | nullCurrently selected item id (null = "All").
setSelectedItemId(id) => voidUpdate the selection programmatically.
selectedItemPivotFilterItem | nullCurrently selected item (null for "All").
filterRuleArray<PivotFilterRule> | nullFilter rule(s) corresponding to the current selection — merge into your consumer's filter. null when "All" is selected.
fieldDataSourceField | nullResolved field definition.
fieldType'list' | 'user' | 'user-multi' | 'date' | 'unsupported'Strategy derived from field.type.
dateBucketPivotFilterDateBucketCurrent bucket.
setDateBucket(bucket) => voidChange the bucket (resets selection).
calculationPivotFilterCalculation | nullCurrent calculation.
setCalculation(calc) => voidChange the calculation.
reload() => voidRefetch metadata + items.
isLoadingbooleanFirst-fetch loading state.
isFetchingbooleanBackground refetch state.
errorError | nullFirst query error, if any.

DocyrusPivotFilterGroup

Stacks multiple <PivotFilter> strips above a consumer surface (data grid, calendar, map…) and wires bidirectional cross-filtering between them automatically — when the user picks an item in one strip, every other strip refetches its pill counts with the new selection applied as an activeFilters rule.

Because React hooks can't be called in a loop with a dynamic count, the group component delegates each field to a single-instance child that owns its own useDocyrusPivotFilter. The group lifts each row's filterRule into a shared map and feeds the other rows' rules back down as activeFilters — so consumer pages don't need to write any state-lift glue.

Usage

'use client';

import { useState } from 'react';

import { useDocyrusAuth } from '@docyrus/signin';

import { useDocyrusDataGrid } from '@docyrus/ui/library/hooks/use-docyrus-data-grid';
import {
  DocyrusPivotFilterGroup,
  type PivotFilterRule
} from '@docyrus/ui/library/hooks/use-docyrus-pivot-filter';

export function TasksPage() {
  const { client } = useDocyrusAuth();
  const [pivotRule, setPivotRule] = useState<Array<PivotFilterRule> | null>(null);

  if (!client) return null;

  const grid = useDocyrusDataGrid({
    client,
    appSlug: 'base',
    dataSourceSlug: 'task',
    listParams: pivotRule
      ? { filters: { combinator: 'and', rules: pivotRule } }
      : undefined
  });

  return (
    <>
      <DocyrusPivotFilterGroup
        client={client}
        appSlug="base"
        dataSourceSlug="task"
        fields={[
          { fieldSlug: 'status', totalLabel: 'All statuses' },
          { fieldSlug: 'priority', totalLabel: 'All priorities' },
          { fieldSlug: 'start_date', defaultDateBucket: 'days_of_week' }
        ]}
        onFilterRuleChange={setPivotRule} />

      {grid.toolbar}
      {grid.table}
    </>
  );
}

Clicking High in the priority strip narrows the status strip's pill counts to "only high-priority tasks per status", and vice-versa. The onFilterRuleChange callback fires whenever any pivot selection changes, with the AND-combined rule of every active selection (or null when nothing is selected).

API Reference

PropTypeDescription
clientRestApiClientAuthenticated Docyrus API client.
appSlugstringTarget app slug.
dataSourceSlugstringTarget data source slug.
fieldsReadonlyArray<DocyrusPivotFilterGroupField>One entry per stacked strip. Order = stack order top-to-bottom.
defaultFiltersunknownBase filter applied to every strip's items query (e.g. saved view).
activeFiltersunknownExternal filter applied to every strip's items query (e.g. the consumer grid's current toolbar filter).
onFilterRuleChange(rule: Array<PivotFilterRule> | null) => voidFired with the AND-combined rule of every pivot's current selection. null when no pivot has a selection.
verticalbooleanRender strips vertically (column rail).
compactbooleanCompact pill layout.
classNamestringWrapper class name.
staleTimenumberTanStack Query staleTime (ms). Forwarded to every strip.
dataSourceExpandstring | falseexpand query param for the shared data-source schema fetch (default 'enums'). Pass false/'' to omit expand for backends that don't support it. Forwarded to every strip.

DocyrusPivotFilterGroupField

FieldTypeDescription
fieldSlugstringSlug of the field to pivot on.
defaultDateBucketPivotFilterDateBucketInitial date bucket — only used for field-date / field-dateTime.
calculationPivotFilterCalculation | nullInitial calculation. null (default) is count of id.
totalLabelstringOverride the "All" pill label for this strip.
hideZeroValuesbooleanHide items whose stat is zero (except the currently selected one).
disableSettingsbooleanDisable the settings popover for this strip.
defaultSelectedItemIdstring | nullInitial selection.

Type Exports

TypeDescription
PivotFilterAggregateFunc'count' | 'sum' | 'avg' | 'min' | 'max'.
PivotFilterCalculation{ func: PivotFilterAggregateFunc; field: string }.
PivotFilterDateBucketOne of the eight bucket ids.
PivotFilterFieldType'list' | 'user' | 'user-multi' | 'date' | 'unsupported'.
PivotFilterRule{ field, operator, value } — emitted via filterRule.
UseDocyrusPivotFilterOptionsHook option shape.
UseDocyrusPivotFilterResultHook return shape.
DocyrusPivotFilterGroupFieldPer-pivot config shape used in <DocyrusPivotFilterGroup>.
DocyrusPivotFilterGroupPropsGroup component prop shape.

On this page