New table component (#995)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced data table with cursor-based pagination, per-page caching and
background prefetching for faster navigation
  * Pagination controls with page-size selector and Prev/Next navigation
  * Skeleton loading screens for smoother transitions
* URL-synchronized query state with debounced search and shareable
filters
* Redesigned user management table with improved columns, actions
(including copy-to-clipboard), filtering, sorting, and accessible
controls
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2025-11-05 17:40:27 -08:00 committed by GitHub
parent 2e892664f3
commit 35a56f721f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1325 additions and 220 deletions

View File

@ -0,0 +1,50 @@
"use client";
import { useCallback, useRef } from "react";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
export function useCursorPaginationCache(initialPage: number = 1) {
const cursorCacheRef = useRef(new Map<number, string | null>([[initialPage, null]]));
const prefetchedCursorRef = useRef(new Set<string>());
const resetCache = useCallback(() => {
cursorCacheRef.current = new Map<number, string | null>([[initialPage, null]]);
prefetchedCursorRef.current.clear();
}, [initialPage]);
const readCursorForPage = useCallback((page: number): string | null | undefined => {
return cursorCacheRef.current.get(page);
}, []);
const recordPageCursor = useCallback((page: number, cursor: string | null | undefined) => {
cursorCacheRef.current.set(page, cursor ?? null);
}, []);
const recordNextCursor = useCallback((page: number, nextCursor: string | null | undefined) => {
if (nextCursor) {
cursorCacheRef.current.set(page + 1, nextCursor);
} else {
cursorCacheRef.current.delete(page + 1);
}
}, []);
const prefetchCursor = useCallback((cursor: string | null | undefined, task: () => void | Promise<void>) => {
if (!cursor) {
return;
}
if (prefetchedCursorRef.current.has(cursor)) {
return;
}
prefetchedCursorRef.current.add(cursor);
runAsynchronously(task());
}, []);
return {
resetCache,
readCursorForPage,
recordPageCursor,
recordNextCursor,
prefetchCursor,
};
}

View File

@ -0,0 +1,77 @@
"use client";
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import { combineClassNames } from "./table";
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50];
type PaginationControlsProps = {
page: number,
pageSize: number,
hasNextPage: boolean,
hasPreviousPage: boolean,
onNextPage: () => void,
onPreviousPage: () => void,
onPageSizeChange: (pageSize: number) => void,
pageSizeOptions?: number[],
pageSizeLabel?: string,
pageIndicatorLabel?: (page: number) => ReactNode,
className?: string,
};
const defaultIndicator = (page: number) => <>Page {page}</>;
export function PaginationControls(props: PaginationControlsProps) {
const {
page,
pageSize,
hasNextPage,
hasPreviousPage,
onNextPage,
onPreviousPage,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
pageSizeLabel = "Rows per page",
pageIndicatorLabel = defaultIndicator,
className,
} = props;
return (
<div className={combineClassNames("flex flex-col gap-3 border-border/70 px-4 py-3 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between", className)}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{pageSizeLabel}</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="w-20" aria-label={`${pageSizeLabel}: ${pageSize}`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onPreviousPage} disabled={!hasPreviousPage}>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<span className="rounded-md border border-border px-3 py-1 text-xs font-medium text-foreground">
{pageIndicatorLabel(page)}
</span>
<Button variant="ghost" size="sm" onClick={onNextPage} disabled={!hasNextPage}>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
"use client";
import { useRef } from "react";
export function useStableValue<T>(value: T, fingerprint: string): T {
const previousRef = useRef<{ fingerprint: string, value: T }>();
if (previousRef.current && previousRef.current.fingerprint === fingerprint) {
return previousRef.current.value;
}
previousRef.current = { fingerprint, value };
return value;
}

View File

@ -0,0 +1,69 @@
"use client";
import type { ReactNode } from "react";
import {
DEFAULT_ROW_HEIGHT_PX,
combineClassNames,
getColumnStyles,
getRowHeightStyle,
type ColumnLayout,
} from "./table";
type TableSkeletonProps<TColumnKey extends string> = {
columnOrder: readonly TColumnKey[],
columnLayout: Partial<ColumnLayout<TColumnKey>>,
headerLabels: Partial<Record<TColumnKey, ReactNode | null>>,
rowCount: number,
renderCellSkeleton: (columnKey: TColumnKey, rowIndex: number) => ReactNode,
rowHeightPx?: number,
};
export function TableSkeleton<TColumnKey extends string>(props: TableSkeletonProps<TColumnKey>) {
const { columnOrder, columnLayout, headerLabels, rowCount, renderCellSkeleton, rowHeightPx } = props;
const rows = Array.from({ length: rowCount });
const rowStyle = getRowHeightStyle(rowHeightPx ?? DEFAULT_ROW_HEIGHT_PX);
return (
<div className="flex flex-col">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-muted/80 text-xs font-semibold tracking-wide text-muted-foreground backdrop-blur">
<tr className="border-b border-border/70">
{columnOrder.map((columnKey) => {
const layout = columnLayout[columnKey];
return (
<th
key={columnKey}
className={combineClassNames("px-4 py-3", layout?.headerClassName)}
style={getColumnStyles(layout)}
>
{headerLabels[columnKey] ?? null}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map((_, rowIndex) => (
<tr key={rowIndex} className="border-b border-border/60" style={rowStyle}>
{columnOrder.map((columnKey) => {
const layout = columnLayout[columnKey];
return (
<td
key={columnKey}
className={combineClassNames("px-4 py-2", layout?.cellClassName)}
style={getColumnStyles(layout)}
>
{renderCellSkeleton(columnKey, rowIndex)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
"use client";
import { flexRender, type Table } from "@tanstack/react-table";
import type { CSSProperties } from "react";
export type ColumnLayoutEntry = {
size: number,
minWidth: number,
maxWidth: number,
width: string,
headerClassName?: string,
cellClassName?: string,
};
export type ColumnLayout<TColumnKey extends string> = Record<TColumnKey, ColumnLayoutEntry>;
export type ColumnMeta<TColumnKey extends string> = {
columnKey: TColumnKey,
};
export const DEFAULT_ROW_HEIGHT_PX = 50;
export function getRowHeightStyle(heightPx: number = DEFAULT_ROW_HEIGHT_PX) {
return { height: heightPx } satisfies CSSProperties;
}
export function combineClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(" ");
}
export function getColumnStyles(layout?: ColumnLayoutEntry) {
if (!layout) {
return undefined;
}
return {
width: layout.width,
minWidth: layout.minWidth,
maxWidth: layout.maxWidth,
} satisfies CSSProperties;
}
type TableContentProps<TData, TColumnKey extends string> = {
table: Table<TData>,
columnLayout: Partial<ColumnLayout<TColumnKey>>,
renderEmptyState: () => React.ReactNode,
rowHeightPx?: number,
};
export function TableContent<TData, TColumnKey extends string>(props: TableContentProps<TData, TColumnKey>) {
const {
table,
columnLayout,
renderEmptyState,
rowHeightPx,
} = props;
const resolveColumnKey = ((meta: unknown) => (meta as ColumnMeta<TColumnKey> | undefined)?.columnKey);
const rowHeightStyle = getRowHeightStyle(rowHeightPx ?? DEFAULT_ROW_HEIGHT_PX);
return (
<div className="overflow-x-auto">
<table className={"w-full border-collapse text-left text-sm text-foreground"}>
<thead className="sticky top-0 z-10 bg-muted/80 text-xs font-semibold tracking-wide text-muted-foreground backdrop-blur">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-border/70">
{headerGroup.headers.map((header) => {
const columnKey = resolveColumnKey(header.column.columnDef.meta);
const layout = columnKey ? columnLayout[columnKey] : undefined;
return (
<th
key={header.id}
className={combineClassNames("px-4 py-3 font-medium", layout?.headerClassName)}
style={getColumnStyles(layout)}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="border-b border-border/60 transition hover:bg-muted/60"
style={rowHeightStyle}
>
{row.getVisibleCells().map((cell) => {
const columnKey = resolveColumnKey(cell.column.columnDef.meta);
const layout = columnKey ? columnLayout[columnKey] : undefined;
return (
<td
key={cell.id}
className={combineClassNames("px-4 py-2 align-middle", layout?.cellClassName)}
style={getColumnStyles(layout)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))
) : (
<tr>
<td
colSpan={table.getAllColumns().length}
className="px-6 py-12 text-center text-sm text-muted-foreground"
>
{renderEmptyState()}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,93 @@
"use client";
import { useRouter } from "@/components/router";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { AnyObjectSchema } from "yup";
type Updater<TState> = Partial<TState> | ((prev: TState) => Partial<TState>);
export type UseUrlQueryStateOptions<TState> = {
schema: AnyObjectSchema,
defaultState: TState,
sanitize?: (state: Partial<TState>) => TState,
serialize?: (state: TState) => URLSearchParams,
isEqual?: (a: TState, b: TState) => boolean,
};
type UseUrlQueryStateResult<TState> = {
state: TState,
setState: (updater: Updater<TState>) => void,
};
export function useUrlQueryState<TState extends Record<string, unknown>>(options: UseUrlQueryStateOptions<TState>): UseUrlQueryStateResult<TState> {
const { schema, defaultState, sanitize, serialize, isEqual } = options;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsKey = searchParams.toString();
const parsedState = useMemo(() => {
const raw: Record<string, unknown> = {};
searchParams.forEach((value, key) => {
raw[key] = value;
});
let partial: Partial<TState> = {};
try {
const result = schema.validateSync(raw, { abortEarly: false, stripUnknown: true }) as Partial<TState>;
partial = result;
} catch {
partial = {};
}
const sanitized = sanitize ? sanitize(partial) : { ...defaultState, ...partial };
return sanitized as TState;
}, [schema, sanitize, defaultState, searchParams]);
const stateRef = useRef(parsedState);
useEffect(() => {
stateRef.current = parsedState;
}, [parsedState]);
const replaceRef = useRef(router.replace);
useEffect(() => {
replaceRef.current = router.replace;
}, [router.replace]);
const defaultSerialize = useCallback(
(state: TState) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(state)) {
const defaultValue = (defaultState as Record<string, unknown>)[key];
if (value === undefined || value === null || value === defaultValue) {
continue;
}
params.set(key, String(value));
}
return params;
},
[defaultState],
);
const setState = useCallback(
(updater: Updater<TState>) => {
const equalityFn = isEqual ?? ((a, b) => JSON.stringify(a) === JSON.stringify(b));
const current = stateRef.current;
const patch = typeof updater === "function" ? updater(current) : updater;
const merged = { ...current, ...patch };
const next = sanitize ? sanitize(merged) : ({ ...defaultState, ...merged } as TState);
if (equalityFn(current, next)) {
return;
}
const params = (serialize ?? defaultSerialize)(next);
const queryString = params.toString();
const replace = replaceRef.current;
replace(queryString.length > 0 ? `${pathname}?${queryString}` : pathname);
},
[pathname, sanitize, serialize, defaultSerialize, defaultState, isEqual],
);
return { state: parsedState, setState };
}

View File

@ -4,7 +4,7 @@ import { ServerUser } from '@stackframe/stack';
import { AvatarCell, DataTableColumnHeader, DataTableManualPagination, SearchToolbarItem, TextCell } from "@stackframe/stack-ui";
import { ColumnDef, ColumnFiltersState, SortingState } from "@tanstack/react-table";
import { useState } from "react";
import { extendUsers } from './user-table';
import { extendUsers } from "./user-table";
export function TeamMemberSearchTable(props: {
action: (user: ServerUser) => React.ReactNode,

View File

@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react";
import * as yup from "yup";
import { SmartFormDialog } from "../form-dialog";
import { PermissionListField } from "../permission-field";
import { ExtendedServerUser, extendUsers, getCommonUserColumns } from "./user-table";
import { extendUsers, getCommonUserColumns, type ExtendedServerUser } from "./user-table";
type ExtendedServerUserForTeam = ExtendedServerUser & {

File diff suppressed because it is too large Load Diff