Server Row Grouping
Display hierarchical data efficiently with LyteNyte Grid. Use the server data source to request and assemble data slices for grouped grid views.
Grouping Rows
LyteNyte Grid represents row groups using the rowGroupModel
property in the grid state.
When row groups are defined, the grid creates a hierarchical row structure with parent/child relationships.
While LyteNyte Grid manages the model state, the server provides the rows for each grouping level.
When row groups are active, the grid's server data source includes a path
property in its requests,
indicating which row group slice to retrieve.
The simplified example below illustrates this behavior. If you're new to the data request interface, see the Data Interface guide.
Row Grouping
"use client";import "./component.css";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { SalaryData } from "./data";import clsx from "clsx";import { twMerge } from "tailwind-merge";import {AgeCellRenderer,BaseCellRenderer,GroupCellRenderer,SalaryRenderer,YearsOfExperienceRenderer,} from "./components";import { GroupPills } from "./ui";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,uiHints: { rowGroupable: true },cellRenderer: BaseCellRenderer,},{id: "Education Level",name: "Education",width: 160,hide: true,widthFlex: 1,uiHints: { rowGroupable: true },cellRenderer: BaseCellRenderer,},{id: "Age",type: "number",width: 100,widthFlex: 1,uiHints: { rowGroupable: true },cellRenderer: AgeCellRenderer,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,uiHints: {rowGroupable: true,},},{ id: "Salary", type: "number", width: 160, widthFlex: 1, cellRenderer: SalaryRenderer },];export default function RowGroupingBasic() {const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, params.model.groups);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,rowGroupColumn: {cellRenderer: GroupCellRenderer,widthFlex: 1,},rowGroupModel: ["Education Level"],});const view = grid.view.useValue();return (<><GroupPills grid={grid} /><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",),)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end tabular-nums",),)}/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type { CellRendererParams } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { tw } from "./ui";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{field}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{field as string}</div>;}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}</div></div>);}const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};
import type {DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], groupModel: RowGroupModelItem<SalaryData>[]) {// Simulate latency and server work.await sleep();return reqs.map((c) => {// Return flat items if there are no row groupsif (!groupModel.length) {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;}const groupLevel = c.path.length;const groupKeys = groupModel.slice(0, groupLevel + 1);const filteredForGrouping = data.filter((row) => {return c.path.every((v, i) => {const groupKey = groupModel[i];return `${row[groupKey as keyof SalaryData]}` === v;});});// This is the leaf level of the groupingif (groupLevel === groupModel.length) {return {kind: "center",asOfTime: Date.now(),start: c.start,end: c.end,path: c.path,data: filteredForGrouping.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),size: filteredForGrouping.length,} satisfies DataResponse;}const groupedData = Object.groupBy(filteredForGrouping, (r) => {const groupPath = groupKeys.map((g) => {if (typeof g !== "string")throw new Error("Non-string groups are not supported by this dummy implementation");return r[g as keyof SalaryData];});return groupPath.join(" / ");});// Sort the groups to make them nicerconst rows = Object.entries(groupedData).sort((x, y) => {const left = x[0];const right = y[0];const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {if (!left && !right) return 0;if (!left) return 1;if (!right) return -1;return left.localeCompare(right);}return asNumberLeft - asNumberRight;});return {kind: "center",asOfTime: Date.now(),data: rows.slice(c.start, c.end).map((x) => {const childRows = x[1]!;const nextGroup = groupLevel + 1;let childCnt: number;if (nextGroup === groupModel.length) childCnt = childRows.length;else {childCnt = Object.keys(Object.groupBy(childRows, (x) => {const groupKey = groupModel[nextGroup];return x[groupKey as keyof SalaryData];}),).length;}return {kind: "branch",childCount: childCnt,data: {}, // See aggregationsid: x[0],key: x[0].split(" / ").at(-1)!,};}),path: c.path,start: c.start,end: c.end,size: rows.length,} satisfies DataResponse;});}
import { GridBox } from "@1771technologies/lytenyte-pro";import { type PropsWithChildren, type ReactNode } from "react";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import type { SalaryData } from "./data";import type { Grid } from "@1771technologies/lytenyte-pro/types";import { DragDotsSmallIcon } from "@1771technologies/lytenyte-pro/icons";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export interface PillManagerRowProps {readonly icon: ReactNode;readonly label: string;readonly className?: string;}export function PillManagerRow({icon,label,children,className,}: PropsWithChildren<PillManagerRowProps>) {return (<divclassName={tw("bg-ln-gray-05 border-ln-gray-20 grid grid-cols-[42px_1fr_64px] border-t md:grid-cols-[151px_1fr_118px]",)}><div className="text-ln-gray-80 flex min-h-[52px] items-center justify-center gap-2 text-sm md:justify-start md:pl-[30px] md:pr-3">{icon}<div className="hidden md:block">{label}</div></div><GridBox.PanelclassName={tw("no-scrollbar flex max-h-[200px] w-full items-center overflow-auto focus:outline-none md:max-h-[unset]","focus-visible:outline-ln-primary-50 focus-visible:outline-offset-[-1px]",className,)}>{children}</GridBox.Panel></div>);}export function GroupPills({ grid }: { grid: Grid<SalaryData> }) {const { rootProps, items } = GridBox.useRowGroupBoxItems({grid,orientation: "horizontal",hideColumnOnGroup: true,includeGroupables: true,placeholder: (el) => el.firstElementChild! as HTMLElement,});return (<GridBox.Root {...rootProps}><PillManagerRowicon={<RowGroupIcon />}label="Row Groups"className="data-[ln-can-drop=true]:bg-ln-primary-10">{items.map((c) => {const isActive = c.active ?? true;return (<GridBox.Itemkey={c.id}item={c}className="horizontal-indicators flex h-[52px] items-center"onKeyDown={(ev) => {if (ev.key === " ")if (isActive) {c.onDelete(ev.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);}}}onClick={(e) => {if (isActive) {c.onDelete(e.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);grid.api.columnUpdate({ [c.id]: { hide: true } });}}}itemClassName={tw("h-full flex items-center px-[6px] focus:outline-none group text-ln-gray-90 ","opacity-60 hover:opacity-80 transition-opacity cursor-pointer",isActive && "opacity-100 hover:opacity-100",)}><div className="bg-ln-pill-group-fill border-ln-pill-group-stroke group-focus-visible:ring-ln-primary-50 flex h-[28px] cursor-pointer items-center text-nowrap rounded border pl-1 group-focus-visible:ring-1">{isActive && <DragDotsSmallIcon className="no-drag" />}<div className={tw("pl-1 pr-3 text-xs", !isActive && "pr-2")}>{c.label}</div></div></GridBox.Item>);})}</PillManagerRow></GridBox.Root>);}function RowGroupIcon() {return (<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><pathd="M2.5 3.75H17.203"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 9.56055H9.07178"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 15.4688H9"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M12 12.8281L14.8284 12.8281L14.8284 9.9997"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M14.8286 15.6572L14.8286 12.8288L17.657 12.8288"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/></svg>);}
This basic grouping example does not include aggregations (covered in the next section), so only
the leaf-level rows contain data values.
If you inspect the server.ts
file in the demo (click Expand Code), you'll
see that grouped rows are more complex to compute than flat rows. The added parent/child hierarchy
increases the complexity of the view's visual representation.
When reviewing the code, pay close attention to the path
property.
LyteNyte Grid includes the path
in each data request to identify
which part of the grouped rows is being fetched.
This value must be returned in the response so the grid's server
data source knows how to store the response rows in its internal data tree.
Applying Aggregations
The rowGroupModel
defines the hierarchical relationships of rows created on the server.
This is only one part of the relationship. The aggModel
in the grid state specifies how group rows should be aggregated.
The model defines, per column, how child values are combined, for example, by averaging or summing them.
In essence, the aggModel
tells the server how to calculate values for each group row.
Row Grouping With Aggregations
"use client";import "./component.css";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { SalaryData } from "./data";import clsx from "clsx";import { twMerge } from "tailwind-merge";import {AgeCellRenderer,BaseCellRenderer,GroupCellRenderer,HeaderCell,SalaryRenderer,YearsOfExperienceRenderer,} from "./components";import { GroupPills } from "./ui";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,uiHints: { rowGroupable: true, aggsAllowed: ["first", "last"] },cellRenderer: BaseCellRenderer,},{id: "Education Level",name: "Education",width: 160,widthFlex: 1,uiHints: { rowGroupable: true },hide: true,cellRenderer: BaseCellRenderer,},{id: "Age",type: "number",width: 100,widthFlex: 1,uiHints: { rowGroupable: true, aggsAllowed: ["avg", "first", "last"] },cellRenderer: AgeCellRenderer,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,uiHints: {rowGroupable: true,aggsAllowed: ["avg", "max", "min", "sum"],},},{id: "Salary",type: "number",width: 160,widthFlex: 1,cellRenderer: SalaryRenderer,uiHints: {aggsAllowed: ["avg", "max", "min", "sum"],},},];export default function RowGroupingAggregated() {const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, params.model.groups, params.model.aggregations);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,aggModel: {Gender: { fn: "first" },Age: { fn: "avg" },Salary: { fn: "avg" },"Years of Experience": { fn: "max" },},columnBase: {headerRenderer: HeaderCell,},rowGroupColumn: {cellRenderer: GroupCellRenderer,widthFlex: 1,},rowGroupModel: ["Education Level"],});const view = grid.view.useValue();return (<><GroupPills grid={grid} /><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",),)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end tabular-nums",),)}/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type {CellRendererParams,HeaderCellRendererFn,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { AggMenu, tw } from "./ui";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{(field as string) || "-"}</div>;}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}</div></div>);}const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};export const HeaderCell: HeaderCellRendererFn<SalaryData> = ({ grid, column }) => {const aggModel = grid.state.aggModel.useValue();const hasGroups = grid.state.rowGroupModel.useValue().length > 0;const aggFn = aggModel[column.id]?.fn;return (<divclassName={tw("flex h-full w-full items-center gap-2 text-sm text-[var(--lng1771-gray-80)]",column.type === "number" && "flex-row-reverse",)}><div>{column.name ?? column.id}</div>{aggFn && hasGroups && <AggMenu grid={grid} column={column} />}</div>);};
import type {AggModelFn,DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[],groupModel: RowGroupModelItem<SalaryData>[],aggModel: { [columnId: string]: { fn: AggModelFn<SalaryData> } },) {// Simulate latency and server work.await sleep();return reqs.map((c) => {// Return flat items if there are no row groupsif (!groupModel.length) {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;}const groupLevel = c.path.length;const groupKeys = groupModel.slice(0, groupLevel + 1);const filteredForGrouping = data.filter((row) => {return c.path.every((v, i) => {const groupKey = groupModel[i];return `${row[groupKey as keyof SalaryData]}` === v;});});// This is the leaf level of the groupingif (groupLevel === groupModel.length) {return {kind: "center",asOfTime: Date.now(),start: c.start,end: c.end,path: c.path,data: filteredForGrouping.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),size: filteredForGrouping.length,} satisfies DataResponse;}const groupedData = Object.groupBy(filteredForGrouping, (r) => {const groupPath = groupKeys.map((g) => {if (typeof g !== "string")throw new Error("Non-string groups are not supported by this dummy implementation");return r[g as keyof SalaryData];});return groupPath.join(" / ");});// Sort the groups to make them nicerconst rows = Object.entries(groupedData).sort((x, y) => {const left = x[0];const right = y[0];const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {if (!left && !right) return 0;if (!left) return 1;if (!right) return -1;return left.localeCompare(right);}return asNumberLeft - asNumberRight;});return {kind: "center",asOfTime: Date.now(),data: rows.slice(c.start, c.end).map((x) => {const childRows = x[1]!;const nextGroup = groupLevel + 1;let childCnt: number;if (nextGroup === groupModel.length) childCnt = childRows.length;else {childCnt = Object.keys(Object.groupBy(childRows, (x) => {const groupKey = groupModel[nextGroup];return x[groupKey as keyof SalaryData];}),).length;}const aggData = Object.fromEntries(Object.entries(aggModel).map(([column, m]) => {if (typeof m.fn !== "string")throw new Error("Non-string aggregations are not supported by this dummy implementation",);const id = column as keyof SalaryData;if (m.fn === "first") return [column, childRows[0][id]];if (m.fn === "last") return [column, childRows.at(-1)![id]];if (m.fn === "avg")return [column,childRows.reduce((acc, x) => acc + (x[id] as number), 0) / childRows.length,];if (m.fn === "sum")return [column, childRows.reduce((acc, x) => acc + (x[id] as number), 0)];if (m.fn === "min")return [column, Math.min(...childRows.map((x) => x[id] as number))];if (m.fn === "max")return [column, Math.max(...childRows.map((x) => x[id] as number))];}).filter(Boolean) as [string, number | string][],);return {kind: "branch",childCount: childCnt,data: aggData,id: x[0],key: x[0].split(" / ").at(-1)!,};}),path: c.path,start: c.start,end: c.end,size: rows.length,} satisfies DataResponse;});}
import { GridBox } from "@1771technologies/lytenyte-pro";import type { ComponentProps } from "react";import { type PropsWithChildren, type ReactNode } from "react";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import type { SalaryData } from "./data";import type { Grid } from "@1771technologies/lytenyte-pro/types";import type { HeaderCellRendererParams } from "@1771technologies/lytenyte-pro/types";import { DragDotsSmallIcon, TickmarkIcon } from "@1771technologies/lytenyte-pro/icons";import { DropdownMenu as D, DropdownMenu } from "radix-ui";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export interface PillManagerRowProps {readonly icon: ReactNode;readonly label: string;readonly className?: string;}export function PillManagerRow({icon,label,children,className,}: PropsWithChildren<PillManagerRowProps>) {return (<divclassName={tw("bg-ln-gray-05 border-ln-gray-20 grid grid-cols-[42px_1fr_64px] border-t md:grid-cols-[151px_1fr_118px]",)}><div className="text-ln-gray-80 flex min-h-[52px] items-center justify-center gap-2 text-sm md:justify-start md:pl-[30px] md:pr-3">{icon}<div className="hidden md:block">{label}</div></div><GridBox.PanelclassName={tw("no-scrollbar flex max-h-[200px] w-full items-center overflow-auto focus:outline-none md:max-h-[unset]","focus-visible:outline-ln-primary-50 focus-visible:outline-offset-[-1px]",className,)}>{children}</GridBox.Panel></div>);}export function GroupPills({ grid }: { grid: Grid<SalaryData> }) {const { rootProps, items } = GridBox.useRowGroupBoxItems({grid,orientation: "horizontal",hideColumnOnGroup: true,includeGroupables: true,placeholder: (el) => el.firstElementChild! as HTMLElement,});return (<GridBox.Root {...rootProps}><PillManagerRowicon={<RowGroupIcon />}label="Row Groups"className="data-[ln-can-drop=true]:bg-ln-primary-10">{items.map((c) => {const isActive = c.active ?? true;return (<GridBox.Itemkey={c.id}item={c}className="horizontal-indicators flex h-[52px] items-center"onKeyDown={(ev) => {if (ev.key === " ")if (isActive) {c.onDelete(ev.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);}}}onClick={(e) => {if (isActive) {c.onDelete(e.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);grid.api.columnUpdate({ [c.id]: { hide: true } });}}}itemClassName={tw("h-full flex items-center px-[6px] focus:outline-none group text-ln-gray-90 ","opacity-60 hover:opacity-80 transition-opacity cursor-pointer",isActive && "opacity-100 hover:opacity-100",)}><div className="bg-ln-pill-group-fill border-ln-pill-group-stroke group-focus-visible:ring-ln-primary-50 flex h-[28px] cursor-pointer items-center text-nowrap rounded border pl-1 group-focus-visible:ring-1">{isActive && <DragDotsSmallIcon className="no-drag" />}<div className={tw("pl-1 pr-3 text-xs", !isActive && "pr-2")}>{c.label}</div></div></GridBox.Item>);})}</PillManagerRow></GridBox.Root>);}function RowGroupIcon() {return (<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><pathd="M2.5 3.75H17.203"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 9.56055H9.07178"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 15.4688H9"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M12 12.8281L14.8284 12.8281L14.8284 9.9997"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M14.8286 15.6572L14.8286 12.8288L17.657 12.8288"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/></svg>);}export function AggMenu({column,grid,className,}: HeaderCellRendererParams<any> & { className?: string }) {const base = grid.state.columnBase.useValue();const aggs = grid.state.aggModel.useValue();const agg = aggs[column.id];const aggName = typeof agg?.fn === "string" ? agg?.fn : "Fn(x)";const options = column.uiHints?.aggsAllowed ?? base.uiHints?.aggsAllowed ?? [];return (<D.Root><D.Trigger className={tw(className)} asChild><button className="focus-visible:ring-ln-primary-50 rounded px-1 py-1 text-xs text-[var(--lng1771-primary-50)] hover:bg-[var(--lng1771-primary-30)] focus:outline-none focus-visible:ring-1">({aggName as string})</button></D.Trigger><D.Portal><GridDropMenuContent><D.Arrow fill="var(--lng1771-gray-30)" /><D.DropdownMenuRadioGroupvalue={aggName}onValueChange={(c) => {grid.state.aggModel.set((prev) => {return { ...prev, [column.id]: { fn: c } };});}}>{options.map((c) => {return <RadioItem key={c} value={c} label={c} className="pl-1" />;})}</D.DropdownMenuRadioGroup></GridDropMenuContent></D.Portal></D.Root>);}const itemCls ="flex items-center text-sm text-ln-gray-80 cursor-pointer rounded-lg data-[highlighted]:bg-ln-gray-30 py-1 pr-2 px-0.5";const RadioItem = ({icon,...props}: Omit<ComponentProps<typeof D.DropdownMenuRadioItem>, "children"> & {icon?: ReactNode;label: ReactNode;}) => {return (<D.DropdownMenuRadioItem{...props}className={clsx(props.className, itemCls, "group", "data-[disabled]:text-ln-gray-30")}>{icon && <MenuIcon>{icon}</MenuIcon>}{props.label}<MenuIcon><TickmarkIconclassName="stroke-ln-primary-50 relative hidden group-data-[state='checked']:block"style={{ right: -16 }}/></MenuIcon></D.DropdownMenuRadioItem>);};const MenuIcon = (props: PropsWithChildren) => {return (<span className="text-ln-gray-70 mr-2 flex h-[24px] w-[20px] items-center justify-center">{props.children}</span>);};function GridDropMenuContent(props: PropsWithChildren) {return (<DropdownMenu.ContentclassName={tw("bg-ln-gray-05 border-ln-gray-30 z-50 rounded-lg border p-1")}>{props.children}</DropdownMenu.Content>);}
Aggregations make row grouping valuable. They reveal key insights in your data.
In the demo above, rows are grouped by Education
, and both Age
and Salary
are aggregated by average.
From our (fictitious) salary dataset, we can see that the average age of someone with a Bachelor's
degree is 30 years, with an average salary of $124,768.
To change an aggregation in the demo, click the aggregation name in
the column header to open the selection menu.
Changing an aggregation resets the grid and triggers a new data request to the server.
Like row grouping, the aggregation model is passed to the dataFetcher
function
provided to the server data source.
const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {const aggModel = params.model.aggregations;return Server(params.requests, params.model.groups, aggModel);},});
Not every column needs an aggregation. For many columns, especially text columns, aggregation doesn't make sense. The server is responsible for returning correct aggregation results. LyteNyte Grid does not validate the aggregated data; it trusts the server's calculations in the response.
Many aggregation types are supported. In most cases, these correspond to the aggregation
functions available in your database. Common examples include sum
and average
, while advanced
databases like ClickHouse provide additional functions
listed here.
Displaying Child Counts
When grouping rows, it is sometimes useful to display the direct child count on each parent row. This helps visualize the size of each group.
To support this, the server can include a child count value for each group in the response data. The demo below illustrates this by showing child counts in the group cells.
Row Grouping with Child Counts
"use client";import "./component.css";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { SalaryData } from "./data";import clsx from "clsx";import { twMerge } from "tailwind-merge";import {AgeCellRenderer,BaseCellRenderer,GroupCellRenderer,SalaryRenderer,YearsOfExperienceRenderer,} from "./components";import { GroupPills } from "./ui";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,uiHints: { rowGroupable: true },cellRenderer: BaseCellRenderer,},{id: "Education Level",name: "Education",width: 160,hide: true,widthFlex: 1,uiHints: { rowGroupable: true },cellRenderer: BaseCellRenderer,},{id: "Age",type: "number",width: 100,widthFlex: 1,hide: true,uiHints: { rowGroupable: true },cellRenderer: AgeCellRenderer,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,uiHints: {rowGroupable: true,},},{ id: "Salary", type: "number", width: 160, widthFlex: 1, cellRenderer: SalaryRenderer },];export default function RowGroupChildCounts() {const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, params.model.groups);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,rowGroupColumn: {cellRenderer: GroupCellRenderer,widthFlex: 1,},rowGroupModel: ["Education Level", "Age"],});const view = grid.view.useValue();return (<><GroupPills grid={grid} /><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",),)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end tabular-nums",),)}/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type { CellRendererParams } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { tw } from "./ui";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{field}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{field as string}</div>;}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}{" "}<span className="text-[11px] font-bold tracking-wide">({row.data.child_count as number})</span></div></div>);}const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};
import type {DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], groupModel: RowGroupModelItem<SalaryData>[]) {// Simulate latency and server work.await sleep();return reqs.map((c) => {// Return flat items if there are no row groupsif (!groupModel.length) {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;}const groupLevel = c.path.length;const groupKeys = groupModel.slice(0, groupLevel + 1);const filteredForGrouping = data.filter((row) => {return c.path.every((v, i) => {const groupKey = groupModel[i];return `${row[groupKey as keyof SalaryData]}` === v;});});// This is the leaf level of the groupingif (groupLevel === groupModel.length) {return {kind: "center",asOfTime: Date.now(),start: c.start,end: c.end,path: c.path,data: filteredForGrouping.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),size: filteredForGrouping.length,} satisfies DataResponse;}const groupedData = Object.groupBy(filteredForGrouping, (r) => {const groupPath = groupKeys.map((g) => {if (typeof g !== "string")throw new Error("Non-string groups are not supported by this dummy implementation");return r[g as keyof SalaryData];});return groupPath.join(" / ");});// Sort the groups to make them nicerconst rows = Object.entries(groupedData).sort((x, y) => {const left = x[0];const right = y[0];const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {if (!left && !right) return 0;if (!left) return 1;if (!right) return -1;return left.localeCompare(right);}return asNumberLeft - asNumberRight;});return {kind: "center",asOfTime: Date.now(),data: rows.slice(c.start, c.end).map((x) => {const childRows = x[1]!;const nextGroup = groupLevel + 1;let childCnt: number;if (nextGroup === groupModel.length) childCnt = childRows.length;else {childCnt = Object.keys(Object.groupBy(childRows, (x) => {const groupKey = groupModel[nextGroup];return x[groupKey as keyof SalaryData];}),).length;}return {kind: "branch",childCount: childCnt,data: { child_count: childCnt },id: x[0],key: x[0].split(" / ").at(-1)!,};}),path: c.path,start: c.start,end: c.end,size: rows.length,} satisfies DataResponse;});}
import { GridBox } from "@1771technologies/lytenyte-pro";import { type PropsWithChildren, type ReactNode } from "react";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import type { SalaryData } from "./data";import type { Grid } from "@1771technologies/lytenyte-pro/types";import { DragDotsSmallIcon } from "@1771technologies/lytenyte-pro/icons";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export interface PillManagerRowProps {readonly icon: ReactNode;readonly label: string;readonly className?: string;}export function PillManagerRow({icon,label,children,className,}: PropsWithChildren<PillManagerRowProps>) {return (<divclassName={tw("bg-ln-gray-05 border-ln-gray-20 grid grid-cols-[42px_1fr_64px] border-t md:grid-cols-[151px_1fr_118px]",)}><div className="text-ln-gray-80 flex min-h-[52px] items-center justify-center gap-2 text-sm md:justify-start md:pl-[30px] md:pr-3">{icon}<div className="hidden md:block">{label}</div></div><GridBox.PanelclassName={tw("no-scrollbar flex max-h-[200px] w-full items-center overflow-auto focus:outline-none md:max-h-[unset]","focus-visible:outline-ln-primary-50 focus-visible:outline-offset-[-1px]",className,)}>{children}</GridBox.Panel></div>);}export function GroupPills({ grid }: { grid: Grid<SalaryData> }) {const { rootProps, items } = GridBox.useRowGroupBoxItems({grid,orientation: "horizontal",hideColumnOnGroup: true,includeGroupables: true,placeholder: (el) => el.firstElementChild! as HTMLElement,});return (<GridBox.Root {...rootProps}><PillManagerRowicon={<RowGroupIcon />}label="Row Groups"className="data-[ln-can-drop=true]:bg-ln-primary-10">{items.map((c) => {const isActive = c.active ?? true;return (<GridBox.Itemkey={c.id}item={c}className="horizontal-indicators flex h-[52px] items-center"onKeyDown={(ev) => {if (ev.key === " ")if (isActive) {c.onDelete(ev.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);}}}onClick={(e) => {if (isActive) {c.onDelete(e.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);grid.api.columnUpdate({ [c.id]: { hide: true } });}}}itemClassName={tw("h-full flex items-center px-[6px] focus:outline-none group text-ln-gray-90 ","opacity-60 hover:opacity-80 transition-opacity cursor-pointer",isActive && "opacity-100 hover:opacity-100",)}><div className="bg-ln-pill-group-fill border-ln-pill-group-stroke group-focus-visible:ring-ln-primary-50 flex h-[28px] cursor-pointer items-center text-nowrap rounded border pl-1 group-focus-visible:ring-1">{isActive && <DragDotsSmallIcon className="no-drag" />}<div className={tw("pl-1 pr-3 text-xs", !isActive && "pr-2")}>{c.label}</div></div></GridBox.Item>);})}</PillManagerRow></GridBox.Root>);}function RowGroupIcon() {return (<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><pathd="M2.5 3.75H17.203"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 9.56055H9.07178"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 15.4688H9"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M12 12.8281L14.8284 12.8281L14.8284 9.9997"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M14.8286 15.6572L14.8286 12.8288L17.657 12.8288"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/></svg>);}
In the example above, the child counts are sent from the server to the client as part of the row data. This might seem unexpected since the data response for a group row already includes a child count property. However, that property belongs to the server data loading interface, not the LyteNyte Grid row interface.
Including child counts directly in the row data ensures they're accessible to the grid's cell renderers.
Next Steps
- Unbalanced Rows: handle asymmetric row groups of varying depths.
- Data Pushing or Pulling: manually request or push data to the server data source from the client.
- Optimistic Loading: use optimistic loading to prefetch data and improve responsiveness.
Server Row Pinning
You can pin specific rows to the top or bottom of the grid's viewport. Pinned rows stay visible while the user scrolls. When using a server data source, the server manages pinned rows through a pinned row response.
Data Updates
Handle live data updates to display real-time views driven by server events. LyteNyte Grid's server data source requests update ticks from the server at a configurable frequency.