From 770d8a958eab38a68944ed4e435df7a070b01b35 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 28 Oct 2025 17:19:05 -0700 Subject: [PATCH 1/6] working user table loading --- .../src/components/data-table/user-table.tsx | 121 +++++++++++++----- .../src/components/data-table/data-table.tsx | 54 ++++++-- 2 files changed, 133 insertions(+), 42 deletions(-) diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 0400c5ac87..1cf08d9eda 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, TableCell, TableRow, TextCell } from "@stackframe/stack-ui"; +import { ColumnDef, ColumnFiltersState, Row, SortingState, Table as TableType } from "@tanstack/react-table"; +import { useEffect, useCallback, 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 @@ -117,7 +120,7 @@ export const getCommonUserColumns = () => [ { accessorKey: "displayName", header: ({ column }) => , - cell: ({ row }) => + cell: ({ row }) =>
{row.original.displayName ?? '–'} {row.original.isAnonymous && Anonymous} @@ -130,7 +133,7 @@ export const getCommonUserColumns = () => [ header: ({ column }) => , cell: ({ row }) => }> + icon={row.original.primaryEmail && row.original.emailVerified === "unverified" && }> {row.original.primaryEmail ?? '–'} , enableSorting: false, @@ -183,53 +186,101 @@ 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]>({ - limit: 10, - orderBy: "signedUpAt", - desc: true, - includeAnonymous: false, + const [users, setUsers] = useState(() => { + const empty = [] as ExtendedUsersResult; + return empty; }); + const [includeAnonymous, setIncludeAnonymous] = useState(false); + const [isFetching, setIsFetching] = useState(false); - const users = extendUsers(stackAdminApp.useUsers(filters)); + const latestRequestIdRef = useRef(0); + const skeletonRows = isFetching ? ( + <> + {Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + ))} + + ) : null; + const loadingState = skeletonRows ? { isLoading: true, skeleton: skeletonRows } : 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 +288,7 @@ export function UserTable() { onRowClick={(row) => { router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); }} + loadingState={loadingState} + externalRefreshKey={includeAnonymous} />; } 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..b905c5cb81 100644 --- a/packages/stack-ui/src/components/data-table/data-table.tsx +++ b/packages/stack-ui/src/components/data-table/data-table.tsx @@ -31,6 +31,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,6 +41,10 @@ export function TableView(props: { defaultColumnFilters: ColumnFiltersState, defaultSorting: SortingState, onRowClick?: (row: TData) => void, + loadingState?: { + isLoading: boolean, + skeleton: React.ReactNode, + }, }) { return (
@@ -70,7 +76,9 @@ export function TableView(props: { ))} - {props.table.getRowModel().rows.length ? ( + {props.loadingState?.isLoading ? ( + props.loadingState.skeleton ?? null + ) : props.table.getRowModel().rows.length ? ( props.table.getRowModel().rows.map((row) => ( (props: { ) : ( - No results. - - + colSpan={props.columns.length} + className="h-24 text-center" + > + No results. + + )} @@ -118,6 +126,10 @@ type DataTableProps = { defaultColumnFilters: ColumnFiltersState, defaultSorting: SortingState, showDefaultToolbar?: boolean, + loadingState?: { + isLoading: boolean, + skeleton: React.ReactNode, + }, onRowClick?: (row: TData) => void, } @@ -130,6 +142,7 @@ export function DataTable({ defaultSorting, showDefaultToolbar = true, onRowClick, + loadingState, }: DataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnFilters, setColumnFilters] = React.useState(defaultColumnFilters); @@ -158,6 +171,7 @@ export function DataTable({ setGlobalFilter={setGlobalFilter} showDefaultToolbar={showDefaultToolbar} onRowClick={onRowClick} + loadingState={loadingState} />; } @@ -169,6 +183,7 @@ type DataTableManualPaginationProps = DataTableProps Promise<{ nextCursor: string | null }>, + externalRefreshKey?: number | string, } export function DataTableManualPagination({ @@ -181,6 +196,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 +205,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 +230,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 +281,10 @@ type DataTableBaseProps = DataTableProps & { manualFiltering?: boolean, globalFilter?: any, setGlobalFilter?: OnChangeFn, + loadingState?: { + isLoading: boolean, + skeleton: React.ReactNode, + }, } function DataTableBase({ @@ -271,6 +307,7 @@ function DataTableBase({ manualFiltering = true, showDefaultToolbar = true, onRowClick, + loadingState, }: DataTableBaseProps) { const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState(defaultVisibility || {}); @@ -314,5 +351,6 @@ function DataTableBase({ defaultColumnFilters={defaultColumnFilters} defaultSorting={defaultSorting} onRowClick={onRowClick} + loadingState={loadingState} />; } From 671abd15b1cb163bff36aedfe079db5c891e47df Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Oct 2025 10:55:39 -0700 Subject: [PATCH 2/6] loading state in cells --- .../src/components/data-table/user-table.tsx | 44 +++++-------------- .../src/components/data-table/data-table.tsx | 43 +++++++++++++----- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 1cf08d9eda..841a3ae7bc 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -3,7 +3,7 @@ import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-a import { useRouter } from "@/components/router"; import { ServerUser } from '@stackframe/stack'; import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; -import { ActionCell, AvatarCell, BadgeCell, DataTableColumnHeader, DataTableManualPagination, DateCell, SearchToolbarItem, SimpleTooltip, Skeleton, TableCell, TableRow, TextCell } from "@stackframe/stack-ui"; +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 { useEffect, useCallback, useRef, useState } from "react"; import { Link } from '../link'; @@ -110,6 +110,9 @@ export const getCommonUserColumns = () => [ return ; }, enableSorting: false, + meta: { + loading: , + }, }, { accessorKey: "id", @@ -168,6 +171,9 @@ const columns: ColumnDef[] = [ { id: "actions", cell: ({ row }) => , + meta: { + loading:
+ }, }, ]; @@ -199,37 +205,7 @@ export function UserTable() { const [isFetching, setIsFetching] = useState(false); const latestRequestIdRef = useRef(0); - const skeletonRows = isFetching ? ( - <> - {Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - - - - - - - - - - - - - - - - - - ))} - - ) : null; - const loadingState = skeletonRows ? { isLoading: true, skeleton: skeletonRows } : undefined; + const loadingState = isFetching ? { isLoading: true, rowCount: 5 } : undefined; const onUpdate = useCallback(async ({ cursor, @@ -250,7 +226,7 @@ export function UserTable() { limit, query: globalFilters, orderBy: "signedUpAt", - desc: primarySort?.id === "signedUpAt" ? primarySort.desc : true, + desc: primarySort.id === "signedUpAt" ? primarySort.desc : true, includeAnonymous, }; @@ -289,6 +265,6 @@ export function UserTable() { router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); }} loadingState={loadingState} - externalRefreshKey={includeAnonymous} + externalRefreshKey={String(includeAnonymous)} />; } 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 b905c5cb81..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, @@ -43,9 +45,20 @@ export function TableView(props: { onRowClick?: (row: TData) => void, loadingState?: { isLoading: boolean, - skeleton: React.ReactNode, + 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.loadingState?.isLoading ? ( - props.loadingState.skeleton ?? null + <> + {Array.from({ length: loadingRowCount }).map((_, rowIndex) => ( + + {visibleColumns.map((column) => ( + + {renderLoadingCell(column)} + + ))} + + ))} + ) : props.table.getRowModel().rows.length ? ( props.table.getRowModel().rows.map((row) => ( (props: { ) : ( - No results. - - + colSpan={props.columns.length} + className="h-24 text-center" + > + No results. + + )} @@ -128,7 +151,7 @@ type DataTableProps = { showDefaultToolbar?: boolean, loadingState?: { isLoading: boolean, - skeleton: React.ReactNode, + rowCount?: number, }, onRowClick?: (row: TData) => void, } @@ -283,7 +306,7 @@ type DataTableBaseProps = DataTableProps & { setGlobalFilter?: OnChangeFn, loadingState?: { isLoading: boolean, - skeleton: React.ReactNode, + rowCount?: number, }, } From f996540e587b0709d7851c619498b43f5023bf99 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Oct 2025 12:04:15 -0700 Subject: [PATCH 3/6] fix search input lag --- .../components/data-table/toolbar-items.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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)} /> ); From 61c68da0c2ef6ffd081117307be628f207a04cad Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Oct 2025 12:09:03 -0700 Subject: [PATCH 4/6] change num skeleton rows --- apps/dashboard/src/components/data-table/user-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 841a3ae7bc..43f170e631 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -205,7 +205,7 @@ export function UserTable() { const [isFetching, setIsFetching] = useState(false); const latestRequestIdRef = useRef(0); - const loadingState = isFetching ? { isLoading: true, rowCount: 5 } : undefined; + const loadingState = isFetching ? { isLoading: true, rowCount: 10 } : undefined; const onUpdate = useCallback(async ({ cursor, From 349248c3e46fee97e04ef82e35da5df90e02bdf3 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Oct 2025 12:47:47 -0700 Subject: [PATCH 5/6] refresh user table on cache invalidate --- .../src/components/data-table/user-table.tsx | 15 +++++++++++++-- .../apps/implementations/server-app-impl.ts | 6 +++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 43f170e631..9f1a5eb712 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -5,7 +5,7 @@ import { ServerUser } from '@stackframe/stack'; import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; 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 { useEffect, useCallback, useRef, useState } from "react"; +import { useEffect, useCallback, useMemo, useRef, useState } from "react"; import { Link } from '../link'; import { CreateCheckoutDialog } from '../payments/create-checkout-dialog'; import { DeleteUserDialog, ImpersonateUserDialog } from '../user-dialogs'; @@ -203,6 +203,17 @@ export function UserTable() { }); const [includeAnonymous, setIncludeAnonymous] = useState(false); const [isFetching, setIsFetching] = useState(false); + const liveUsersPreview = stackAdminApp.useUsers({ + limit: 10, + orderBy: "signedUpAt", + desc: true, + includeAnonymous, + }); + + 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; @@ -265,6 +276,6 @@ export function UserTable() { router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); }} loadingState={loadingState} - externalRefreshKey={String(includeAnonymous)} + externalRefreshKey={externalRefreshKey} />; } 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 a26c0a052a..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; From ff0c4df0f69866de1b3e4dc8d1d04ad918fe03cb Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Oct 2025 12:56:44 -0700 Subject: [PATCH 6/6] remove unneeded --- apps/dashboard/src/components/data-table/user-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 9f1a5eb712..f0388554d1 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -5,7 +5,7 @@ import { ServerUser } from '@stackframe/stack'; import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; 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 { useEffect, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Link } from '../link'; import { CreateCheckoutDialog } from '../payments/create-checkout-dialog'; import { DeleteUserDialog, ImpersonateUserDialog } from '../user-dialogs';