Skip to content

Commit 8b7e60a

Browse files
committed
fix(dashboard): enhance data grid state management and pagination
- Replaced the use of `createDefaultDataGridState` with `useDataGridUrlState` across multiple components to improve state persistence and URL synchronization. - Updated pagination logic in various tables to ensure consistent handling of grid states and improve user experience during data retrieval. - Refactored components to utilize the new user picker table for better user selection functionality. These changes enhance the overall reliability and usability of the dashboard's data grid features.
1 parent 37ead67 commit 8b7e60a

38 files changed

Lines changed: 421 additions & 339 deletions

File tree

apps/backend/src/app/api/latest/permission-definitions-pagination.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/s
22
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
33
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
44

5+
// Binary search: index of the first item whose id > cursor, in an
6+
// array already sorted by `stringCompare(a.id, b.id)`.
7+
function firstIndexAfter<T extends { id: string }>(sorted: T[], cursor: string): number {
8+
let lo = 0;
9+
let hi = sorted.length;
10+
while (lo < hi) {
11+
const mid = (lo + hi) >>> 1;
12+
if (stringCompare(sorted[mid].id, cursor) <= 0) lo = mid + 1;
13+
else hi = mid;
14+
}
15+
return lo;
16+
}
17+
518
type PermissionDefinition = {
619
id: string,
720
description?: string,
@@ -40,10 +53,14 @@ export function paginatePermissionDefinitions(items: PermissionDefinition[], que
4053
let startIdx = 0;
4154
if (query.cursor != null) {
4255
const cursorIdx = filtered.findIndex((p) => p.id === query.cursor);
43-
if (cursorIdx === -1) {
44-
throw new StatusError(StatusError.BadRequest, `Cursor not found: ${query.cursor}`);
45-
}
46-
startIdx = cursorIdx + 1;
56+
// If the cursor row was deleted (or filtered out) between page
57+
// requests, fall back to "first id strictly greater than the cursor"
58+
// rather than 400'ing the client mid-scroll. Worst case the user
59+
// sees a one-row gap; the alternative is a hard error on infinite
60+
// scroll for any concurrent edit.
61+
startIdx = cursorIdx === -1
62+
? firstIndexAfter(filtered, query.cursor)
63+
: cursorIdx + 1;
4764
}
4865
const slice = filtered.slice(startIdx, startIdx + query.limit);
4966
const hasMore = startIdx + query.limit < filtered.length;

apps/backend/src/app/api/latest/users/crud.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
520520
}),
521521
querySchema: yupObject({
522522
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" } }),
523-
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" } }),
523+
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return (capped at 200)." } }),
524524
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." } }),
525525
order_by: yupString().oneOf(['signed_up_at', 'last_active_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" } }),
526526
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" } }),
@@ -629,9 +629,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
629629
},
630630
{ projectUserId: sortDirection },
631631
],
632-
// +1 because we need to know if there is a next page
632+
// +1 to detect whether a next page exists without a separate count.
633633
take: query.limit ? query.limit + 1 : undefined,
634+
// Cursor convention (matches teams/crud.tsx): the client sends the
635+
// id of the LAST row of the previous page; Prisma starts AT that id,
636+
// and `skip: 1` drops it so we don't re-emit it.
634637
...query.cursor ? {
638+
skip: 1,
635639
cursor: {
636640
tenancyId_projectUserId: {
637641
tenancyId: auth.tenancy.id,
@@ -641,13 +645,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
641645
} : {},
642646
});
643647

648+
const items = db.slice(0, query.limit).map((user) => userPrismaToCrud(user, auth.tenancy.config));
649+
const hasMore = query.limit != null && db.length > query.limit;
644650
return {
645-
// remove the last item because it's the next cursor
646-
items: db.map((user) => userPrismaToCrud(user, auth.tenancy.config)).slice(0, query.limit),
651+
items,
647652
is_paginated: true,
648653
pagination: {
649-
// if result is not full length, there is no next cursor
650-
next_cursor: query.limit && db.length >= query.limit + 1 ? db[db.length - 1].projectUserId : null,
654+
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
651655
},
652656
};
653657
},

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { InputField, SwitchField } from "@/components/form-fields";
44
import { InlineSaveDiscard } from "@/components/inline-save-discard";
55
import { SettingCard, SettingSwitch } from "@/components/settings";
66
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, ActionDialog, Alert, Button, Typography } from "@/components/ui";
7-
import { createDefaultDataGridState, DataGrid, useDataSource, type DataGridColumnDef } from "@stackframe/dashboard-ui-components";
7+
import { DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@stackframe/dashboard-ui-components";
88
import { useUpdateConfig } from "@/lib/config-update";
99
import { yupString } from "@stackframe/stack-shared/dist/schema-fields";
1010
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
@@ -330,7 +330,7 @@ function DomainDataGrid({ domains }: { domains: DomainEntry[] }) {
330330
},
331331
], [domains]);
332332

333-
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
333+
const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "domains" });
334334
const gridData = useDataSource({
335335
data: domains,
336336
columns,

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
3+
import { UserPickerTable } from "@/components/data-table/user-picker-table";
44
import { DesignButton } from "@/components/design-components";
55
import { DesignCard } from "@/components/design-components";
66
import EmailPreview, { type OnWysiwygEditCommit } from "@/components/email-preview";
@@ -379,7 +379,7 @@ function RecipientsStage({ draftId, onBack, onNext, onStepClick }: RecipientsSta
379379
)}
380380

381381
{/* Search Table */}
382-
<TeamMemberSearchTable
382+
<UserPickerTable
383383
action={(user) => (
384384
<Button
385385
type="button"

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { SettingCard } from "@/components/settings";
44
import { ActionDialog, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Typography, useToast } from "@/components/ui";
5-
import { createDefaultDataGridState, DataGrid, DataGridToolbar, useDataSource, type DataGridColumnDef, type DataGridDataSource } from "@stackframe/dashboard-ui-components";
5+
import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource } from "@stackframe/dashboard-ui-components";
66
import { cn } from "@/lib/utils";
77
import { DotsThreeIcon, PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react";
88
import { AdminEmailOutbox, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus } from "@stackframe/stack";
@@ -700,7 +700,7 @@ export default function PageClient() {
700700
},
701701
], []);
702702

703-
const [emailGridState, setEmailGridState] = useState(() => createDefaultDataGridState(emailColumns));
703+
const [emailGridState, setEmailGridState] = useDataGridUrlState(emailColumns, { paramPrefix: "outbox" });
704704
const getRowId = useCallback((row: AdminEmailOutbox) => row.id, []);
705705
const emailGridData = useDataSource({
706706
dataSource,

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/grouped-email-table.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { Spinner, Typography } from "@/components/ui";
55
import { AdminEmailOutbox, AdminEmailOutboxStatus } from "@stackframe/stack";
66
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
77
import {
8-
createDefaultDataGridState,
98
DataGrid,
9+
useDataGridUrlState,
1010
useDataSource,
1111
type DataGridColumnDef,
12-
type DataGridState,
1312
} from "@stackframe/dashboard-ui-components";
1413
import { useEffect, useMemo, useState } from "react";
1514
import { useAdminApp } from "../use-admin-app";
@@ -234,10 +233,10 @@ export function GroupedEmailTable() {
234233
[emails, draftsMap, templatesMap]
235234
);
236235

237-
const [gridState, setGridState] = useState<DataGridState>(() => ({
238-
...createDefaultDataGridState(groupedEmailGridColumns),
239-
sorting: [{ columnId: "recipientCount", direction: "desc" }],
240-
}));
236+
const [gridState, setGridState] = useDataGridUrlState(groupedEmailGridColumns, {
237+
paramPrefix: "sentgroup",
238+
initial: { sorting: [{ columnId: "recipientCount", direction: "desc" }] },
239+
});
241240

242241
const gridData = useDataSource({
243242
data: groupedRows,

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import { Spinner, Typography } from "@/components/ui";
88
import { Envelope } from "@phosphor-icons/react";
99
import { AdminEmailOutbox } from "@stackframe/stack";
1010
import {
11-
createDefaultDataGridState,
1211
DataGrid,
12+
useDataGridUrlState,
1313
useDataSource,
1414
type DataGridColumnDef,
1515
type DataGridDataSource,
16-
type DataGridState,
1716
} from "@stackframe/dashboard-ui-components";
1817
import { useCallback, useMemo, useState } from "react";
1918
import { AppEnabledGuard } from "../app-enabled-guard";
@@ -116,10 +115,10 @@ function EmailSendDataTable() {
116115
const stackAdminApp = useAdminApp();
117116
const router = useRouter();
118117

119-
const [gridState, setGridState] = useState<DataGridState>(() => ({
120-
...createDefaultDataGridState(emailTableColumns),
121-
sorting: [{ columnId: "scheduledAt", direction: "desc" }],
122-
}));
118+
const [gridState, setGridState] = useDataGridUrlState(emailTableColumns, {
119+
paramPrefix: "sentemails",
120+
initial: { sorting: [{ columnId: "scheduledAt", direction: "desc" }] },
121+
});
123122

124123
const dataSource = useMemo<DataGridDataSource<AdminEmailOutbox>>(
125124
() => async function* (params) {

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@ import { Envelope } from "@phosphor-icons/react";
88
import { AdminEmailOutbox } from "@stackframe/stack";
99
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
1010
import {
11-
createDefaultDataGridState,
1211
DataGrid,
12+
useDataGridUrlState,
1313
useDataSource,
1414
type DataGridColumnDef,
15-
type DataGridState,
1615
} from "@stackframe/dashboard-ui-components";
1716
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
1817
import { useAdminApp, useProjectId } from "../use-admin-app";
@@ -111,10 +110,10 @@ export function SentEmailsView({ filterFn, renderActions }: SentEmailsViewProps)
111110
const filtered = useMemo(() => emails.filter(filterFn), [emails, filterFn]);
112111
const stats = useMemo(() => computeEmailStats(filtered), [filtered]);
113112

114-
const [gridState, setGridState] = useState<DataGridState>(() => ({
115-
...createDefaultDataGridState(emailColumns),
116-
sorting: [{ columnId: "scheduledAt", direction: "desc" }],
117-
}));
113+
const [gridState, setGridState] = useDataGridUrlState(emailColumns, {
114+
paramPrefix: "sentview",
115+
initial: { sorting: [{ columnId: "scheduledAt", direction: "desc" }] },
116+
});
118117

119118
const gridData = useDataSource({
120119
data: filtered,

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
3+
import { UserPickerTable } from "@/components/data-table/user-picker-table";
44
import { FormDialog } from "@/components/form-dialog";
55
import { InputField, SelectField, TextAreaField } from "@/components/form-fields";
66
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography, useToast } from "@/components/ui";
@@ -15,11 +15,10 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1515
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
1616
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
1717
import {
18-
createDefaultDataGridState,
1918
DataGrid,
19+
useDataGridUrlState,
2020
useDataSource,
2121
type DataGridColumnDef,
22-
type DataGridState,
2322
} from "@stackframe/dashboard-ui-components";
2423
import { useEffect, useMemo, useState, type ElementType } from "react";
2524
import * as yup from "yup";
@@ -461,10 +460,10 @@ function EmailLogCard() {
461460
const [emailLogs, setEmailLogs] = useState<AdminSentEmail[]>([]);
462461
const [loading, setLoading] = useState(true);
463462
const [error, setError] = useState<string | null>(null);
464-
const [gridState, setGridState] = useState<DataGridState>(() => ({
465-
...createDefaultDataGridState(emailTableColumns),
466-
sorting: [{ columnId: "sentAt", direction: "desc" }],
467-
}));
463+
const [gridState, setGridState] = useDataGridUrlState(emailTableColumns, {
464+
paramPrefix: "emails",
465+
initial: { sorting: [{ columnId: "sentAt", direction: "desc" }] },
466+
});
468467
const gridData = useDataSource({
469468
data: emailLogs,
470469
columns: emailTableColumns,
@@ -1235,7 +1234,7 @@ function SendEmailDialog(props: {
12351234
<>
12361235
{renderRecipientsBar()}
12371236
{stage === 'recipients' ? (
1238-
<TeamMemberSearchTable
1237+
<UserPickerTable
12391238
action={(user) => (
12401239
<Button
12411240
size="sm"

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/external-db-sync/page-client.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
Switch,
1515
Typography,
1616
} from "@/components/ui";
17-
import { createDefaultDataGridState, DataGrid, useDataSource, type DataGridColumnDef } from "@stackframe/dashboard-ui-components";
17+
import { DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@stackframe/dashboard-ui-components";
1818
import { Result } from "@stackframe/stack-shared/dist/utils/results";
1919
import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
2020
import { urlString } from "@stackframe/stack-shared/dist/utils/urls";
@@ -264,7 +264,7 @@ function SequencerDataGrid({ status, loading }: { status: ExternalDbSyncStatus |
264264
{ name: "DeletedRow", ...status?.sequencer.deleted_rows },
265265
]), [status]);
266266

267-
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
267+
const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "extdbseq" });
268268
const gridData = useDataSource({
269269
data,
270270
columns,
@@ -301,7 +301,7 @@ function DeletedRowsDataGrid({ rows, loading }: { rows: DeletedRowEntry[], loadi
301301
{ id: "max_seq", header: "Max Seq", width: 100, accessor: "max_sequence_id", renderCell: ({ row }) => <DataValue value={row.max_sequence_id} loading={loading} /> },
302302
], [loading]);
303303

304-
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
304+
const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "extdbdeleted" });
305305
const gridData = useDataSource({
306306
data: rows,
307307
columns,
@@ -351,7 +351,7 @@ function PollerDataGrid({ status, loading }: { status: ExternalDbSyncStatus | nu
351351
stale: status?.poller.stale,
352352
}], [status]);
353353

354-
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
354+
const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "extdbpoller" });
355355
const gridData = useDataSource({
356356
data,
357357
columns,
@@ -384,7 +384,7 @@ function SyncEngineDataGrid({ rows, loading }: { rows: MappingStats[], loading:
384384
{ id: "pending", header: "Pending Rows", width: 120, accessor: "internal_pending_count", renderCell: ({ row }) => <DataValue value={row.internal_pending_count} loading={loading} /> },
385385
], [loading]);
386386

387-
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
387+
const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "extdbmap" });
388388
const gridData = useDataSource({
389389
data: rows,
390390
columns,

0 commit comments

Comments
 (0)