mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
2e892664f3
commit
35a56f721f
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
119
apps/dashboard/src/components/data-table/common/table.tsx
Normal file
119
apps/dashboard/src/components/data-table/common/table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user