Server Row Filtering
Send a custom filter model to the server to filter rows before returning them to the client.
Note
This guide covers filtering for the server row data source. For details on client-side filtering, start with the Filter Text guide.
Filtering Rows
To filter rows on the server, define a filter model and include it in each data request.
The useServerDataSource hook accepts a queryKey array. When any value in the array
changes, the server data source resets and fetches new rows.
Include the filter model in the queryKey dependency array. The data source passes the queryKey
value to the queryFn. The queryFn then sends the filter model to the server, which applies the filter
before returning the data.
The following demo passes a custom filter model to the queryKey property. Click the funnel
icon in the column header to apply a filter.
Server Row Filtering
1"use client";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, usePiece, useServerDataSource, type PieceWritable } from "@1771technologies/lytenyte-pro";4
5import { useMemo, useState } from "react";6import { Server } from "./server.js";7import type { MovieData } from "./data";8import {9 GenreRenderer,10 Header,11 LinkRenderer,12 NameCellRenderer,13 RatingRenderer,14 ReleasedRenderer,15 TypeRenderer,16} from "./components.js";17import type { GridFilter } from "./types.js";18
19export interface GridSpec {20 readonly data: MovieData;21 readonly api: {22 readonly filterModel: PieceWritable<Record<string, GridFilter>>;23 };24}25
26const columns: Grid.Column<GridSpec>[] = [27 {28 id: "#",29 name: "",30 width: 30,31 field: "link",32 widthMin: 30,33 widthMax: 30,34 cellRenderer: LinkRenderer,35 headerRenderer: () => <div />,36 },37 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },38 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },39 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },40 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },41 { id: "imdb_rating", type: "number", name: "Rating", width: 120, cellRenderer: RatingRenderer },42];43
44const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };45
46export default function ServerDataDemo() {47 const [filters, setFilters] = useState<Record<string, GridFilter>>({});48
49 const model = usePiece(filters, setFilters);50
51 const ds = useServerDataSource({52 queryFn: (params) => Server(params.requests, params.queryKey[0]),53 queryKey: [filters] as const,54 blockSize: 50,55 });56
57 const isLoading = ds.isLoading.useValue();58 const apiExtension = useMemo(() => ({ filterModel: model }), [model]);59
60 return (61 <div className="ln-grid" style={{ height: 500 }}>62 <Grid63 apiExtension={apiExtension}64 rowSource={ds}65 columns={columns}66 columnBase={base}67 styles={useMemo(() => {68 return { viewport: { style: { scrollbarGutter: "stable" } } };69 }, [])}70 slotViewportOverlay={71 isLoading && (72 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>73 )74 }75 />76 </div>77 );78}1import { format } from "date-fns";2import type { JSX } from "react";3import { Rating, ThinRoundedStar } from "@smastrom/react-rating";4import "@smastrom/react-rating/style.css";5import { Link1Icon } from "@radix-ui/react-icons";6import { type Grid } from "@1771technologies/lytenyte-pro";7import type { GridSpec } from "./demo";8import { FilterControl } from "./filter.jsx";9import { Popover } from "@1771technologies/lytenyte-pro/components";10
11function SkeletonLoading() {12 return (13 <div className="h-full w-full p-2">14 <div className="bg-ln-gray-20 h-full w-full animate-pulse rounded-xl"></div>15 </div>16 );17}18
19export const NameCellRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {20 if (params.row.loading && !params.row.data) return <SkeletonLoading />;21
22 const field = params.api.columnField(params.column, params.row) as string;23
24 return <div className="overflow-hidden text-ellipsis">{field}</div>;25};26
27export const ReleasedRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {28 if (params.row.loading && !params.row.data) return <SkeletonLoading />;29 const field = params.api.columnField(params.column, params.row) as string;30
31 const formatted = field ? format(field, "dd MMM yyyy") : "-";32
33 return <div>{formatted}</div>;34};35
36export const GenreRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {37 if (params.row.loading && !params.row.data) return <SkeletonLoading />;38 const field = params.api.columnField(params.column, params.row) as string;39
40 const splits = field ? field.split(",") : [];41
42 return (43 <div className="flex h-full w-full items-center gap-1">44 {splits.map((c) => {45 return (46 <div47 className="border-(--primary-200) text-(--primary-700) dark:text-(--primary-500) bg-(--primary-200)/20 rounded border p-1 px-2 text-xs"48 key={c}49 >50 {c}51 </div>52 );53 })}54 </div>55 );56};57
58const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {59 return (60 <svg61 xmlns="http://www.w3.org/2000/svg"62 width="20"63 height="20"64 fill="currentcolor"65 viewBox="0 0 256 256"66 {...props}67 >68 <path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path>69 </svg>70 );71};72
73const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {74 return (75 <svg76 xmlns="http://www.w3.org/2000/svg"77 width="20"78 height="20"79 fill="currentcolor"80 viewBox="0 0 256 256"81 {...props}82 >83 <path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path>84 </svg>85 );86};87
88export const TypeRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {89 if (params.row.loading && !params.row.data) return <SkeletonLoading />;90 const field = params.api.columnField(params.column, params.row) as string;91
92 const isMovie = field === "Movie";93 const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;94
95 return (96 <div className="flex h-full w-full items-center gap-2">97 <span className={isMovie ? "text-(--primary-500)" : "text-ln-primary-50"}>98 <Icon />99 </span>100 <span>{field}</span>101 </div>102 );103};104
105export const RatingRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {106 if (params.row.loading && !params.row.data) return <SkeletonLoading />;107 const field = params.api.columnField(params.column, params.row) as string;108 const rating = field ? Number.parseFloat(field.split("/")[0]) : null;109 if (rating == null || Number.isNaN(rating)) return "-";110
111 return (112 <div className="flex h-full w-full items-center">113 <Rating114 style={{ maxWidth: 100 }}115 halfFillMode="svg"116 value={Math.round(rating / 2)}117 itemStyles={{118 activeFillColor: "hsla(173, 78%, 34%, 1)",119 itemShapes: ThinRoundedStar,120 inactiveFillColor: "transparent",121 inactiveBoxBorderColor: "transparent",122 inactiveBoxColor: "transparent",123 inactiveStrokeColor: "transparent",124 }}125 readOnly126 />127 </div>128 );129};130
131export const LinkRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {132 if (params.row.loading && !params.row.data) return <SkeletonLoading />;133 const field = params.api.columnField(params.column, params.row) as string;134
135 return (136 <a href={field} className="text-(--primary-500)">137 <Link1Icon />138 </a>139 );140};141
142export function Header({ api, column }: Grid.T.HeaderParams<GridSpec>) {143 const label = column.name ?? column.id;144
145 const model = api.filterModel.useValue();146 const hasFilter = !!model[column.id];147 return (148 <div className="flex h-full w-full items-center justify-between">149 <div>{label}</div>150
151 <Popover>152 <Popover.Trigger data-ln-button="secondary" data-ln-icon data-ln-size="sm" className="relative">153 <div className="sr-only">Filter the {label}</div>154 <svg155 xmlns="http://www.w3.org/2000/svg"156 width="16"157 height="16"158 fill="currentcolor"159 viewBox="0 0 256 256"160 >161 <path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"></path>162 </svg>163
164 {hasFilter && <div className="bg-ln-primary-50 absolute right-px top-px size-2 rounded-full" />}165 </Popover.Trigger>166 <Popover.Container>167 <Popover.Arrow />168 <Popover.Title className="sr-only">Filter {label}</Popover.Title>169 <Popover.Description className="sr-only">Filter the text in the{label}</Popover.Description>170 <FilterControl column={column} filter={model[column.id] ?? null} api={api} />171 </Popover.Container>172 </Popover>173 </div>174 );175}1import "@1771technologies/lytenyte-pro/components.css";2import { type Grid } from "@1771technologies/lytenyte-pro";3import type { GridSpec } from "./demo";4import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";5import { useState } from "react";6import type { GridFilter } from "./types";7import clsx from "clsx";8import { Popover, SmartSelect } from "@1771technologies/lytenyte-pro/components";9
10const textOptions = [11 { id: "equals", label: "Equals" },12 { id: "not_equals", label: "Does Not Equal" },13 { id: "contains", label: "Contains" },14 { id: "not_contains", label: "Does Not Contain" },15];16
17const numberOptions = [18 { id: "equals", label: "Equals" },19 { id: "not_equals", label: "Does Not Equal" },20 { id: "less_than", label: "Less Than" },21 { id: "less_than_or_equal", label: "Less Than Or Equal To" },22 { id: "greater_than", label: "Greater Than" },23 { id: "greater_than_or_equal", label: "Greater Than Or Equal To" },24];25
26const dateOptions = [27 { id: "before", label: "Before" },28 { id: "after", label: "After" },29];30
31type DeepPartial<T> = T extends object32 ? {33 -readonly [P in keyof T]?: DeepPartial<T[P]>;34 }35 : T;36
37export function FilterControl({38 api,39 filter: initialFilter,40 column,41}: {42 api: Grid.API<GridSpec>;43 filter: GridFilter | null;44 column: Grid.Column<GridSpec>;45}) {46 const inputType = column.type === "date" ? "date" : column.type === "number" ? "number" : "text";47 const options =48 column.type === "date" ? dateOptions : column.type === "number" ? numberOptions : textOptions;49
50 const [filter, setFilter] = useState<DeepPartial<GridFilter> | null>(initialFilter);51
52 const canSubmit = filter?.value && filter.operator;53
54 const filterValue = options.find((x) => x.id === filter?.operator);55
56 const popoverControls = Popover.useControls();57 return (58 <form59 className="grid grid-cols-1 gap-2 md:grid-cols-2"60 onSubmit={(e) => {61 if (!canSubmit) return;62 e.preventDefault();63
64 api.filterModel.set((prev) => ({ ...prev, [column.id]: filter as GridFilter }));65 popoverControls.openChange(false);66 }}67 >68 <div className="text-ln-text hidden ps-2 text-sm md:block">Operator</div>69 <div className="text-ln-text hidden ps-2 text-sm md:block">Values</div>70
71 <SmartSelect72 options={options}73 value={filter ? (textOptions.find((x) => x.id === filter.operator) ?? null) : null}74 onOptionChange={(option) => {75 if (!option) return;76
77 setFilter((prev) => {78 return { ...prev, kind: inputType, operator: option.id } as GridFilter;79 });80 }}81 kind="basic"82 trigger={83 <SmartSelect.BasicTrigger84 type="button"85 data-ln-input86 className="flex min-w-40 cursor-pointer items-center justify-between"87 >88 <div>{filterValue?.label ?? "Select..."}</div>89 <div>90 <ChevronDownIcon />91 </div>92 </SmartSelect.BasicTrigger>93 }94 >95 {(p) => {96 if (p.option.id.startsWith("separator")) {97 return <div role="separator" className="bg-ln-gray-40 my-1 h-px w-full" />;98 }99
100 return (101 <SmartSelect.Option key={p.option.id} {...p} className="flex items-center justify-between">102 {p.option.label}103 {p.selected && <CheckIcon className="text-ln-primary-50" />}104 </SmartSelect.Option>105 );106 }}107 </SmartSelect>108
109 <div>110 <label>111 <span className="sr-only">Value for the first filter</span>112 <input113 data-ln-input114 value={filter?.value ?? ""}115 className={clsx("w-full", inputType === "date" && "text-xs")}116 type={inputType}117 onChange={(e) => {118 setFilter((prev) => {119 return {120 ...prev,121 kind: inputType,122 value:123 inputType === "number"124 ? e.target.value125 ? Number.parseFloat(e.target.value)126 : null127 : e.target.value,128 } as GridFilter;129 });130 }}131 />132 </label>133 </div>134
135 <div className="flex items-center justify-between gap-4 md:col-span-2 md:grid md:grid-cols-subgrid">136 <div className="pt-2">137 <button138 data-ln-button="tertiary"139 data-ln-size="sm"140 type="button"141 className="hover:bg-ln-gray-30"142 onClick={() => popoverControls.openChange(false)}143 >144 Cancel145 </button>146 </div>147 <div className="flex justify-end gap-2 pt-2">148 <button149 data-ln-button="secondary"150 data-ln-size="sm"151 type="button"152 className="hover:bg-ln-bg-button-light"153 onClick={() => {154 api.filterModel.set((prev) => {155 const next = { ...prev };156 delete next[column.id];157
158 return next;159 });160 popoverControls.openChange(false);161 }}162 >163 Clear164 </button>165 <button data-ln-button="primary" data-ln-size="sm" disabled={!canSubmit}>166 Apply Filters167 </button>168 </div>169 </div>170 </form>171 );172}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { MovieData } from "./data";3import { data as movieData } from "./data.js";4import type { GridFilter } from "./types.js";5
6const sleep = () => new Promise((res) => setTimeout(res, 200));7
8export async function Server(reqs: DataRequest[], filterModel: Record<string, GridFilter>) {9 // Simulate latency and server work.10 await sleep();11
12 const filters = Object.entries(filterModel);13
14 const data =15 filters.length === 016 ? movieData17 : movieData.filter((row) => {18 for (const [columnId, filter] of filters) {19 const value = row[columnId as keyof MovieData];20 if (!value) return false;21
22 if (columnId === "imdb_rating") {23 if (filter.kind !== "number") continue;24
25 const rating = value ? Math.round(Number.parseFloat(value.split("/")[0]) / 2) : "";26 const checkValue = rating as number;27
28 if (filter.operator === "equals" && checkValue !== filter.value) return false;29 if (filter.operator === "not_equals" && checkValue === filter.value) return false;30 if (filter.operator === "greater_than" && checkValue <= filter.value) return false;31 if (filter.operator === "greater_than_or_equal" && checkValue < filter.value) return false;32 if (filter.operator === "less_than" && checkValue >= filter.value) return false;33 if (filter.operator === "less_than_or_equal" && checkValue > filter.value) return false;34
35 continue;36 }37
38 if (columnId === "released_at") {39 const v = new Date(value);40 const filterV = new Date(filter.value as string);41
42 if (filter.operator === "before" && v >= filterV) return false;43 if (filter.operator === "after" && v <= filterV) return false;44 continue;45 }46
47 if (columnId === "genre" && filter.operator === "equals") {48 const genres = value49 .toLowerCase()50 .split(",")51 .map((x) => x.trim());52
53 if (genres.some((x) => x === String(filter.value).toLowerCase())) continue;54 return false;55 }56 if (columnId === "genre" && filter.operator === "not_equals") {57 const genres = value58 .toLowerCase()59 .split(",")60 .map((x) => x.trim());61
62 if (genres.every((x) => x !== String(filter.value).toLowerCase())) continue;63 return false;64 }65
66 if (filter.operator === "equals" && `${filter.value}`.toLowerCase() !== value.toLowerCase())67 return false;68 if (filter.operator === "not_equals" && `${filter.value}`.toLowerCase() === value.toLowerCase())69 return false;70
71 if (72 filter.operator === "contains" &&73 !value.toLowerCase().includes(`${filter.value}`.toLowerCase())74 )75 return false;76 if (77 filter.operator === "not_contains" &&78 value.toLowerCase().includes(`${filter.value}`.toLowerCase())79 ) {80 return false;81 }82 }83
84 return true;85 });86
87 return reqs.map((c) => {88 return {89 asOfTime: Date.now(),90 data: data.slice(c.start, c.end).map((x) => {91 return {92 kind: "leaf",93 id: x.uniq_id,94 data: x,95 };96 }),97 start: c.start,98 end: c.end,99 kind: "center",100 path: c.path,101 size: data.length,102 } satisfies DataResponse;103 });104}1export type FilterStringOperator = "equals" | "not_equals" | "not_contains" | "contains";2
3export interface FilterString {4 readonly operator: FilterStringOperator;5 readonly value: string;6 readonly kind: "text";7}8
9export type FilterNumberOperator =10 | "equals"11 | "not_equals"12 | "less_than"13 | "greater_than"14 | "less_than_or_equal"15 | "greater_than_or_equal";16export interface FilterNumber {17 readonly operator: FilterNumberOperator;18 readonly value: number;19 readonly kind: "number";20}21
22export type FilterDateOperator = "before" | "after";23
24export interface FilterDate {25 readonly operator: FilterDateOperator;26 readonly value: string;27 readonly kind: "date";28}29
30export type GridFilter = FilterString | FilterDate | FilterNumber;To filter rows, the demo creates a filter model and sends it to the server. The filter model
interface appears below. The server uses the GridFilter type to evaluate and filter rows.
1export type FilterStringOperator = "equals" | "not_equals" | "not_contains" | "contains";2
3export interface FilterString {4 readonly operator: FilterStringOperator;5 readonly value: string;6 readonly kind: "text";7}8
9export type FilterNumberOperator =10 | "equals"11 | "not_equals"12 | "less_than"13 | "greater_than"14 | "less_than_or_equal"15 | "greater_than_or_equal";16export interface FilterNumber {17 readonly operator: FilterNumberOperator;18 readonly value: number;19 readonly kind: "number";20}21
22export type FilterDateOperator = "before" | "after";23
24export interface FilterDate {25 readonly operator: FilterDateOperator;26 readonly value: string;27 readonly kind: "date";28}29
30export type GridFilter = FilterString | FilterDate | FilterNumber;To send the filter model to the server, add it to the queryKey as shown below.
The server then evaluates the filter model and returns only the matching rows.
1const [filters, setFilters] = useState<Record<string, GridFilter>>({});2
3const ds = useServerDataSource({4 queryFn: (params) => Server(params.requests, params.queryKey[0]),5 queryKey: [filters] as const,6 blockSize: 50,7});This is just one approach to server-side filtering. We recommend aligning your client-side filter model with your existing server format to maintain consistency.
Next Steps
- Server Row Grouping: Use the server data source to load and manage group slices.
- Server Row Sorting: Sort rows on the server using a defined sort model.
- Optimistic Loading: Pre-fetch data using optimistic loading to reduce perceived latency.
