Server Row Grouping
Display hierarchical data by using the server data source to request and assemble data slices for grouped views.
Server Grouped Rows
To group rows in the server data source, define a custom row group model. The simplest approach uses an
array of string values, where each value is the ID of a column to group by.
Send this model to the server to request grouped rows.
The example below shows this behavior. For details on the data request interface used by the server data source, see the Data Interface guide.
Basic Server Row Grouping
1"use client";2import "@1771technologies/lytenyte-pro/pill-manager.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";5
6import { useMemo, useState } from "react";7import { Server } from "./server.js";8import type { SalaryData } from "./data";9import {10 AgeCellRenderer,11 BaseCellRenderer,12 SalaryRenderer,13 YearsOfExperienceRenderer,14} from "./components.js";15import { PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";16
17export interface GridSpec {18 readonly data: SalaryData;19}20
21const columns: Grid.Column<GridSpec>[] = [22 {23 id: "Gender",24 width: 120,25 widthFlex: 1,26 cellRenderer: BaseCellRenderer,27 },28 {29 id: "Education Level",30 name: "Education",31 width: 160,32 hide: true,33 widthFlex: 1,34 cellRenderer: BaseCellRenderer,35 },36 {37 id: "Age",38 type: "number",39 width: 100,40 widthFlex: 1,41 cellRenderer: AgeCellRenderer,42 },43 {44 id: "Years of Experience",45 name: "YoE",46 type: "number",47 width: 100,48 widthFlex: 1,49 cellRenderer: YearsOfExperienceRenderer,50 },51 { id: "Salary", type: "number", width: 160, widthFlex: 1, cellRenderer: SalaryRenderer },52];53
54const group: Grid.RowGroupColumn<GridSpec> = {55 cellRenderer: RowGroupCell,56 width: 200,57 pin: "start",58};59
60export default function ServerDataDemo() {61 const [rowGroups, setRowGroups] = useState<PillManager.T.PillItem[]>([62 { name: "Education Level", id: "Education Level", active: true, movable: true },63 { name: "Gender", id: "Gender", active: false, movable: true },64 { name: "Age", id: "Age", active: false, movable: true },65 { name: "YoE", id: "Years of Experience", active: false, movable: true },66 ]);67
68 const model = useMemo(() => rowGroups.filter((x) => x.active).map((x) => x.id), [rowGroups]);69
70 const ds = useServerDataSource({71 queryFn: (params) => {72 return Server(params.requests, params.queryKey[0]);73 },74 hasRowBranches: model.length > 0,75 queryKey: [model] as const,76 blockSize: 50,77 });78
79 const isLoading = ds.isLoading.useValue();80
81 return (82 <>83 <PillManager84 onPillItemActiveChange={(p) => {85 setRowGroups((prev) => {86 return [...prev].map((x) => {87 if (p.item.id === x.id) {88 return { ...x, active: p.item.active };89 }90 return x;91 });92 });93 }}94 onPillRowChange={(ev) => {95 setRowGroups(ev.changed[0].pills);96 }}97 rows={[98 {99 id: "row-groups",100 label: "Row Groups",101 type: "row-groups",102 pills: rowGroups,103 },104 ]}105 />106 <div className="ln-grid" style={{ height: 500 }}>107 <Grid108 rowSource={ds}109 columns={columns}110 rowGroupColumn={group}111 slotViewportOverlay={112 isLoading && (113 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>114 )115 }116 />117 </div>118 </>119 );120}1import type { Grid } from "@1771technologies/lytenyte-pro";2import { twMerge } from "tailwind-merge";3import clsx, { type ClassValue } from "clsx";4import type { GridSpec } from "./demo";5
6export function tw(...c: ClassValue[]) {7 return twMerge(clsx(...c));8}9
10function SkeletonLoading() {11 return (12 <div className="h-full w-full p-2">13 <div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div>14 </div>15 );16}17
18const formatter = new Intl.NumberFormat("en-Us", {19 minimumFractionDigits: 0,20 maximumFractionDigits: 0,21});22export function SalaryRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {23 const field = api.columnField(column, row);24
25 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;26
27 if (typeof field !== "number") return "-";28
29 return <div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>;30}31
32export function YearsOfExperienceRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {33 const field = api.columnField(column, row);34
35 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;36
37 if (typeof field !== "number") return "-";38
39 return (40 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">41 <span className="font-bold">{field}</span>{" "}42 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>43 </div>44 );45}46
47export function AgeCellRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {48 const field = api.columnField(column, row);49
50 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;51
52 if (typeof field !== "number") return "-";53
54 return (55 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">56 <span className="font-bold">{formatter.format(field)}</span>{" "}57 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>58 </div>59 );60}61
62export function BaseCellRenderer({ row, column, api }: Grid.T.CellRendererParams<GridSpec>) {63 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;64
65 const field = api.columnField(column, row);66
67 return <div className="flex h-full w-full items-center">{(field as string) ?? "-"}</div>;68}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { SalaryData } from "./data";3import { data } from "./data.js";4
5const sleep = () => new Promise((res) => setTimeout(res, 600));6
7export async function Server(reqs: DataRequest[], groupModel: string[]) {8 // Simulate latency and server work.9 await sleep();10
11 return reqs.map((c) => {12 // Return flat items if there are no row groups13 if (!groupModel.length) {14 return {15 asOfTime: Date.now(),16 data: data.slice(c.start, c.end).map((x) => {17 return {18 kind: "leaf",19 id: x.id,20 data: x,21 };22 }),23 start: c.start,24 end: c.end,25 kind: "center",26 path: c.path,27 size: data.length,28 } satisfies DataResponse;29 }30
31 const groupLevel = c.path.length;32 const groupKeys = groupModel.slice(0, groupLevel + 1);33
34 const filteredForGrouping = data.filter((row) => {35 return c.path.every((v, i) => {36 const groupKey = groupModel[i];37 return `${row[groupKey as keyof SalaryData]}` === v;38 });39 });40
41 // This is the leaf level of the grouping42 if (groupLevel === groupModel.length) {43 return {44 kind: "center",45 asOfTime: Date.now(),46 start: c.start,47 end: c.end,48 path: c.path,49 data: filteredForGrouping.slice(c.start, c.end).map((x) => {50 return {51 kind: "leaf",52 id: x.id,53 data: x,54 };55 }),56 size: filteredForGrouping.length,57 } satisfies DataResponse;58 }59
60 const groupedData = Object.groupBy(filteredForGrouping, (r) => {61 const groupPath = groupKeys.map((g) => {62 if (typeof g !== "string")63 throw new Error("Non-string groups are not supported by this dummy implementation");64
65 return r[g as keyof SalaryData];66 });67
68 return groupPath.join(" / ");69 });70
71 // Sort the groups to make them nicer72 const rows = Object.entries(groupedData).sort((x, y) => {73 const left = x[0];74 const right = y[0];75
76 const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());77 const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());78
79 if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {80 if (!left && !right) return 0;81 if (!left) return 1;82 if (!right) return -1;83
84 return left.localeCompare(right);85 }86
87 return asNumberLeft - asNumberRight;88 });89
90 return {91 kind: "center",92 asOfTime: Date.now(),93 data: rows.slice(c.start, c.end).map((x) => {94 const childRows = x[1]!;95
96 const nextGroup = groupLevel + 1;97 let childCnt: number;98 if (nextGroup === groupModel.length) childCnt = childRows.length;99 else {100 childCnt = Object.keys(101 Object.groupBy(childRows, (x) => {102 const groupKey = groupModel[nextGroup];103 return x[groupKey as keyof SalaryData];104 }),105 ).length;106 }107
108 return {109 kind: "branch",110 childCount: childCnt,111 data: {}, // See aggregations112 id: x[0],113 key: x[0].split(" / ").at(-1)!,114 };115 }),116
117 path: c.path,118 start: c.start,119 end: c.end,120 size: rows.length,121 } satisfies DataResponse;122 });123}This basic grouping example excludes aggregations, so only leaf-level rows contain data values.
The demo’s server.ts file shows the parent/child logic required to build and return grouped rows.
Compared to flat rows, grouped rows add parent/child structure to the data model. To support this
hierarchy, the grid includes a path property in each request to identify
the specific group being fetched.
Return the same path in the response so the server data source
can store the returned rows in its internal data tree.
Server Row Aggregations
To display values on group rows, return group rows with aggregated data from the server.
In the demo, click the aggregation name in a column header to change the aggregation.
Each change updates the queryKey, which refreshes the server data source
and updates the view with new values.
Aggregated Server Row Grouping
1"use client";2import "@1771technologies/lytenyte-pro/pill-manager.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";5
6import { useMemo, useState } from "react";7import { Server } from "./server.js";8import type { SalaryData } from "./data";9import {10 AgeCellRenderer,11 BaseCellRenderer,12 SalaryRenderer,13 tw,14 YearsOfExperienceRenderer,15} from "./components.js";16import { CheckIcon } from "@radix-ui/react-icons";17import { Menu, PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";18
19export interface GridSpec {20 readonly data: SalaryData;21 readonly column: { agg: string; allowedAggs: string[] };22}23
24const initialColumns: Grid.Column<GridSpec>[] = [25 {26 id: "Gender",27 width: 120,28 widthFlex: 1,29 cellRenderer: BaseCellRenderer,30 agg: "first",31 allowedAggs: ["first", "last"],32 headerRenderer: HeaderCell,33 },34 {35 id: "Education Level",36 name: "Education",37 width: 160,38 hide: true,39 widthFlex: 1,40 cellRenderer: BaseCellRenderer,41 agg: "first",42 allowedAggs: ["first", "last"],43 headerRenderer: HeaderCell,44 },45 {46 id: "Age",47 type: "number",48 width: 100,49 widthFlex: 1,50 cellRenderer: AgeCellRenderer,51 agg: "avg",52 allowedAggs: ["avg", "first", "last"],53 headerRenderer: HeaderCell,54 },55 {56 id: "Years of Experience",57 name: "YoE",58 type: "number",59 width: 100,60 widthFlex: 1,61 cellRenderer: YearsOfExperienceRenderer,62 agg: "max",63 allowedAggs: ["avg", "sum", "max", "min"],64 headerRenderer: HeaderCell,65 },66 {67 id: "Salary",68 type: "number",69 width: 160,70 widthFlex: 1,71 cellRenderer: SalaryRenderer,72 agg: "avg",73 allowedAggs: ["avg", "sum", "max", "min"],74 headerRenderer: HeaderCell,75 },76];77
78const group: Grid.RowGroupColumn<GridSpec> = {79 cellRenderer: RowGroupCell,80 width: 200,81 pin: "start",82};83
84export default function ServerDataDemo() {85 const [columns, setColumns] = useState(initialColumns);86 const [rowGroups, setRowGroups] = useState<PillManager.T.PillItem[]>([87 { name: "Education Level", id: "Education Level", active: true, movable: true },88 { name: "Gender", id: "Gender", active: false, movable: true },89 { name: "Age", id: "Age", active: false, movable: true },90 { name: "YoE", id: "Years of Experience", active: false, movable: true },91 ]);92
93 const model = useMemo(() => rowGroups.filter((x) => x.active).map((x) => x.id), [rowGroups]);94
95 const aggModel = useMemo(() => {96 return Object.fromEntries(columns.map((x) => [x.id, { fn: x.agg }]));97 }, [columns]);98
99 const ds = useServerDataSource({100 queryFn: (params) => {101 return Server(params.requests, params.queryKey[0], params.queryKey[1]);102 },103
104 hasRowBranches: model.length > 0,105 queryKey: [model, aggModel] as const,106 blockSize: 50,107 });108
109 const isLoading = ds.isLoading.useValue();110
111 return (112 <>113 <PillManager114 onPillItemActiveChange={(p) => {115 setRowGroups((prev) => {116 return [...prev].map((x) => {117 if (p.item.id === x.id) {118 return { ...x, active: p.item.active };119 }120 return x;121 });122 });123 }}124 onPillRowChange={(ev) => {125 setRowGroups(ev.changed[0].pills);126 }}127 rows={[128 {129 id: "row-groups",130 label: "Row Groups",131 type: "row-groups",132 pills: rowGroups,133 },134 ]}135 />136 <div className="ln-grid" style={{ height: 500 }}>137 <Grid138 rowSource={ds}139 columns={columns}140 rowGroupColumn={group}141 onColumnsChange={setColumns}142 slotViewportOverlay={143 isLoading && (144 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>145 )146 }147 />148 </div>149 </>150 );151}152
39 collapsed lines
153export function HeaderCell({ api, column }: Grid.T.HeaderParams<GridSpec>) {154 return (155 <div156 className={tw(157 "flex items-center justify-between gap-2",158 column.type === "number" && "flex-row-reverse",159 )}160 >161 <div>{column.name ?? column.id}</div>162 {column.agg && (163 <Menu>164 <Menu.Trigger className="text-ln-primary-50 hover:bg-ln-bg-strong cursor-pointer rounded px-1 py-1 text-[10px] transition-colors">165 ({column.agg})166 </Menu.Trigger>167 <Menu.Popover>168 <Menu.Arrow />169 <Menu.Container>170 <Menu.RadioGroup171 value={column.agg}172 onChange={(x) => {173 api.columnUpdate({ [column.id]: { agg: x } });174 }}175 >176 {column.allowedAggs.map((x) => {177 return (178 <Menu.RadioItem key={x} value={x} className="flex items-center justify-between gap-1">179 {x}180 {column.agg === x && <CheckIcon className="text-ln-primary-50" />}181 </Menu.RadioItem>182 );183 })}184 </Menu.RadioGroup>185 </Menu.Container>186 </Menu.Popover>187 </Menu>188 )}189 </div>190 );191}1import type { Grid } from "@1771technologies/lytenyte-pro";2import { twMerge } from "tailwind-merge";3import clsx, { type ClassValue } from "clsx";4import type { GridSpec } from "./demo";5
6export function tw(...c: ClassValue[]) {7 return twMerge(clsx(...c));8}9
10function SkeletonLoading() {11 return (12 <div className="h-full w-full p-2">13 <div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div>14 </div>15 );16}17
18const formatter = new Intl.NumberFormat("en-Us", {19 minimumFractionDigits: 0,20 maximumFractionDigits: 0,21});22export function SalaryRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {23 const field = api.columnField(column, row);24
25 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;26
27 if (typeof field !== "number") return "-";28
29 return <div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>;30}31
32export function YearsOfExperienceRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {33 const field = api.columnField(column, row);34
35 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;36
37 if (typeof field !== "number") return "-";38
39 return (40 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">41 <span className="font-bold">{formatter.format(field)}</span>{" "}42 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>43 </div>44 );45}46
47export function AgeCellRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {48 const field = api.columnField(column, row);49
50 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;51
52 if (typeof field !== "number") return "-";53
54 return (55 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">56 <span className="font-bold">{formatter.format(field)}</span>{" "}57 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>58 </div>59 );60}61
62export function BaseCellRenderer({ row, column, api }: Grid.T.CellRendererParams<GridSpec>) {63 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;64
65 const field = api.columnField(column, row);66
67 return <div className="flex h-full w-full items-center">{(field as string) ?? "-"}</div>;68}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { SalaryData } from "./data";3import { data } from "./data.js";4
5const sleep = () => new Promise((res) => setTimeout(res, 600));6
7export async function Server(8 reqs: DataRequest[],9 groupModel: string[],10 aggModel: { [columnId: string]: { fn: string } },11) {12 // Simulate latency and server work.13 await sleep();14
15 return reqs.map((c) => {16 // Return flat items if there are no row groups17 if (!groupModel.length) {18 return {19 asOfTime: Date.now(),20 data: data.slice(c.start, c.end).map((x) => {21 return {22 kind: "leaf",23 id: x.id,24 data: x,25 };26 }),27 start: c.start,28 end: c.end,29 kind: "center",30 path: c.path,31 size: data.length,32 } satisfies DataResponse;33 }34
35 const groupLevel = c.path.length;36 const groupKeys = groupModel.slice(0, groupLevel + 1);37
38 const filteredForGrouping = data.filter((row) => {39 return c.path.every((v, i) => {40 const groupKey = groupModel[i];41 return `${row[groupKey as keyof SalaryData]}` === v;42 });43 });44
45 // This is the leaf level of the grouping46 if (groupLevel === groupModel.length) {47 return {48 kind: "center",49 asOfTime: Date.now(),50 start: c.start,51 end: c.end,52 path: c.path,53 data: filteredForGrouping.slice(c.start, c.end).map((x) => {54 return {55 kind: "leaf",56 id: x.id,57 data: x,58 };59 }),60 size: filteredForGrouping.length,61 } satisfies DataResponse;62 }63
64 const groupedData = Object.groupBy(filteredForGrouping, (r) => {65 const groupPath = groupKeys.map((g) => {66 if (typeof g !== "string")67 throw new Error("Non-string groups are not supported by this dummy implementation");68
69 return r[g as keyof SalaryData];70 });71
72 return groupPath.join(" / ");73 });74
75 // Sort the groups to make them nicer76 const rows = Object.entries(groupedData).sort((x, y) => {77 const left = x[0];78 const right = y[0];79
80 const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());81 const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());82
83 if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {84 if (!left && !right) return 0;85 if (!left) return 1;86 if (!right) return -1;87
88 return left.localeCompare(right);89 }90
91 return asNumberLeft - asNumberRight;92 });93
94 return {95 kind: "center",96 asOfTime: Date.now(),97 data: rows.slice(c.start, c.end).map((x) => {98 const childRows = x[1]!;99
100 const nextGroup = groupLevel + 1;101 let childCnt: number;102 if (nextGroup === groupModel.length) childCnt = childRows.length;103 else {104 childCnt = Object.keys(105 Object.groupBy(childRows, (x) => {106 const groupKey = groupModel[nextGroup];107 return x[groupKey as keyof SalaryData];108 }),109 ).length;110 }111
112 const aggData = Object.fromEntries(113 Object.entries(aggModel)114 .map(([column, m]) => {115 if (typeof m.fn !== "string")116 throw new Error("Non-string aggregations are not supported by this dummy implementation");117
118 const id = column as keyof SalaryData;119
120 if (m.fn === "first") return [column, childRows[0][id]];121 if (m.fn === "last") return [column, childRows.at(-1)![id]];122
123 if (m.fn === "avg")124 return [column, childRows.reduce((acc, x) => acc + (x[id] as number), 0) / childRows.length];125
126 if (m.fn === "sum") return [column, childRows.reduce((acc, x) => acc + (x[id] as number), 0)];127
128 if (m.fn === "min") return [column, Math.min(...childRows.map((x) => x[id] as number))];129 if (m.fn === "max") return [column, Math.max(...childRows.map((x) => x[id] as number))];130 })131 .filter(Boolean) as [string, number | string][],132 );133
134 return {135 kind: "branch",136 childCount: childCnt,137 data: aggData,138 id: x[0],139 key: x[0].split(" / ").at(-1)!,140 };141 }),142
143 path: c.path,144 start: c.start,145 end: c.end,146 size: rows.length,147 } satisfies DataResponse;148 });149}Not every column requires aggregation, especially text columns. The server must return correct aggregate values, as LyteNyte Grid does not validate them and simply trusts the server’s 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
1"use client";2import "@1771technologies/lytenyte-pro/pill-manager.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";5
6import { useMemo, useState } from "react";7import { Server } from "./server.jsx";8import type { SalaryData } from "./data.js";9import {10 AgeCellRenderer,11 BaseCellRenderer,12 SalaryRenderer,13 YearsOfExperienceRenderer,14} from "./components.jsx";15import { PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";16
17export interface GridSpec {18 readonly data: SalaryData;19}20
21const columns: Grid.Column<GridSpec>[] = [22 {23 id: "Gender",24 width: 120,25 widthFlex: 1,26 cellRenderer: BaseCellRenderer,27 },28 {29 id: "Education Level",30 name: "Education",31 width: 160,32 hide: true,33 widthFlex: 1,34 cellRenderer: BaseCellRenderer,35 },36 {37 id: "Age",38 type: "number",39 width: 100,40 widthFlex: 1,41 cellRenderer: AgeCellRenderer,42 },43 {44 id: "Years of Experience",45 name: "YoE",46 type: "number",47 width: 100,48 widthFlex: 1,49 cellRenderer: YearsOfExperienceRenderer,50 },51 { id: "Salary", type: "number", width: 160, widthFlex: 1, cellRenderer: SalaryRenderer },52];53
54const group: Grid.RowGroupColumn<GridSpec> = {55 cellRenderer: (p) => {56 return (57 <RowGroupCell58 {...p}59 groupLabel={(row) => (60 <div className="flex items-baseline gap-1">61 <div>{row.key || "(blank)"}</div>62 <div className="text-ln-text-xlight text-xs">({row.data.childCnt as number})</div>63 </div>64 )}65 />66 );67 },68 width: 200,69 pin: "start",70};71
72export default function ServerDataDemo() {73 const [rowGroups, setRowGroups] = useState<PillManager.T.PillItem[]>([74 { name: "Education Level", id: "Education Level", active: true, movable: true },75 { name: "Gender", id: "Gender", active: false, movable: true },76 { name: "Age", id: "Age", active: false, movable: true },77 { name: "YoE", id: "Years of Experience", active: false, movable: true },78 ]);79
80 const model = useMemo(() => rowGroups.filter((x) => x.active).map((x) => x.id), [rowGroups]);81
82 const ds = useServerDataSource({83 queryFn: (params) => {84 return Server(params.requests, params.queryKey[0]);85 },86 hasRowBranches: model.length > 0,87 queryKey: [model] as const,88 blockSize: 50,89 });90
91 const isLoading = ds.isLoading.useValue();92
93 return (94 <>95 <PillManager96 onPillItemActiveChange={(p) => {97 setRowGroups((prev) => {98 return [...prev].map((x) => {99 if (p.item.id === x.id) {100 return { ...x, active: p.item.active };101 }102 return x;103 });104 });105 }}106 onPillRowChange={(ev) => {107 setRowGroups(ev.changed[0].pills);108 }}109 rows={[110 {111 id: "row-groups",112 label: "Row Groups",113 type: "row-groups",114 pills: rowGroups,115 },116 ]}117 />118 <div className="ln-grid" style={{ height: 500 }}>119 <Grid120 rowSource={ds}121 columns={columns}122 rowGroupColumn={group}123 slotViewportOverlay={124 isLoading && (125 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>126 )127 }128 />129 </div>130 </>131 );132}1import type { Grid } from "@1771technologies/lytenyte-pro";2import { twMerge } from "tailwind-merge";3import clsx, { type ClassValue } from "clsx";4import type { GridSpec } from "./demo";5
6export function tw(...c: ClassValue[]) {7 return twMerge(clsx(...c));8}9
10function SkeletonLoading() {11 return (12 <div className="h-full w-full p-2">13 <div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div>14 </div>15 );16}17
18const formatter = new Intl.NumberFormat("en-Us", {19 minimumFractionDigits: 0,20 maximumFractionDigits: 0,21});22export function SalaryRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {23 const field = api.columnField(column, row);24
25 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;26
27 if (typeof field !== "number") return "-";28
29 return <div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>;30}31
32export function YearsOfExperienceRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {33 const field = api.columnField(column, row);34
35 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;36
37 if (typeof field !== "number") return "-";38
39 return (40 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">41 <span className="font-bold">{field}</span>{" "}42 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>43 </div>44 );45}46
47export function AgeCellRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {48 const field = api.columnField(column, row);49
50 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;51
52 if (typeof field !== "number") return "-";53
54 return (55 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">56 <span className="font-bold">{formatter.format(field)}</span>{" "}57 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>58 </div>59 );60}61
62export function BaseCellRenderer({ row, column, api }: Grid.T.CellRendererParams<GridSpec>) {63 if (api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;64
65 const field = api.columnField(column, row);66
67 return <div className="flex h-full w-full items-center">{(field as string) ?? "-"}</div>;68}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { SalaryData } from "./data.js";3import { data } from "./data.js";4
5const sleep = () => new Promise((res) => setTimeout(res, 600));6
7export async function Server(reqs: DataRequest[], groupModel: string[]) {8 // Simulate latency and server work.9 await sleep();10
11 return reqs.map((c) => {12 // Return flat items if there are no row groups13 if (!groupModel.length) {14 return {15 asOfTime: Date.now(),16 data: data.slice(c.start, c.end).map((x) => {17 return {18 kind: "leaf",19 id: x.id,20 data: x,21 };22 }),23 start: c.start,24 end: c.end,25 kind: "center",26 path: c.path,27 size: data.length,28 } satisfies DataResponse;29 }30
31 const groupLevel = c.path.length;32 const groupKeys = groupModel.slice(0, groupLevel + 1);33
34 const filteredForGrouping = data.filter((row) => {35 return c.path.every((v, i) => {36 const groupKey = groupModel[i];37 return `${row[groupKey as keyof SalaryData]}` === v;38 });39 });40
41 // This is the leaf level of the grouping42 if (groupLevel === groupModel.length) {43 return {44 kind: "center",45 asOfTime: Date.now(),46 start: c.start,47 end: c.end,48 path: c.path,49 data: filteredForGrouping.slice(c.start, c.end).map((x) => {50 return {51 kind: "leaf",52 id: x.id,53 data: x,54 };55 }),56 size: filteredForGrouping.length,57 } satisfies DataResponse;58 }59
60 const groupedData = Object.groupBy(filteredForGrouping, (r) => {61 const groupPath = groupKeys.map((g) => {62 if (typeof g !== "string")63 throw new Error("Non-string groups are not supported by this dummy implementation");64
65 return r[g as keyof SalaryData];66 });67
68 return groupPath.join(" / ");69 });70
71 // Sort the groups to make them nicer72 const rows = Object.entries(groupedData).sort((x, y) => {73 const left = x[0];74 const right = y[0];75
76 const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());77 const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());78
79 if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {80 if (!left && !right) return 0;81 if (!left) return 1;82 if (!right) return -1;83
84 return left.localeCompare(right);85 }86
87 return asNumberLeft - asNumberRight;88 });89
90 return {91 kind: "center",92 asOfTime: Date.now(),93 data: rows.slice(c.start, c.end).map((x) => {94 const childRows = x[1]!;95
96 const nextGroup = groupLevel + 1;97 let childCnt: number;98 if (nextGroup === groupModel.length) childCnt = childRows.length;99 else {100 childCnt = Object.keys(101 Object.groupBy(childRows, (x) => {102 const groupKey = groupModel[nextGroup];103 return x[groupKey as keyof SalaryData];104 }),105 ).length;106 }107
108 return {109 kind: "branch",110 childCount: childCnt,111 data: { childCnt },112 id: x[0],113 key: x[0].split(" / ").at(-1)!,114 };115 }),116
117 path: c.path,118 start: c.start,119 end: c.end,120 size: rows.length,121 } satisfies DataResponse;122 });123}Note
Include child counts directly in the row data to make them accessible to cell renderers. The child count property on the group row response belongs to the server data loading interface, not the row interface.
Next Steps
- Unbalanced Rows (Tree Data): 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: Pre-fetch data using optimistic loading to reduce perceived latency.
