Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 71 additions & 31 deletions apps/dashboard/src/components/data-table/user-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app';
import { useRouter } from "@/components/router";
import { ServerUser } from '@stackframe/stack';
import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects';
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
import { ActionCell, AvatarCell, BadgeCell, DataTableColumnHeader, DataTableManualPagination, DateCell, SearchToolbarItem, SimpleTooltip, TextCell } from "@stackframe/stack-ui";
import { ColumnDef, ColumnFiltersState, Row, SortingState, Table } from "@tanstack/react-table";
import { useState } from "react";
import { ActionCell, AvatarCell, BadgeCell, DataTableColumnHeader, DataTableManualPagination, DateCell, SearchToolbarItem, SimpleTooltip, Skeleton, TextCell } from "@stackframe/stack-ui";
import { ColumnDef, ColumnFiltersState, Row, SortingState, Table as TableType } from "@tanstack/react-table";
import { useCallback, useMemo, useRef, useState } from "react";
import { Link } from '../link';
import { CreateCheckoutDialog } from '../payments/create-checkout-dialog';
import { DeleteUserDialog, ImpersonateUserDialog } from '../user-dialogs';
Expand All @@ -16,7 +15,11 @@ export type ExtendedServerUser = ServerUser & {
emailVerified: 'verified' | 'unverified',
};

function userToolbarRender<TData>(table: Table<TData>, showAnonymous: boolean, setShowAnonymous: (value: boolean) => void) {
function userToolbarRender<TData>(
table: TableType<TData>,
showAnonymous: boolean,
onIncludeAnonymousChange: (value: boolean, table: TableType<TData>) => void,
) {
return (
<>
<SearchToolbarItem table={table} placeholder="Search table" />
Expand All @@ -25,7 +28,7 @@ function userToolbarRender<TData>(table: Table<TData>, showAnonymous: boolean, s
<input
type="checkbox"
checked={showAnonymous}
onChange={(e) => setShowAnonymous(e.target.checked)}
onChange={(e) => onIncludeAnonymousChange(e.target.checked, table)}
className="rounded border-gray-300"
/>
Show anonymous users
Expand Down Expand Up @@ -107,6 +110,9 @@ export const getCommonUserColumns = <T extends ExtendedServerUser>() => [
return <AvatarCellWrapper user={row.original} />;
},
enableSorting: false,
meta: {
loading: <Skeleton className="h-6 w-6 rounded-full" />,
},
},
{
accessorKey: "id",
Expand All @@ -117,7 +123,7 @@ export const getCommonUserColumns = <T extends ExtendedServerUser>() => [
{
accessorKey: "displayName",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Display Name" />,
cell: ({ row }) => <TextCell size={120}>
cell: ({ row }) => <TextCell size={120}>
<div className="flex items-center gap-2">
<span className={row.original.displayName === null ? 'text-slate-400' : ''}>{row.original.displayName ?? '–'}</span>
{row.original.isAnonymous && <span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">Anonymous</span>}
Expand All @@ -130,7 +136,7 @@ export const getCommonUserColumns = <T extends ExtendedServerUser>() => [
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Primary Email" />,
cell: ({ row }) => <TextCell
size={180}
icon={row.original.primaryEmail && row.original.emailVerified === "unverified" && <SimpleTooltip tooltip='Email not verified' type='warning'/>}>
icon={row.original.primaryEmail && row.original.emailVerified === "unverified" && <SimpleTooltip tooltip='Email not verified' type='warning' />}>
{row.original.primaryEmail ?? '–'}
</TextCell>,
enableSorting: false,
Expand Down Expand Up @@ -165,6 +171,9 @@ const columns: ColumnDef<ExtendedServerUser>[] = [
{
id: "actions",
cell: ({ row }) => <UserActions row={row} />,
meta: {
loading: <div className="p-1"><Skeleton className="h-6 w-6 rounded-md" /></div>
},
},
];

Expand All @@ -183,59 +192,90 @@ export function extendUsers(users: ServerUser[] & { nextCursor?: string | null }
return Object.assign(extended, { nextCursor: users.nextCursor });
}

type ExtendedUsersResult = ReturnType<typeof extendUsers>;

export function UserTable() {
const stackAdminApp = useAdminApp();
const router = useRouter();
const [filters, setFilters] = useState<Parameters<typeof stackAdminApp.listUsers>[0]>({
const [users, setUsers] = useState<ExtendedUsersResult>(() => {
const empty = [] as ExtendedUsersResult;
return empty;
});
const [includeAnonymous, setIncludeAnonymous] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const liveUsersPreview = stackAdminApp.useUsers({
limit: 10,
orderBy: "signedUpAt",
desc: true,
includeAnonymous: false,
includeAnonymous,
});

const users = extendUsers(stackAdminApp.useUsers(filters));
const externalRefreshKey = useMemo(() => {
const signature = JSON.stringify([...liveUsersPreview]);
return `${includeAnonymous}:${signature}:${liveUsersPreview.nextCursor ?? "null"}`;
}, [includeAnonymous, liveUsersPreview]);

const latestRequestIdRef = useRef(0);
const loadingState = isFetching ? { isLoading: true, rowCount: 10 } : undefined;

const onUpdate = async (options: {
const onUpdate = useCallback(async ({
cursor,
limit,
sorting,
columnFilters: _columnFilters,
globalFilters,
}: {
cursor: string,
limit: number,
sorting: SortingState,
columnFilters: ColumnFiltersState,
globalFilters: any,
}) => {
let newFilters: Parameters<typeof stackAdminApp.listUsers>[0] = {
cursor: options.cursor,
limit: options.limit,
query: options.globalFilters,
const primarySort = sorting[0];
const nextFilters: Parameters<typeof stackAdminApp.listUsers>[0] = {
cursor,
limit,
query: globalFilters,
orderBy: "signedUpAt",
desc: primarySort.id === "signedUpAt" ? primarySort.desc : true,
includeAnonymous,
};

const orderMap = {
signedUpAt: "signedUpAt",
} as const;
if (options.sorting.length > 0 && options.sorting[0].id in orderMap) {
newFilters.orderBy = orderMap[options.sorting[0].id as keyof typeof orderMap];
newFilters.desc = options.sorting[0].desc;
const requestId = ++latestRequestIdRef.current;
setIsFetching(true);
try {
const freshUsers = extendUsers(await stackAdminApp.listUsers(nextFilters));
if (requestId === latestRequestIdRef.current) {
setUsers(freshUsers);
}
return { nextCursor: freshUsers.nextCursor ?? null };
} finally {
if (requestId === latestRequestIdRef.current) {
setIsFetching(false);
}
}
}, [includeAnonymous, stackAdminApp]);

if (deepPlainEquals(newFilters, filters, { ignoreUndefinedValues: true })) {
// save ourselves a request if the filters didn't change
return { nextCursor: users.nextCursor };
} else {
setFilters(newFilters);
const users = await stackAdminApp.listUsers(newFilters);
return { nextCursor: users.nextCursor };
const handleIncludeAnonymousChange = useCallback((value: boolean, table: TableType<ExtendedServerUser>) => {
if (includeAnonymous === value) {
return;
}
};
setIncludeAnonymous(value);
table.setPageIndex(0);
}, [includeAnonymous]);

return <DataTableManualPagination
columns={columns}
data={users}
toolbarRender={(table) => userToolbarRender(table, filters?.includeAnonymous ?? false, (value) => setFilters(prev => ({ ...prev, includeAnonymous: value })))}
toolbarRender={(table) => userToolbarRender(table, includeAnonymous, handleIncludeAnonymousChange)}
onUpdate={onUpdate}
defaultVisibility={{ emailVerified: false }}
defaultColumnFilters={[]}
defaultSorting={[{ id: 'signedUpAt', desc: true }]}
onRowClick={(row) => {
router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`);
}}
loadingState={loadingState}
externalRefreshKey={externalRefreshKey}
/>;
}
65 changes: 63 additions & 2 deletions packages/stack-ui/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import {
Skeleton,
Table,
TableBody,
TableCell,
Expand All @@ -10,6 +11,7 @@ import {
TableRow,
} from "@stackframe/stack-ui";
import {
Column,
ColumnDef,
ColumnFiltersState,
GlobalFiltering,
Expand All @@ -31,6 +33,8 @@ import React from "react";
import { DataTablePagination } from "./pagination";
import { DataTableToolbar } from "./toolbar";

const GLOBAL_FILTER_DEBOUNCE_MS = 300;

export function TableView<TData, TValue>(props: {
table: TableType<TData>,
columns: ColumnDef<TData, TValue>[],
Expand All @@ -39,7 +43,22 @@ export function TableView<TData, TValue>(props: {
defaultColumnFilters: ColumnFiltersState,
defaultSorting: SortingState,
onRowClick?: (row: TData) => void,
loadingState?: {
isLoading: boolean,
rowCount?: number,
},
}) {
const visibleColumns = props.table.getVisibleLeafColumns();

const loadingRowCount = props.loadingState?.isLoading ? (props.loadingState.rowCount ?? 10) : 0;

const renderLoadingCell = (column: Column<TData, unknown>) => {
const meta = column.columnDef.meta as {
loading?: React.ReactNode,
} | undefined;
return meta?.loading ?? <Skeleton className="h-4 w-full" />;
};

return (
<div className="space-y-4">
<DataTableToolbar
Expand Down Expand Up @@ -70,7 +89,19 @@ export function TableView<TData, TValue>(props: {
))}
</TableHeader>
<TableBody>
{props.table.getRowModel().rows.length ? (
{props.loadingState?.isLoading ? (
<>
{Array.from({ length: loadingRowCount }).map((_, rowIndex) => (
<TableRow key={`data-table-loading-${rowIndex}`}>
{visibleColumns.map((column) => (
<TableCell key={column.id}>
{renderLoadingCell(column)}
</TableCell>
))}
</TableRow>
))}
</>
) : props.table.getRowModel().rows.length ? (
props.table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
Expand Down Expand Up @@ -118,6 +149,10 @@ type DataTableProps<TData, TValue> = {
defaultColumnFilters: ColumnFiltersState,
defaultSorting: SortingState,
showDefaultToolbar?: boolean,
loadingState?: {
isLoading: boolean,
rowCount?: number,
},
onRowClick?: (row: TData) => void,
}

Expand All @@ -130,6 +165,7 @@ export function DataTable<TData, TValue>({
defaultSorting,
showDefaultToolbar = true,
onRowClick,
loadingState,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(defaultSorting);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(defaultColumnFilters);
Expand Down Expand Up @@ -158,6 +194,7 @@ export function DataTable<TData, TValue>({
setGlobalFilter={setGlobalFilter}
showDefaultToolbar={showDefaultToolbar}
onRowClick={onRowClick}
loadingState={loadingState}
/>;
}

Expand All @@ -169,6 +206,7 @@ type DataTableManualPaginationProps<TData, TValue> = DataTableProps<TData, TValu
columnFilters: ColumnFiltersState,
globalFilters: any,
}) => Promise<{ nextCursor: string | null }>,
externalRefreshKey?: number | string,
}

export function DataTableManualPagination<TData, TValue>({
Expand All @@ -181,13 +219,16 @@ export function DataTableManualPagination<TData, TValue>({
onRowClick,
onUpdate,
showDefaultToolbar = true,
loadingState,
externalRefreshKey,
}: DataTableManualPaginationProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(defaultSorting);
const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 });
const [cursors, setCursors] = React.useState<Record<number, string>>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(defaultColumnFilters);
const [globalFilter, setGlobalFilter] = React.useState<any>();
const [refreshCounter, setRefreshCounter] = React.useState(0);
const previousExternalRefreshKey = React.useRef(externalRefreshKey);

React.useEffect(() => {
runAsynchronouslyWithAlert(async () => {
Expand All @@ -212,10 +253,23 @@ export function DataTableManualPagination<TData, TValue>({
React.useEffect(() => {
const timer = setTimeout(() => {
setRefreshCounter(x => x + 1);
}, 3_000);
}, GLOBAL_FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [globalFilter]);

React.useEffect(() => {
if (externalRefreshKey === undefined) {
return;
}
if (previousExternalRefreshKey.current === externalRefreshKey) {
return;
}
previousExternalRefreshKey.current = externalRefreshKey;
setPagination(pagination => ({ ...pagination, pageIndex: 0 }));
setCursors({});
setRefreshCounter(x => x + 1);
}, [externalRefreshKey]);

return <DataTableBase
columns={columns}
data={data}
Expand All @@ -234,6 +288,7 @@ export function DataTableManualPagination<TData, TValue>({
defaultVisibility={defaultVisibility}
showDefaultToolbar={showDefaultToolbar}
onRowClick={onRowClick}
loadingState={loadingState}
/>;
}

Expand All @@ -249,6 +304,10 @@ type DataTableBaseProps<TData, TValue> = DataTableProps<TData, TValue> & {
manualFiltering?: boolean,
globalFilter?: any,
setGlobalFilter?: OnChangeFn<any>,
loadingState?: {
isLoading: boolean,
rowCount?: number,
},
}

function DataTableBase<TData, TValue>({
Expand All @@ -271,6 +330,7 @@ function DataTableBase<TData, TValue>({
manualFiltering = true,
showDefaultToolbar = true,
onRowClick,
loadingState,
}: DataTableBaseProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(defaultVisibility || {});
Expand Down Expand Up @@ -314,5 +374,6 @@ function DataTableBase<TData, TValue>({
defaultColumnFilters={defaultColumnFilters}
defaultSorting={defaultSorting}
onRowClick={onRowClick}
loadingState={loadingState}
/>;
}
18 changes: 16 additions & 2 deletions packages/stack-ui/src/components/data-table/toolbar-items.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
"use client";
import { Input, cn } from "../..";
import { Table } from "@tanstack/react-table";
import { useState } from "react";

export function SearchToolbarItem<TData>(props: { table: Table<TData>, keyName?: string | null, placeholder: string, className?: string }) {
const [search, setSearch] = useState<string>("");

return (
<Input
placeholder={props.placeholder}
value={props.keyName ? `${props.table.getColumn(props.keyName)?.getFilterValue() ?? ""}` : props.table.getState().globalFilter ?? ""}
onChange={(event) => props.keyName ? props.table.getColumn(props.keyName)?.setFilterValue(event.target.value) : props.table.setGlobalFilter(event.target.value)}
value={search}
onChange={(event) => {
setSearch(event.target.value);
// run in timeout to prevent immediate re-render
setTimeout(() => {
if (props.keyName) {
props.table.getColumn(props.keyName)?.setFilterValue(event.target.value);
} else {
props.table.setGlobalFilter(event.target.value);
}
}, 0);
}}
className={cn("h-8 w-[250px]", props.className)}
/>
);
Expand Down
Loading
Loading