diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 0400c5ac87..f0388554d1 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -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'; @@ -16,7 +15,11 @@ export type ExtendedServerUser = ServerUser & { emailVerified: 'verified' | 'unverified', }; -function userToolbarRender(table: Table, showAnonymous: boolean, setShowAnonymous: (value: boolean) => void) { +function userToolbarRender( + table: TableType, + showAnonymous: boolean, + onIncludeAnonymousChange: (value: boolean, table: TableType) => void, +) { return ( <> @@ -25,7 +28,7 @@ function userToolbarRender(table: Table, showAnonymous: boolean, s setShowAnonymous(e.target.checked)} + onChange={(e) => onIncludeAnonymousChange(e.target.checked, table)} className="rounded border-gray-300" /> Show anonymous users @@ -107,6 +110,9 @@ export const getCommonUserColumns = () => [ return ; }, enableSorting: false, + meta: { + loading: , + }, }, { accessorKey: "id", @@ -117,7 +123,7 @@ export const getCommonUserColumns = () => [ { accessorKey: "displayName", header: ({ column }) => , - cell: ({ row }) => + cell: ({ row }) =>
{row.original.displayName ?? '–'} {row.original.isAnonymous && Anonymous} @@ -130,7 +136,7 @@ export const getCommonUserColumns = () => [ header: ({ column }) => , cell: ({ row }) => }> + icon={row.original.primaryEmail && row.original.emailVerified === "unverified" && }> {row.original.primaryEmail ?? '–'} , enableSorting: false, @@ -165,6 +171,9 @@ const columns: ColumnDef[] = [ { id: "actions", cell: ({ row }) => , + meta: { + loading:
+ }, }, ]; @@ -183,53 +192,82 @@ export function extendUsers(users: ServerUser[] & { nextCursor?: string | null } return Object.assign(extended, { nextCursor: users.nextCursor }); } +type ExtendedUsersResult = ReturnType; + export function UserTable() { const stackAdminApp = useAdminApp(); const router = useRouter(); - const [filters, setFilters] = useState[0]>({ + const [users, setUsers] = useState(() => { + 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[0] = { - cursor: options.cursor, - limit: options.limit, - query: options.globalFilters, + const primarySort = sorting[0]; + const nextFilters: Parameters[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) => { + if (includeAnonymous === value) { + return; } - }; + setIncludeAnonymous(value); + table.setPageIndex(0); + }, [includeAnonymous]); return userToolbarRender(table, filters?.includeAnonymous ?? false, (value) => setFilters(prev => ({ ...prev, includeAnonymous: value })))} + toolbarRender={(table) => userToolbarRender(table, includeAnonymous, handleIncludeAnonymousChange)} onUpdate={onUpdate} defaultVisibility={{ emailVerified: false }} defaultColumnFilters={[]} @@ -237,5 +275,7 @@ export function UserTable() { onRowClick={(row) => { router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); }} + loadingState={loadingState} + externalRefreshKey={externalRefreshKey} />; } diff --git a/packages/stack-ui/src/components/data-table/data-table.tsx b/packages/stack-ui/src/components/data-table/data-table.tsx index 4f666fa0fe..d8d130c49c 100644 --- a/packages/stack-ui/src/components/data-table/data-table.tsx +++ b/packages/stack-ui/src/components/data-table/data-table.tsx @@ -2,6 +2,7 @@ import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { + Skeleton, Table, TableBody, TableCell, @@ -10,6 +11,7 @@ import { TableRow, } from "@stackframe/stack-ui"; import { + Column, ColumnDef, ColumnFiltersState, GlobalFiltering, @@ -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(props: { table: TableType, columns: ColumnDef[], @@ -39,7 +43,22 @@ export function TableView(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) => { + const meta = column.columnDef.meta as { + loading?: React.ReactNode, + } | undefined; + return meta?.loading ?? ; + }; + return (
(props: { ))} - {props.table.getRowModel().rows.length ? ( + {props.loadingState?.isLoading ? ( + <> + {Array.from({ length: loadingRowCount }).map((_, rowIndex) => ( + + {visibleColumns.map((column) => ( + + {renderLoadingCell(column)} + + ))} + + ))} + + ) : props.table.getRowModel().rows.length ? ( props.table.getRowModel().rows.map((row) => ( = { defaultColumnFilters: ColumnFiltersState, defaultSorting: SortingState, showDefaultToolbar?: boolean, + loadingState?: { + isLoading: boolean, + rowCount?: number, + }, onRowClick?: (row: TData) => void, } @@ -130,6 +165,7 @@ export function DataTable({ defaultSorting, showDefaultToolbar = true, onRowClick, + loadingState, }: DataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnFilters, setColumnFilters] = React.useState(defaultColumnFilters); @@ -158,6 +194,7 @@ export function DataTable({ setGlobalFilter={setGlobalFilter} showDefaultToolbar={showDefaultToolbar} onRowClick={onRowClick} + loadingState={loadingState} />; } @@ -169,6 +206,7 @@ type DataTableManualPaginationProps = DataTableProps Promise<{ nextCursor: string | null }>, + externalRefreshKey?: number | string, } export function DataTableManualPagination({ @@ -181,6 +219,8 @@ export function DataTableManualPagination({ onRowClick, onUpdate, showDefaultToolbar = true, + loadingState, + externalRefreshKey, }: DataTableManualPaginationProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 }); @@ -188,6 +228,7 @@ export function DataTableManualPagination({ const [columnFilters, setColumnFilters] = React.useState(defaultColumnFilters); const [globalFilter, setGlobalFilter] = React.useState(); const [refreshCounter, setRefreshCounter] = React.useState(0); + const previousExternalRefreshKey = React.useRef(externalRefreshKey); React.useEffect(() => { runAsynchronouslyWithAlert(async () => { @@ -212,10 +253,23 @@ export function DataTableManualPagination({ 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 ({ defaultVisibility={defaultVisibility} showDefaultToolbar={showDefaultToolbar} onRowClick={onRowClick} + loadingState={loadingState} />; } @@ -249,6 +304,10 @@ type DataTableBaseProps = DataTableProps & { manualFiltering?: boolean, globalFilter?: any, setGlobalFilter?: OnChangeFn, + loadingState?: { + isLoading: boolean, + rowCount?: number, + }, } function DataTableBase({ @@ -271,6 +330,7 @@ function DataTableBase({ manualFiltering = true, showDefaultToolbar = true, onRowClick, + loadingState, }: DataTableBaseProps) { const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState(defaultVisibility || {}); @@ -314,5 +374,6 @@ function DataTableBase({ defaultColumnFilters={defaultColumnFilters} defaultSorting={defaultSorting} onRowClick={onRowClick} + loadingState={loadingState} />; } diff --git a/packages/stack-ui/src/components/data-table/toolbar-items.tsx b/packages/stack-ui/src/components/data-table/toolbar-items.tsx index 9c0dd01f99..d9be92d404 100644 --- a/packages/stack-ui/src/components/data-table/toolbar-items.tsx +++ b/packages/stack-ui/src/components/data-table/toolbar-items.tsx @@ -1,12 +1,26 @@ +"use client"; import { Input, cn } from "../.."; import { Table } from "@tanstack/react-table"; +import { useState } from "react"; export function SearchToolbarItem(props: { table: Table, keyName?: string | null, placeholder: string, className?: string }) { + const [search, setSearch] = useState(""); + return ( 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)} /> ); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 19dd0733f4..53896767bd 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -1109,7 +1109,11 @@ export class _StackServerAppImplIncomplete this._serverUserFromCrud(j)); result.nextCursor = crud.pagination?.next_cursor ?? null; return result as any;