Client Row Having Filters
Having filters exclude group rows and their children after the grid performs grouping. Apply these filters to all groups or target specific depths.
Having filters run after the grid creates row groups. If a filter excludes a group row, the grid also removes all associated child rows.
This guide covers having filters in the client data source, not filter types. For client-side filtering, see the Filter Text guide.
Info
Having filters derives from the SQL HAVING clause, which filters grouped results after aggregation. For conceptual background, refer to the PostgreSQL HAVING guide.
Applying Having Filters
Having filters target aggregated group rows in the client data source. Review the Client Row Grouping and Client Row Aggregations guides for prerequisite knowledge.
To apply a having filter, set having on the client data source to an array of having filter
functions (HavingFilterFn | null[ ]). The grid uses the function at each index to
filter group rows at that depth. Use null to skip filtering at a depth.
The demo demonstrates a HavingFilterFn utilizing the filterModel
provided by the grid API extension.
1export interface GridSpec {2 readonly data: BankData;3 readonly column: { agg: string; allowedAggs: string[] };4 readonly api: {5 filterModel: PieceWritable<FilterModel>;6 };7}The filter model lets you build a filter representation before generating the filter function from it. The demo uses a custom floating header row component that allows users to modify the model’s value.
In the demo, the having filter applies to the second row grouping level: Education.
For example, when you set the Balance filter to greater than $0, the grid removes
the Secondary row from the Administration group and the Primary row
from the Blue-Collar group.
Having Filter Function
172 collapsed lines
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { sum, uniq } from "es-toolkit";4import {5 computeField,6 Grid,7 useClientDataSource,8 usePiece,9 type PieceWritable,10} from "@1771technologies/lytenyte-pro";11import { twMerge } from "tailwind-merge";12import clsx, { type ClassValue } from "clsx";13import { useMemo, useState } from "react";14import { FloatingFilter } from "./filter.js";15import {16 AgeCell,17 CountryCell,18 CustomerRating,19 DateCell,20 DurationCell,21 NameCell,22 NumberCell,23 OverdueCell,24} from "./components.jsx";25import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";26import { CheckIcon } from "@radix-ui/react-icons";27import { Menu, RowGroupCell } from "@1771technologies/lytenyte-pro/components";28
29export type FilterModel = Record<30 string,31 { kind: "gt" | "lt" | "eq" | "neq" | "ge" | "le"; value: null | string }32>;33
34export interface GridSpec {35 readonly data: LoanDataItem;36 readonly column: { agg: string; allowedAggs: string[] };37 readonly api: {38 filterModel: PieceWritable<FilterModel>;39 };40}41
42const numberAllowed = ["first", "last", "count", "min", "max", "avg", "sum"];43const stringAllowed = ["first", "last", "count", "same"];44
45const initialColumns: Grid.Column<GridSpec>[] = [46 {47 name: "Loan Amount",48 id: "loanAmount",49 width: 150,50 type: "number",51 cellRenderer: NumberCell,52 agg: "sum",53 allowedAggs: numberAllowed,54 floatingCellRenderer: FloatingFilter,55 },56 {57 name: "Balance",58 id: "balance",59 type: "number",60 cellRenderer: NumberCell,61 floatingCellRenderer: FloatingFilter,62 agg: "sum",63 allowedAggs: numberAllowed,64 },65 {66 name: "Name",67 id: "name",68 cellRenderer: NameCell,69 width: 110,70 agg: "same",71 allowedAggs: stringAllowed,72 },73 {74 name: "Country",75 id: "country",76 width: 150,77 cellRenderer: CountryCell,78 agg: "same",79 allowedAggs: stringAllowed,80 },81 {82 name: "Customer Rating",83 id: "customerRating",84 type: "number",85 width: 175,86 cellRenderer: CustomerRating,87
88 agg: "sum",89 allowedAggs: numberAllowed,90 },91 {92 name: "Marital",93 id: "marital",94
95 agg: "same",96 allowedAggs: stringAllowed,97 },98 {99 name: "Education",100 id: "education",101 hide: true,102
103 agg: "same",104 allowedAggs: stringAllowed,105 },106 {107 name: "Job",108 id: "job",109 width: 120,110 hide: true,111
112 agg: "same",113 allowedAggs: stringAllowed,114 },115 {116 name: "Overdue",117 id: "overdue",118 cellRenderer: OverdueCell,119
120 agg: "same",121 allowedAggs: stringAllowed,122 },123 {124 name: "Duration",125 id: "duration",126 type: "number",127 cellRenderer: DurationCell,128
129 agg: "sum",130 allowedAggs: numberAllowed,131 },132 {133 name: "Date",134 id: "date",135 width: 110,136 cellRenderer: DateCell,137
138 agg: "same",139 allowedAggs: stringAllowed,140 },141 {142 name: "Age",143 id: "age",144 width: 80,145 type: "number",146 cellRenderer: AgeCell,147
148 agg: "sum",149 allowedAggs: numberAllowed,150 },151 {152 name: "Contact",153 id: "contact",154
155 agg: "same",156 allowedAggs: stringAllowed,157 },158];159
160const base: Grid.ColumnBase<GridSpec> = {161 width: 150,162 headerRenderer: HeaderCell,163
164 floatingCellRenderer: () => <div></div>,165};166
167const group: Grid.RowGroupColumn<GridSpec> = {168 cellRenderer: RowGroupCell,169 width: 200,170 pin: "start",171};172
173
174const avg: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {175 const values = data.map((x) => computeField<number>(field, x));176 return Math.round(sum(values) / values.length);177};178const sumAgg: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {179 const values = data.map((x) => computeField<number>(field, x));180 return sum(values);181};182const count: Grid.T.Aggregator<GridSpec["data"]> = (_, data) => {183 return data.length;184};185const max: Grid.T.Aggregator<GridSpec["data"]> = (f, data) => {186 return Math.max(...data.map((x) => computeField<number>(f, x)));187};188const min: Grid.T.Aggregator<GridSpec["data"]> = (f, data) => {189 return Math.min(...data.map((x) => computeField<number>(f, x)));190};191
192const same: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {193 const values = uniq(data.map((x) => computeField(field, x)));194 return values.length === 1 ? values[0] : null;195};196const first: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {197 return computeField(field, data[0]);198};199const last: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {200 return computeField(field, data.at(-1)!);201};202
203export default function ClientDemo() {204 const [columns, setColumns] = useState(initialColumns);205
206 const [filterModel, setFilterModel] = useState<FilterModel>({});207 const filterModel$ = usePiece(filterModel, setFilterModel);208
209 const apiExtension = useMemo(() => {210 return {211 filterModel: filterModel$,212 };213 }, [filterModel$]);214
215 const havingFilter = useMemo(() => {216 const model = Object.entries(filterModel).filter(([, f]) => {217 return f.kind && f.value && !Number.isNaN(Number.parseFloat(f.value));218 });219
220 if (!model.length) return;221
222 const fn = (row: Grid.T.RowGroup) => {223 for (const [key, f] of model) {224 const value = row.data[key] as number;225 const compare = Number.parseFloat(f.value!);226 if (value == null || typeof value !== "number") return false;227
228 if (f.kind === "eq" && value !== compare) return false;229 else if (f.kind === "neq" && value === compare) return false;230 else if (f.kind === "ge" && value < compare) return false;231 else if (f.kind === "le" && value > compare) return false;232 else if (f.kind === "lt" && value >= compare) return false;233 else if (f.kind === "gt" && value <= compare) return false;234 }235
236 return true;237 };238
239 return [null, fn];240 }, [filterModel]);241
242 const aggModel = useMemo(() => {243 return columns.map((x) => ({ dim: x, fn: x.agg }));244 }, [columns]);245
246 const ds = useClientDataSource<GridSpec>({247 data: loanData,248 group: [{ id: "job" }, { id: "education" }],249 having: havingFilter,250 aggregate: aggModel,251 aggregateFns: {252 avg,253 count,254 first,255 last,256 sum: sumAgg,257 same,258 min,259 max,260 },261 rowGroupDefaultExpansion: 0,262 });263
264 return (265 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>266 <Grid267 apiExtension={apiExtension}268 rowSource={ds}269 columns={columns}270 columnBase={base}271 rowGroupColumn={group}272 onColumnsChange={setColumns}273 floatingRowEnabled274 />275 </div>276 );277}278
43 collapsed lines
279
280export function HeaderCell({ api, column }: Grid.T.HeaderParams<GridSpec>) {281 return (282 <div283 className={tw(284 "flex items-center justify-between gap-2",285 column.type === "number" && "flex-row-reverse",286 )}287 >288 <div>{column.name ?? column.id}</div>289 {column.agg && (290 <Menu>291 <Menu.Trigger className="text-ln-primary-50 hover:bg-ln-bg-strong cursor-pointer rounded px-1 py-1 text-[10px] transition-colors">292 ({column.agg})293 </Menu.Trigger>294 <Menu.Popover>295 <Menu.Arrow />296 <Menu.Container>297 <Menu.RadioGroup298 value={column.agg}299 onChange={(x) => {300 api.columnUpdate({ [column.id]: { agg: x } });301 }}302 >303 {column.allowedAggs.map((x) => {304 return (305 <Menu.RadioItem key={x} value={x} className="flex items-center justify-between gap-1">306 {x}307 {column.agg === x && <CheckIcon className="text-ln-primary-50" />}308 </Menu.RadioItem>309 );310 })}311 </Menu.RadioGroup>312 </Menu.Container>313 </Menu.Popover>314 </Menu>315 )}316 </div>317 );318}319function tw(...c: ClassValue[]) {320 return twMerge(clsx(...c));321}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags, nameToAvatar } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const prefix = api.rowIsGroup(row) && column.agg === "count" ? "" : "$";23
24 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);25
26 return (27 <div28 className={tw(29 "flex h-full w-full items-center justify-end tabular-nums",30 prefix && field < 0 && "text-red-600 dark:text-red-300",31 prefix && field > 0 && "text-green-600 dark:text-green-300",32 )}33 >34 {formatted}35 </div>36 );37}38
39export function TextCell({ api, column, row }: Grid.T.CellRendererParams<GridSpec>) {40 const field = api.columnField(column, row);41
42 if (typeof field === "number")43 return <div className="flex w-full items-center justify-end px-2">{field}</div>;44
45 return String(field);46}47
48export function NameCell({ api, column, row }: Grid.T.CellRendererParams<GridSpec>) {49 const field = api.columnField(column, row);50
51 if (typeof field === "number")52 return <div className="flex w-full items-center justify-end px-2">{field}</div>;53
54 if (typeof field !== "string") return "-";55
56 const url = nameToAvatar[field];57
58 return (59 <div className="flex h-full w-full items-center gap-2">60 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={field} />61 <div className="text-ln-text-dark flex flex-col gap-0.5">62 <div>{field}</div>63 </div>64 </div>65 );66}67
68export function AgeCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {69 const field = api.columnField(column, row);70
71 return typeof field === "number" ? `${formatter.format(field)}` : `${field ?? "-"}`;72}73
74export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 const prefix = api.rowIsGroup(row) && column.agg === "count" ? "" : "days";78
79 return typeof field === "number" ? `${formatter.format(field)} ${prefix}` : `${field ?? "-"}`;80}81
82export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {83 const field = api.columnField(column, row);84
85 if (typeof field === "number")86 return <div className="flex w-full items-center justify-end px-2">{field}</div>;87
88 const flag = countryFlags[field as keyof typeof countryFlags];89 if (!flag) return "-";90
91 return (92 <div className="flex h-full w-full items-center gap-2">93 <img className="size-4" src={flag} alt={`country flag of ${field}`} />94 <span>{String(field ?? "-")}</span>95 </div>96 );97}98
99export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {100 const field = api.columnField(column, row);101
102 if (typeof field === "number")103 return <div className="flex w-full items-center justify-end px-2">{field}</div>;104
105 if (typeof field !== "string") return "-";106
107 const dateField = parse(field as string, "yyyy-MM-dd", new Date());108
109 if (!isValid(dateField)) return "-";110
111 const niceDate = format(dateField, "yyyy MMM dd");112 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;113}114
115export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {116 const field = api.columnField(column, row);117
118 if (typeof field === "number")119 return <div className="flex w-full items-center justify-end px-2">{field}</div>;120
121 if (field !== "Yes" && field !== "No") return "-";122
123 return (124 <div125 className={tw(126 "flex w-full items-center justify-center rounded-lg py-1 font-bold",127 field === "No" && "bg-green-500/10 text-green-600",128 field === "Yes" && "bg-red-500/10 text-red-400",129 )}130 >131 {field}132 </div>133 );134}135
136export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {137 const field = api.columnField(column, row);138 if (typeof field !== "number") return String(field ?? "-");139
140 if (api.rowIsGroup(row) && column.agg !== "avg") {141 return <div className="flex items-center justify-end">{field}</div>;142 }143
144 return (145 <div className="flex justify-center text-yellow-300">146 <StarRating value={field} />147 </div>148 );149}150
151export default function StarRating({ value = 0 }: { value: number }) {152 const uid = useId();153
154 const max = 5;155
156 const clamped = useMemo(() => {157 const n = Number.isFinite(value) ? value : 0;158 return Math.max(0, Math.min(max, n));159 }, [value, max]);160
161 const stars = useMemo(() => {162 return Array.from({ length: max }, (_, i) => {163 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1164 return { i, fill };165 });166 }, [clamped, max]);167
168 return (169 <div className={"inline-flex items-center"} role="img">170 {stars.map(({ i, fill }) => {171 const gradId = `${uid}-star-${i}`;172 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;173 })}174 </div>175 );176}177
178function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {179 const pct = Math.round(fillFraction * 100);180
181 return (182 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">183 <defs>184 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">185 <stop offset={`${pct}%`} stopColor="currentColor" />186 <stop offset={`${pct}%`} stopColor="transparent" />187 </linearGradient>188 </defs>189
190 <path191 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"192 fill={`url(#${gradientId})`}193 />194 {/* Optional outline for crisp edges */}195 <path196 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"197 fill="none"198 stroke="transparent"199 strokeWidth="1"200 opacity="0.35"201 />202 </svg>203 );204}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { FilterModel, GridSpec } from "./demo";3import { NumberInput } from "./number-input.js";4import { Menu } from "@1771technologies/lytenyte-pro/components";5
6export function FloatingFilter({ api, column }: Grid.T.HeaderParams<GridSpec>) {7 const model = api.filterModel.useValue();8
9 const filter = model[column.id];10
11 const kind = filter?.kind ?? "gt";12
13 return (14 <div className="grid h-full w-full grid-cols-[auto_1fr] items-center gap-2">15 <NumberInput16 className="px-2"17 value={filter?.value ?? ""}18 onChange={(e) => {19 api.filterModel.set((prev) => {20 const next = { ...prev };21 const value = e.target.value;22 next[column.id] = { kind, value: value };23
24 return next;25 });26 }}27 />28
29 <Menu>30 <Menu.Trigger31 data-ln-button="secondary"32 data-ln-size="xs"33 data-ln-icon34 className="relative rounded-none"35 >36 <span className="sr-only">Select your filter operation</span>37 {kind === "lt" && <span className="iconify ph--less-than size-4"></span>}38 {kind === "gt" && <span className="iconify ph--greater-than size-4"></span>}39 {kind === "le" && <span className="iconify ph--less-than-or-equal size-4"></span>}40 {kind === "ge" && <span className="iconify ph--greater-than-or-equal size-4"></span>}41 {kind === "eq" && <span className="iconify ph--equals size-4"></span>}42 {kind === "neq" && <span className="iconify ph--not-equals size-4"></span>}43 {filter?.value && <div className="bg-ln-primary-50 absolute -right-1 -top-1 size-2 rounded-full" />}44 </Menu.Trigger>45 <Menu.Popover>46 <Menu.Arrow />47 <Menu.Container>48 <Menu.RadioGroup49 value={kind}50 onChange={(x) => {51 api.filterModel.set((prev) => {52 const next = { ...prev };53 if (next[column.id])54 next[column.id] = { ...next[column.id], kind: x as FilterModel[string]["kind"] };55 else next[column.id] = { kind: x as FilterModel[string]["kind"], value: null };56
57 return next;58 });59 }}60 >61 <Menu.RadioItem value="lt" className="flex items-center gap-2">62 <span className="iconify ph--less-than size-4"></span>63 Less Than64 </Menu.RadioItem>65 <Menu.RadioItem value="gt" className="flex items-center gap-2">66 <span className="iconify ph--greater-than size-4"></span>67 Greater Than68 </Menu.RadioItem>69 <Menu.RadioItem value="le" className="flex items-center gap-2">70 <span className="iconify ph--less-than-or-equal size-4"></span>71 Less Than Or Equal To72 </Menu.RadioItem>73 <Menu.RadioItem value="ge" className="flex items-center gap-2">74 <span className="iconify ph--greater-than-or-equal size-4"></span>75 Greater Than Or Equal To76 </Menu.RadioItem>77 <Menu.RadioItem value="eq" className="flex items-center gap-2">78 <span className="iconify ph--equals size-4"></span>79 Equal To80 </Menu.RadioItem>81 <Menu.RadioItem value="neq" className="flex items-center gap-2">82 <span className="iconify ph--not-equals size-4"></span>83 Not Equal To84 </Menu.RadioItem>85 </Menu.RadioGroup>86 </Menu.Container>87 </Menu.Popover>88 </Menu>89 </div>90 );91}1import type { ChangeEvent, JSX } from "react";2
3const handleNumberInput = (4 e: ChangeEvent<HTMLInputElement>,5 onChange?: (e: ChangeEvent<HTMLInputElement>) => void,6) => {7 const value = e.target.value;8
9 // Allow empty input10 if (value === "") {11 e.target.value = "";12 onChange?.(e);13 return;14 }15
16 // Allow minus sign only at the start17 if (value === "-") {18 e.target.value = "-";19 onChange?.(e);20 return;21 }22
23 // Convert to number and check if it's an integer24 const number = Number(value);25 if (value && !Number.isNaN(number)) {26 e.target.value = String(number) + (value.endsWith(".") ? "." : "");27 } else {28 // If not a valid integer, revert to previous value29 e.target.value = value.slice(0, -1);30 }31
32 onChange?.(e);33};34
35export function NumberInput({ ...props }: JSX.IntrinsicElements["input"]) {36 return (37 <input38 {...props}39 className="border-ln-border-field-and-button focus:outline-ln-primary-50 h-6 w-full rounded border focus:outline"40 onChange={(e) => {41 handleNumberInput(e, props.onChange);42 }}43 />44 );45}The demo creates the HavingFilterFn function by iterating through the filters in
the model and applying them one at a time.
1const havingFilter = useMemo(() => {2 const model = Object.entries(filterModel).filter(([, f]) => {3 return f.kind && f.value && !Number.isNaN(Number.parseFloat(f.value));4 });5
6 if (!model.length) return;7
8 const fn = (row: Grid.T.RowGroup) => {9 for (const [key, f] of model) {10 const value = row.data[key] as number;11 const compare = Number.parseFloat(f.value!);12 if (value == null || typeof value !== "number") return false;13
14 if (f.kind === "eq" && value !== compare) return false;15 else if (f.kind === "neq" && value === compare) return false;16 else if (f.kind === "ge" && value < compare) return false;17 else if (f.kind === "le" && value > compare) return false;18 else if (f.kind === "lt" && value >= compare) return false;19 else if (f.kind === "gt" && value <= compare) return false;20 }21
22 return true;23 };24
25 return [null, fn];26}, [filterModel]);Next Steps
- Client Row Label Filters: Use row label filters to show only row groups with valid grouping labels.
- Client Row Filtering: Explore how to filter rows when using the client row source.
- Client Row Grouping: Create a hierarchical representation of your data by grouping rows.
- Client Row Aggregations: Aggregate row data per group to display values at the group level.
