Paginated Data Loading
Paginated Row Filtering
Filter rows on the server before sending page slices to the client.
Note
This guide provides an overview of filtering with a focus on paginated server row data. For detailed guidance on filtering, refer to the Filtering section, starting with the Filter Text guide.
Filtering Paginated Rows
To filter rows on the server, define a filter model and include it in each data request.
Include the filter model in the queryKey of the useServerDataSource hook.
Whenever the filter model changes, the grid fetches a new set of rows from the server.
The following demo passes a custom filter model to the queryKey of the server data source. Click the
funnel icon in a column header to apply a filter.
Filter Paginated Rows
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import {4 Grid,5 usePiece,6 useServerDataSource,7 type DataResponse,8 type PieceWritable,9} from "@1771technologies/lytenyte-pro";10
11import { useCallback, useMemo, useRef, useState } from "react";12import { Server } from "./server.jsx";13import type { MovieData } from "./data.js";14import {15 GenreRenderer,16 Header,17 LinkRenderer,18 NameCellRenderer,19 RatingRenderer,20 ReleasedRenderer,21 TypeRenderer,22} from "./components.jsx";23import type { GridFilter } from "./types.js";24
25export interface GridSpec {26 readonly data: MovieData;27 readonly api: {28 readonly filterModel: PieceWritable<Record<string, GridFilter>>;29 };30}31
32const columns: Grid.Column<GridSpec>[] = [33 {34 id: "#",35 name: "",36 width: 30,37 field: "link",38 widthMin: 30,39 widthMax: 30,40 cellRenderer: LinkRenderer,41 headerRenderer: () => <div />,42 },43 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },44 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },45 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },46 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },47 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },48];49
50const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };51
52const pageSize = 10;53
54const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });55
56const headerHeight = 40;57const rowHeight = 40;58
59export default function PaginationDemo() {60 const [page, setPage] = useState(0);61 const [count, setCount] = useState<number | null>(null);62
63 const [filters, setFilters] = useState<Record<string, GridFilter>>({});64 const responseCache = useRef<Record<number, DataResponse[]>>({});65
66 const setFilterAndResetCache: typeof setFilters = useCallback((arg) => {67 setFilters(arg);68 responseCache.current = {};69 }, []);70
71 const model = usePiece(filters, setFilterAndResetCache);72
73 const ds = useServerDataSource({74 queryFn: async ({ requests, queryKey }) => {75 const page = queryKey[0];76 const filter = queryKey[1];77
78 if (responseCache.current[page]) {79 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));80 }81
82 const result = await Server(requests, page, pageSize, filter);83 responseCache.current[page] = result.pages;84
85 setCount(result.count);86
87 return result.pages;88 },89 queryKey: [page, filters] as const,90 });91
92 const isLoading = ds.isLoading.useValue();93 const apiExtension = useMemo(() => ({ filterModel: model }), [model]);94
95 return (96 <div>97 <div className="ln-grid" style={{ height: pageSize * rowHeight + headerHeight }}>98 <Grid99 rowSource={ds}100 apiExtension={apiExtension}101 columnBase={base}102 columns={columns}103 headerHeight={headerHeight}104 rowHeight={rowHeight}105 slotViewportOverlay={106 isLoading && (107 <div className="bg-ln-gray-20/40 top-(--ln-top-offset) absolute left-0 z-20 h-[calc(100%-var(--ln-top-offset))] w-full animate-pulse"></div>108 )109 }110 />111 </div>112 <div className="border-ln-border flex h-12 items-center justify-end gap-4 border-t px-4">113 {count && (114 <>115 <div className="text-sm tabular-nums">116 {formatter.format(page * pageSize + 1)}-{formatter.format(page * pageSize + pageSize)} of{" "}117 {formatter.format(count)}118 </div>119 <div className="flex items-center">120 <button121 data-ln-button="tertiary"122 data-ln-size="lg"123 className="rounded-e-none"124 onClick={() => setPage((prev) => Math.max(0, prev - 1))}125 >126 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">127 <path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path>128 </svg>129 </button>130 <button131 data-ln-button="tertiary"132 data-ln-size="lg"133 className="rounded-s-none border-s-0"134 onClick={() => setPage((prev) => Math.min(Math.ceil(count / pageSize) - 1, prev + 1))}135 >136 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">137 <path d="M181.66,133.66l-80,80A8,8,0,0,1,88,208V48a8,8,0,0,1,13.66-5.66l80,80A8,8,0,0,1,181.66,133.66Z"></path>138 </svg>139 </button>140 </div>141 </>142 )}143 </div>144 </div>145 );146}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 { Popover } from "@1771technologies/lytenyte-pro/components";9import { TextFilterControl } from "./filter.js";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 <TextFilterControl 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.js";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: "less_than", label: "Less Than" },14 { id: "greater_than", label: "Greater Than" },15 { id: "contains", label: "Contains" },16];17
18const dateOptions = [19 { id: "before", label: "Before" },20 { id: "after", label: "After" },21];22
23type DeepPartial<T> = T extends object24 ? {25 -readonly [P in keyof T]?: DeepPartial<T[P]>;26 }27 : T;28
29export function TextFilterControl({30 api,31 filter: initialFilter,32 column,33}: {34 api: Grid.API<GridSpec>;35 filter: GridFilter | null;36 column: Grid.Column<GridSpec>;37}) {38 const inputType = column.type === "date" ? "date" : "text";39 const options = column.type === "date" ? dateOptions : textOptions;40
41 const [filter, setFilter] = useState<DeepPartial<GridFilter> | null>(initialFilter);42
43 const canSubmit = filter?.value && filter.operator;44
45 const filterValue = options.find((x) => x.id === filter?.operator);46
47 const popoverControls = Popover.useControls();48 return (49 <form50 className="grid grid-cols-1 gap-2 md:grid-cols-2"51 onSubmit={(e) => {52 if (!canSubmit) return;53 e.preventDefault();54
55 api.filterModel.set((prev) => ({ ...prev, [column.id]: filter as GridFilter }));56 popoverControls.openChange(false);57 }}58 >59 <div className="text-ln-text hidden ps-2 text-sm md:block">Operator</div>60 <div className="text-ln-text hidden ps-2 text-sm md:block">Values</div>61
62 <SmartSelect63 options={options}64 value={filter ? (textOptions.find((x) => x.id === filter.operator) ?? null) : null}65 onOptionChange={(option) => {66 if (!option) return;67
68 setFilter((prev) => {69 return { ...prev, kind: inputType, operator: option.id } as GridFilter;70 });71 }}72 kind="basic"73 trigger={74 <SmartSelect.BasicTrigger75 type="button"76 data-ln-input77 className="flex min-w-40 cursor-pointer items-center justify-between"78 >79 <div>{filterValue?.label ?? "Select..."}</div>80 <div>81 <ChevronDownIcon />82 </div>83 </SmartSelect.BasicTrigger>84 }85 >86 {(p) => {87 if (p.option.id.startsWith("separator")) {88 return <div role="separator" className="bg-ln-gray-40 my-1 h-px w-full" />;89 }90
91 return (92 <SmartSelect.Option key={p.option.id} {...p} className="flex items-center justify-between">93 {p.option.label}94 {p.selected && <CheckIcon className="text-ln-primary-50" />}95 </SmartSelect.Option>96 );97 }}98 </SmartSelect>99
100 <div>101 <label>102 <span className="sr-only">Value for the first filter</span>103 <input104 data-ln-input105 value={filter?.value ?? ""}106 className={clsx("w-full", inputType === "date" && "text-xs")}107 type={inputType}108 onChange={(e) => {109 setFilter((prev) => {110 return { ...prev, kind: inputType, value: e.target.value } as GridFilter;111 });112 }}113 />114 </label>115 </div>116
117 <div className="flex items-center justify-between gap-4 md:col-span-2 md:grid md:grid-cols-subgrid">118 <div className="pt-2">119 <button120 data-ln-button="tertiary"121 data-ln-size="sm"122 type="button"123 className="hover:bg-ln-gray-30"124 onClick={() => popoverControls.openChange(false)}125 >126 Cancel127 </button>128 </div>129 <div className="flex justify-end gap-2 pt-2">130 <button131 data-ln-button="secondary"132 data-ln-size="sm"133 type="button"134 className="hover:bg-ln-bg-button-light"135 onClick={() => {136 api.filterModel.set((prev) => {137 const next = { ...prev };138 delete next[column.id];139
140 return next;141 });142 popoverControls.openChange(false);143 }}144 >145 Clear146 </button>147 <button data-ln-button="primary" data-ln-size="sm" disabled={!canSubmit}>148 Apply Filters149 </button>150 </div>151 </div>152 </form>153 );154}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data as movieData, type MovieData } from "./data.js";3import type { GridFilter } from "./types.js";4
5const sleep = () => new Promise((res) => setTimeout(res, 200));6
7export async function Server(8 reqs: DataRequest[],9 page: number,10 pageSize: number,11 filterModel: Record<string, GridFilter>,12) {13 // Simulate latency and server work.14 await sleep();15
16 const filters = Object.entries(filterModel);17
18 const data =19 filters.length === 020 ? movieData21 : movieData.filter((row) => {22 for (const [columnId, filter] of filters) {23 // Our logic here only handles a small subset of the possible filter functionality24 // for ease of implementation.25 if (filter.kind !== "text" && filter.kind !== "date") return false;26
27 const value = row[columnId as keyof MovieData];28 if (!value) return false;29
30 if (columnId === "imdb_rating") {31 const rating = value ? Math.round(Number.parseFloat(value.split("/")[0]) / 2) : "";32 const v = rating as number;33 const filterV =34 typeof filter.value === "string" ? Number.parseInt(filter.value) : (filter.value as number);35
36 if (filter.operator === "equals" && v !== filterV) return false;37 if (filter.operator === "not_equals" && v === filterV) return false;38 if (filter.operator === "less_than" && v >= filterV) return false;39 if (filter.operator === "greater_than" && v <= filterV) return false;40 continue;41 }42
43 if (columnId === "released_at") {44 const v = new Date(value);45 const filterV = new Date(filter.value as string);46
47 if (filter.operator === "before" && v >= filterV) return false;48 if (filter.operator === "after" && v <= filterV) return false;49 continue;50 }51
52 if ((filter.operator === "equals" || filter.operator === "not_equals") && columnId === "genre") {53 const genres = value54 .toLowerCase()55 .split(",")56 .map((x) => x.trim());57
58 if (59 filter.operator === "not_equals" &&60 genres.every((x) => x.toLowerCase() === filter.value.toLowerCase())61 )62 return false;63 if (64 filter.operator === "equals" &&65 genres.every((x) => x.toLowerCase() !== filter.value.toLowerCase())66 )67 return false;68 }69
70 if (71 columnId !== "genre" &&72 filter.operator === "equals" &&73 value.toLocaleLowerCase() !== filter.value.toLocaleLowerCase()74 )75 return false;76 if (77 columnId !== "genre" &&78 filter.operator === "not_equals" &&79 value.toLocaleLowerCase() === filter.value.toLocaleLowerCase()80 )81 return false;82 if (83 filter.operator === "less_than" &&84 value?.toLocaleLowerCase() >= filter.value?.toLocaleLowerCase()85 )86 return false;87 if (88 filter.operator === "greater_than" &&89 value?.toLocaleLowerCase() <= filter.value?.toLocaleLowerCase()90 )91 return false;92 if (93 filter.operator === "contains" &&94 !value.toLowerCase().includes(`${filter.value}`.toLowerCase())95 )96 return false;97 }98
99 return true;100 });101
102 const pageStart = page * pageSize;103
104 const pages = reqs.map((c) => {105 const pageData = data.slice(pageStart, pageStart + pageSize);106
107 return {108 asOfTime: Date.now(),109 data: pageData.map((x) => {110 return { kind: "leaf", id: x.uniq_id, data: x };111 }),112 start: c.start,113 end: c.end,114 kind: "center",115 path: c.path,116 size: Math.min(pageSize, pageData.length),117 } satisfies DataResponse;118 });119
120 return {121 pages,122 count: data.length,123 };124}1export type FilterStringOperator = "equals" | "not_equals" | "less_than" | "greater_than" | "contains";2
3export interface FilterString {4 readonly operator: FilterStringOperator;5 readonly value: string;6 readonly kind: "text";7}8
9export type FilterDateOperator = "before" | "after";10
11export interface FilterDate {12 readonly operator: FilterDateOperator;13 readonly value: string;14 readonly kind: "date";15}16
17export type GridFilter = FilterString | FilterDate;For more guidance on filtering rows on the server, see the Server Row Filtering guide.
Next Steps
- Paginated Row Sorting: Sort paginated rows on the server and request sorted slices.
- Paginated Rows: Load rows one page at a time.
- Server Row Filtering: Filter server-side rows using the server data source.
