Server Row Sorting
LyteNyte Grid's server data source requests sorted rows by sending a defined sort model. The server applies the sort and returns the corresponding slice of sorted data.
Retrieving Sorted Row Data
The LyteNyte Grid state object maintains a sort model, which declaratively describes how rows should be sorted. The row data source is responsible for applying this sorting. When using a server data source, sorting must occur on the server because only the server has access to the complete dataset.
In the example below, and all examples that follow, clicking a column header cycles through its sort states. Each time the sort changes, the grid resets its rows and fetches a new batch of sorted data from the server.
Row Sorting
"use client";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 { MovieData } from "./data";import {GenreRenderer,HeaderRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";const columns: Column<MovieData>[] = [{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function RowSorting() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, params.model.sorts);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,sortModel: [{ columnId: "name", sort: { kind: "string" }, isDescending: false }],columnBase: {headerRenderer: HeaderRenderer,},});const view = grid.view.useValue();return (<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="flex h-full w-full items-center text-sm capitalize"/>);})}</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="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererFn,HeaderCellRendererParams,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import type { JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";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>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export function HeaderRenderer({ column, grid }: HeaderCellRendererParams<MovieData>) {const sort = grid.state.sortModel.useValue().find((c) => c.columnId === column.id);const isDescending = sort?.isDescending ?? false;return (<divclassName="hover:bg-ln-gray-10 flex h-full w-full items-center px-2 text-sm transition-all"onClick={() => {const current = grid.api.sortForColumn(column.id);if (current == null) {let sort: SortModelItem<MovieData>;const columnId = column.id;if (column.type === "datetime") {sort = {columnId,sort: { kind: "date", options: { includeTime: true } },};} else if (column.type === "number") {sort = { columnId, sort: { kind: "number" } };} else {sort = { columnId, sort: { kind: "string" } };}grid.state.sortModel.set([sort]);return;}if (!current.sort.isDescending) {grid.state.sortModel.set([{ ...current.sort, isDescending: true }]);} else {grid.state.sortModel.set([]);}}}>{column.name ?? column.id}{sort && (<>{!isDescending ? (<ArrowUpIcon className="size-4" />) : (<ArrowDownIcon className="size-4" />)}</>)}</div>);}
import type {DataRequest,DataResponse,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data as rawData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], sortModel: SortModelItem<MovieData>[]) {// Simulate latency and server work.await sleep();let data = rawData;if (sortModel.length) {data = rawData.toSorted((l, r) => {for (const m of sortModel) {if (m.sort.kind === "custom")throw new Error("This server implementation does not support custom sorts");const id = m.columnId as keyof MovieData;// Notice we are ignoring the sort type on the SortModelItem. This is common// when sorting on the server, since the server already has knowledge of the// columns data typeconst leftValue = l[id];const rightValue = r[id];// Check null states before moving on to checking sort valuesif (!leftValue && !rightValue) continue;else if (leftValue && !rightValue) return -1;else if (!leftValue && rightValue) return 1;let val = 0;if (id === "link" || id === "name" || id === "genre" || id === "type") {val = leftValue.localeCompare(rightValue);} else if (id === "released_at") {if (!leftValue && !rightValue) val = 0;else if (leftValue && !rightValue) val = -1;else if (!leftValue && rightValue) val = 1;else {const leftDate = new Date(leftValue);const rightDate = new Date(rightValue);if (leftDate < rightDate) val = -1;else if (leftDate > rightDate) val = 1;else val = 0;}} else if (id === "imdb_rating") {const left = Number.parseFloat(leftValue.split("/")[0]);const right = Number.parseFloat(rightValue.split("/")[0]);val = left - right;}if (val !== 0) return m.isDescending ? -val : val;}return 0;});}return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
Server-side sorting often ignores some parts of the grid's sort model. For example, the implementation below
uses only the column id
and ignores the sort's kind
. This approach is typical, as the server understands the
data types for each column. For example, SQL uses ORDER BY
clauses rather than data-type specific sorting logic.
for (const m of sortModel) {const id = m.columnId;const leftValue = l[id];const rightValue = r[id];// Check null states before comparing sort valuesif (!leftValue && !rightValue) continue;else if (leftValue && !rightValue) return -1;else if (!leftValue && rightValue) return 1;let val = 0;if (id === "link" || id === "name" || id === "genre" || id === "type") {// Sort logic - see code example} else if (id === "released_at") {// Sort logic - see code example} else if (id === "imdb_rating") {// Sort logic - see code example}if (val !== 0) return m.isDescending ? -val : val;}
Handling Sort Model Options
LyteNyte Grid's sort model supports options such as placing null
values first. These options can be included
in the model sent to the server, but the server must interpret and implement them. The following example handles
the nullsFirst
option; other options can follow a similar pattern:
Nulls First
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useEffect, useId, useState } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,HeaderRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";import { context } from "./nulls-first-context";const columns: Column<MovieData>[] = [{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function SortingNullsFirst() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, params.model.sorts);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,sortModel: [{columnId: "released_at",sort: { kind: "string", options: { nullsFirst: true } },isDescending: false,},],columnBase: {headerRenderer: HeaderRenderer,},});const [nullsFirst, setNullsFirst] = useState(true);const view = grid.view.useValue();useEffect(() => {grid.state.sortModel.set((prev) => {return prev.map((c) => {return {...c,sort: { ...c.sort, options: { ...c.sort.options, nullsFirst: nullsFirst } },};});});}, [grid.state.sortModel, nullsFirst]);return (<context.Provider value={nullsFirst}><div><div className="border-ln-gray-20 flex items-center border-b px-2 py-1"><label className="flex items-center gap-1"><inputtype="checkbox"checked={nullsFirst}onChange={(e) => setNullsFirst(e.target.checked)}/>Display Nulls First</label></div><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="flex h-full w-full items-center text-sm capitalize"/>);})}</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="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></div></context.Provider>);}
import type {CellRendererFn,HeaderCellRendererParams,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { useContext, type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";import { context } from "./nulls-first-context";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>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export function HeaderRenderer({ column, grid }: HeaderCellRendererParams<MovieData>) {const sort = grid.state.sortModel.useValue().find((c) => c.columnId === column.id);const nullsFirstOption = useContext(context);const isDescending = sort?.isDescending ?? false;return (<divclassName="hover:bg-ln-gray-10 flex h-full w-full items-center px-2 text-sm transition-all"onClick={() => {const current = grid.api.sortForColumn(column.id);if (current == null) {let sort: SortModelItem<MovieData>;const columnId = column.id;if (column.type === "datetime") {sort = {columnId,sort: { kind: "date", options: { includeTime: true, nullsFirst: nullsFirstOption } },};} else if (column.type === "number") {sort = {columnId,sort: { kind: "number", options: { nullsFirst: nullsFirstOption } },};} else {sort = {columnId,sort: { kind: "string", options: { nullsFirst: nullsFirstOption } },};}grid.state.sortModel.set([sort]);return;}if (!current.sort.isDescending) {grid.state.sortModel.set([{ ...current.sort, isDescending: true }]);} else {grid.state.sortModel.set([]);}}}>{column.name ?? column.id}{sort && (<>{!isDescending ? (<ArrowUpIcon className="size-4" />) : (<ArrowDownIcon className="size-4" />)}</>)}</div>);}
import type {DataRequest,DataResponse,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data as rawData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], sortModel: SortModelItem<MovieData>[]) {// Simulate latency and server work.await sleep();let data = rawData;if (sortModel.length) {data = rawData.toSorted((l, r) => {for (const m of sortModel) {if (m.sort.kind === "custom")throw new Error("This server implementation does not support custom sorts");const id = m.columnId as keyof MovieData;// Notice we are ignoring the sort type on the SortModelItem. This is common// when sorting on the server, since the server already has knowledge of the// columns data typeconst leftValue = l[id];const rightValue = r[id];const isNullsFirst = m.sort.options?.nullsFirst;// Check null states before moving on to checking sort valuesif (!leftValue && !rightValue) continue;else if (leftValue && !rightValue) return isNullsFirst ? 1 : -1;else if (!leftValue && rightValue) return isNullsFirst ? -1 : 1;let val = 0;if (id === "link" || id === "name" || id === "genre" || id === "type") {val = leftValue.localeCompare(rightValue);} else if (id === "released_at") {if (!leftValue && !rightValue) val = 0;else if (leftValue && !rightValue) val = -1;else if (!leftValue && rightValue) val = 1;else {const leftDate = new Date(leftValue);const rightDate = new Date(rightValue);if (leftDate < rightDate) val = -1;else if (leftDate > rightDate) val = 1;else val = 0;}} else if (id === "imdb_rating") {const left = Number.parseFloat(leftValue.split("/")[0]);const right = Number.parseFloat(rightValue.split("/")[0]);val = left - right;}if (val !== 0) return m.isDescending ? -val : val;}return 0;});}return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
const isNullsFirst = m.sort.options?.nullsFirst;// Check null states before comparing sort valuesif (!leftValue && !rightValue) continue;else if (leftValue && !rightValue) return isNullsFirst ? 1 : -1;else if (!leftValue && rightValue) return isNullsFirst ? -1 : 1;
The null
value doesn't have to represent JavaScript's null
. The server in this example treats empty strings as
null
. It's up to your implementation to define what null
means and how to order it. In Python, this might be
None
; in SQL, NULL
or NaN
.
Using a Different Sort Model
LyteNyte Grid defines its own sort model, but you may already use a different one in your application or backend. The grid doesn't require you to use its model. Developers can supply a custom sort model to the data fetcher. The example below demonstrates how:
Custom Sort Model
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useEffect, useId, useMemo, useRef, useState } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,HeaderRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";import type { CustomSort } from "./custom-sort-context";import { context } from "./custom-sort-context";const columns: Column<MovieData>[] = [{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function SortingNullsFirst() {const [sort, setSort] = useState<CustomSort | null>({ columnId: "name", isDescending: false });const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, sort);},dataFetchExternals: [sort],blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,columnBase: {headerRenderer: HeaderRenderer,},});const view = grid.view.useValue();return (<context.Provider value={useMemo(() => ({ sort, setSort }), [sort])}><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="flex h-full w-full items-center text-sm capitalize"/>);})}</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="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></context.Provider>);}
import type {CellRendererFn,HeaderCellRendererParams,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { useContext, type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";import { context } from "./custom-sort-context";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>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export function HeaderRenderer({ column }: HeaderCellRendererParams<MovieData>) {const customSort = useContext(context);const isDescending = customSort.sort?.isDescending ?? false;return (<divclassName="hover:bg-ln-gray-10 flex h-full w-full items-center px-2 text-sm transition-all"onClick={() => {const sort = customSort.sort;const current = sort?.columnId === column.id ? sort : null;if (current == null) {customSort.setSort({ columnId: column.id, isDescending: false });} else if (!current.isDescending) {customSort.setSort({ columnId: column.id, isDescending: true });} else {customSort.setSort(null);}}}>{column.name ?? column.id}{customSort.sort?.columnId === column.id && (<>{!isDescending ? (<ArrowUpIcon className="size-4" />) : (<ArrowDownIcon className="size-4" />)}</>)}</div>);}
import type { Dispatch, SetStateAction } from "react";import { createContext } from "react";export interface CustomSort {readonly columnId: string;readonly isDescending: boolean;}export const context = createContext<{sort: CustomSort | null;setSort: Dispatch<SetStateAction<CustomSort | null>>;}>({sort: null,setSort: () => {},});
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data as rawData } from "./data";import type { CustomSort } from "./custom-sort-context";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], sortModel: CustomSort | null) {// Simulate latency and server work.await sleep();let data = rawData;if (sortModel) {data = rawData.toSorted((l, r) => {const id = sortModel.columnId as keyof MovieData;const leftValue = l[id];const rightValue = r[id];// Check null states before moving on to checking sort valuesif (!leftValue && !rightValue) return 0;else if (leftValue && !rightValue) return -1;else if (!leftValue && rightValue) return 1;let val = 0;if (id === "link" || id === "name" || id === "genre" || id === "type") {val = leftValue.localeCompare(rightValue);} else if (id === "released_at") {if (!leftValue && !rightValue) val = 0;else if (leftValue && !rightValue) val = -1;else if (!leftValue && rightValue) val = 1;else {const leftDate = new Date(leftValue);const rightDate = new Date(rightValue);if (leftDate < rightDate) val = -1;else if (leftDate > rightDate) val = 1;else val = 0;}} else if (id === "imdb_rating") {const left = Number.parseFloat(leftValue.split("/")[0]);const right = Number.parseFloat(rightValue.split("/")[0]);val = left - right;}if (val !== 0) return sortModel.isDescending ? -val : val;return val;});}return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
This example defines a sort model outside of the LyteNyte Grid state. It includes three key steps:
Injecting External Data Into the Data Fetcher
The server data source's dataFetcher
function doesn't automatically capture updates from React state. Instead
dependencies must be explicitly declared using the dataFetchExternals
property. When sort
changes, LyteNyte
Grid will detect that change in dependencies and refetch row data from the server.
const [sort, setSort] = useState<{ sort: CustomSort | null }>(null);const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, sort);},dataFetchExternals: [sort],});
Creating External State
Create the external sort model however you prefer. In this example, React's useState
and context are used
to share the state:
export const context = createContext<{sort: CustomSort | null;setSort: Dispatch<SetStateAction<CustomSort | null>>;}>({sort: null,setSort: () => {},});
Provide this context to the grid:
const [sort, setSort] = useState<CustomSort | null>({ columnId: "name", isDescending: false });return (<context.Provider value={useMemo(() => ({ sort, setSort }), [sort])}>... Grid defined here</context.Provider>);
Updating External State from Components
The grid's header renderer accesses the context and updates the external sort state on click:
export function HeaderRenderer({ column }: HeaderCellRendererParams<MovieData>) {const customSort = useContext(context);const isDescending = customSort.sort?.isDescending ?? false;return (<divonClick={() => {const sort = customSort.sort;const current = sort?.columnId === column.id ? sort : null;if (current == null) {customSort.setSort({ columnId: column.id, isDescending: false });} else if (!current.isDescending) {customSort.setSort({ columnId: column.id, isDescending: true });} else {customSort.setSort(null);}}}>... Header content</div>);}
These steps represent one approach. External state libraries like Zustand or Jotai provide simpler mechanisms for sharing and syncing state outside React components.
Next Steps
- Server Row Filtering: server-side filtering, including in filters (tree set filters) and quick search filters.
- Server Row Pinning: pin rows to keep specific data visible.
- Server Row Grouping and Aggregation: handle grouped data and load group slices.
- Handling Load Failures: explore how to handle failed data requests.
Server Row Data
LyteNyte Grid's server data source loads row data in slices and retrieves data on demand.
Server Row Filtering
LyteNyte Grid defines several filter models that the server data source can use to filter rows on the server. The server may also define its own custom filter model to implement application-specific filtering logic.