From 228495cad325ba807b6bcb40f59e9bee7a27daba Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:48:12 +0200 Subject: [PATCH] feat(dashboard): bordered data tables with in-table pagination (#2932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): bordered data tables with in-table pagination - Bordered tables with muted header across all dashboard lists - Pagination as a table footer: "1–10 of 47", numbered pages with ellipsis, prev/next, page size select; hidden when rows fit one page - Default page size 10 (regions table 50) - Settings tables (members, invitations, API keys, connected apps) moved to DataTable with columns.tsx + row actions - Shadowless filter controls, data-slot on section/empty-state, status pages list section semantics * fix(dashboard): address data-table review findings - pagination buttons no longer submit the surrounding members form - page buttons get accessible names; footer wraps on narrow screens - page-size select always includes the active size - drop the always-empty Accepted At column from pending invitations - empty member names show the No name placeholder --- .../app/(dashboard)/notifications/client.tsx | 7 +- .../src/app/(dashboard)/overview/page.tsx | 4 + .../settings/audit-logs/client.tsx | 1 + .../integrations/connected-apps-card.tsx | 103 +----------- .../settings/private-locations/client.tsx | 8 +- .../status-pages/(list)/client.tsx | 15 +- .../status-pages/[id]/history/client.tsx | 5 +- .../[id]/maintenances/(list)/page.tsx | 7 +- .../[id]/status-reports/(list)/page.tsx | 7 +- .../status-pages/[id]/subscribers/page.tsx | 1 + .../src/components/content/empty-state.tsx | 8 +- .../src/components/content/section.tsx | 33 +++- .../monitors/data-table-toolbar.tsx | 2 +- .../response-logs/regions/data-table.tsx | 6 +- .../data-table/settings/api-key/columns.tsx | 83 ++++++++++ .../api-key/data-table-row-actions.tsx | 40 +++++ .../settings/api-key/data-table.tsx | 115 +------------ .../settings/connected-apps/columns.tsx | 70 ++++++++ .../connected-apps/data-table-row-actions.tsx | 40 +++++ .../settings/invitations/columns.tsx | 36 ++++ .../invitations/data-table-row-actions.tsx | 40 +++++ .../settings/invitations/data-table.tsx | 78 ++------- .../data-table/settings/members/columns.tsx | 63 +++++++ .../members/data-table-row-actions.tsx | 83 ++++++++++ .../settings/members/data-table.tsx | 152 ++--------------- .../forms/settings/form-api-key.tsx | 6 +- .../components/onboarding/checks-table.tsx | 5 +- .../data-table/data-table-faceted-filter.tsx | 2 +- .../ui/data-table/data-table-pagination.tsx | 150 ++++++++++++----- .../ui/data-table/data-table-skeleton.tsx | 78 ++++----- .../components/ui/data-table/data-table.tsx | 156 +++++++++--------- 31 files changed, 809 insertions(+), 595 deletions(-) create mode 100644 apps/dashboard/src/components/data-table/settings/api-key/columns.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/invitations/columns.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/members/columns.tsx create mode 100644 apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx diff --git a/apps/dashboard/src/app/(dashboard)/notifications/client.tsx b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx index 2b353346e..e1a7e112a 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx @@ -25,6 +25,7 @@ import { import { columns } from "@/components/data-table/notifications/columns"; import { FormSheetNotifier } from "@/components/forms/notifications/sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { config, hiddenProviders } from "@/data/notifications.client"; import { useTRPC } from "@/lib/trpc/client"; @@ -69,7 +70,11 @@ export function Client() { No notifier found ) : ( - + )}
diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index 2a853478d..8ccf285c4 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -28,6 +28,7 @@ import { MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { buildOverviewData } from "@/data/overview-events.client"; import { useTRPC } from "@/lib/trpc/client"; @@ -134,6 +135,7 @@ export default function Page() { {needsAttention.length > 0 ? ( {recentlyResolved.length > 0 ? ( - queryClient.invalidateQueries({ - queryKey: trpc.oauth.listGrants.queryKey(), - }), - }), - ); - return ( @@ -87,64 +55,11 @@ export function ConnectedAppsCard() { ) : ( -
- - - - App - Access - Connected by - Connected - Last used - - Actions - - - - - {grants.map((grant) => ( - - - {grant.clientName} - - - - {scopeLabel(grant.scope)} - - - - {userLabel(grant.user)} - - - {grant.createdAt ? ( - formatDate(grant.createdAt) - ) : ( - Unknown - )} - - - {grant.lastUsedAt ? ( - formatDate(grant.lastUsedAt) - ) : ( - Never - )} - - -
- - await revoke.mutateAsync({ grantId: grant.id }), - }} - /> -
-
-
- ))} -
-
-
+ )} diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx index bb993d46b..3e7c26319 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx @@ -21,6 +21,7 @@ import { import { columns } from "@/components/data-table/private-locations/columns"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; const EXAMPLES = [ @@ -74,6 +75,7 @@ export function Client() { {workspace.limits["private-locations"] === false ? ( @@ -105,7 +107,11 @@ export function Client() { /> ) : ( - + )}
diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx index 7723dbce2..c44665ace 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { NoteButton } from "@/components/common/note"; import { NoteDismissible } from "@/components/common/note-dismissible"; import { + Section, SectionDescription, SectionGroup, SectionHeader, @@ -35,17 +36,19 @@ export function Client() { - - Status Pages - - Create and manage your status pages. - +
+ + Status Pages + + Create and manage your status pages. + + - +
); } diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx index bbf4188fe..45eed1e1a 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -47,6 +47,7 @@ import { MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { HISTORY_WINDOWS, getColumnVisibility, @@ -162,10 +163,10 @@ export function Client() {
@@ -190,10 +191,10 @@ export function Client() { ) : hasAnyData ? (
) : ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index f9983a7f2..5f633c506 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -18,6 +18,7 @@ import { columns } from "@/components/data-table/maintenances/columns"; import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; @@ -96,7 +97,11 @@ export default function Page() { - + ); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx index 4ce21a964..113f2f073 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx @@ -21,6 +21,7 @@ import { columns } from "@/components/data-table/status-reports/columns"; import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { @@ -134,7 +135,11 @@ export default function Page() { - + ); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx index 16125fe5d..d6a687885 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -158,6 +158,7 @@ export default function Page() { {isLimited ? ( ) { return (
) { return ( -

+

{children}

); @@ -37,6 +42,7 @@ export function EmptyStateDescription({ }: React.ComponentProps<"p">) { return (

diff --git a/apps/dashboard/src/components/content/section.tsx b/apps/dashboard/src/components/content/section.tsx index 3c3b068a5..0684a4ce0 100644 --- a/apps/dashboard/src/components/content/section.tsx +++ b/apps/dashboard/src/components/content/section.tsx @@ -6,7 +6,11 @@ export function Section({ ...props }: React.ComponentProps<"section">) { return ( -

+
{children}
); @@ -18,7 +22,11 @@ export function SectionHeader({ ...props }: React.ComponentProps<"div">) { return ( -
+
{children}
); @@ -31,6 +39,7 @@ export function SectionHeaderRow({ }: React.ComponentProps<"div">) { return (
) { return (

) { return ( -

+

{children}

); @@ -79,6 +93,7 @@ export function SectionGroup({ }: React.ComponentProps<"div">) { return (
@@ -93,7 +108,11 @@ export function SectionGroupHeader({ ...props }: React.ComponentProps<"div">) { return ( -
+
{children}
); @@ -105,7 +124,11 @@ export function SectionGroupTitle({ ...props }: React.ComponentProps<"p">) { return ( -

+

{children}

); diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx index 69c90e3fa..064170b8d 100644 --- a/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx +++ b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx @@ -29,7 +29,7 @@ export function MonitorDataTableToolbar({ placeholder="Filter by name, url, type..." value={(table.getState().globalFilter as string) ?? ""} onChange={(event) => table.setGlobalFilter(event.target.value)} - className="h-8 w-[150px] lg:w-[250px]" + className="h-8 w-[150px] shadow-none lg:w-[250px]" /> {table.getColumn("tags") && ( *:first-child]:pl-0 [&_tr>*:last-child]:pr-0", // the plot hover area bleeds past the last row; clip it instead of scrolling "[&_[data-slot=table-container]]:overflow-y-hidden", "[&_tbody_tr]:transition-[opacity,background-color] [&_tbody_tr]:duration-150 [&_tbody_tr]:ease-out", @@ -70,9 +69,10 @@ export function RegionsDataTable({ )} >
diff --git a/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx new file mode 100644 index 000000000..18ff576e4 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { formatDate } from "@/lib/formatter"; + +import { TableCellEmpty } from "../../table-cell-empty"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type ApiKey = RouterOutputs["apiKey"]["list"][number]; + +/** + * Map a key's `scopes` array to a single human label. v1 keys carry one + * value (`['read']` or `['write']`); legacy rows backfilled to + * `['write']` show as "Read & write." `'*'` is super-admin (synthesized + * by middleware, never settable through any public API). Seeing it on + * a row indicates someone hand-edited the DB or a bug bypassed the + * input enum — surface drift instead of silently mislabeling it. + */ +function scopeLabel(scopes: ApiKey["scopes"]): string { + if (scopes.includes("*")) { + if (typeof console !== "undefined") { + console.warn( + "[api-key table] row carries '*' scope — should never happen via the public API", + ); + } + return "Admin"; + } + if (scopes.includes("write")) return "Read & write"; + if (scopes.includes("read")) return "Read-only"; + return "Unknown"; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + meta: { cellClassName: "font-medium" }, + }, + { + accessorKey: "description", + header: "Description", + meta: { cellClassName: "text-muted-foreground max-w-[200px] truncate" }, + cell: ({ row }) => + row.original.description ? ( + row.original.description + ) : ( + No description + ), + }, + { + accessorKey: "prefix", + header: "Prefix", + cell: ({ row }) => ( + {row.original.prefix}... + ), + }, + { + accessorKey: "scopes", + header: "Access", + cell: ({ row }) => ( + {scopeLabel(row.original.scopes)} + ), + }, + { + accessorKey: "expiresAt", + header: "Expires", + meta: { cellClassName: "text-sm" }, + cell: ({ row }) => + row.original.expiresAt ? ( + formatDate(row.original.expiresAt) + ) : ( + Never + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + }, +]; diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx new file mode 100644 index 000000000..7ed6f926d --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { useTRPC } from "@/lib/trpc/client"; + +type ApiKey = RouterOutputs["apiKey"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const revokeApiKeyMutation = useMutation( + trpc.apiKey.revoke.mutationOptions({ + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: trpc.apiKey.list.queryKey(), + }), + }), + ); + + return ( +
+ { + await revokeApiKeyMutation.mutateAsync({ keyId: row.original.id }); + }, + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx index 6dbbe597d..568e07f77 100644 --- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx @@ -1,115 +1,18 @@ import type { RouterOutputs } from "@openstatus/api"; -import { Badge } from "@openstatus/ui/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@openstatus/ui/components/ui/table"; -import { useMutation } from "@tanstack/react-query"; -import { QuickActions } from "@/components/dropdowns/quick-actions"; -import { formatDate } from "@/lib/formatter"; -import { useTRPC } from "@/lib/trpc/client"; +import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; -import { TableCellEmpty } from "../../table-cell-empty"; +import { columns } from "./columns"; type ApiKey = RouterOutputs["apiKey"]["list"][number]; -/** - * Map a key's `scopes` array to a single human label. v1 keys carry one - * value (`['read']` or `['write']`); legacy rows backfilled to - * `['write']` show as "Read & write." `'*'` is super-admin (synthesized - * by middleware, never settable through any public API). Seeing it on - * a row indicates someone hand-edited the DB or a bug bypassed the - * input enum — surface drift instead of silently mislabeling it. - */ -function scopeLabel(scopes: ApiKey["scopes"]): string { - if (scopes.includes("*")) { - if (typeof console !== "undefined") { - console.warn( - "[api-key table] row carries '*' scope — should never happen via the public API", - ); - } - return "Admin"; - } - if (scopes.includes("write")) return "Read & write"; - if (scopes.includes("read")) return "Read-only"; - return "Unknown"; -} - -export function DataTable({ - apiKeys, - refetch, -}: { - apiKeys: ApiKey[]; - refetch: () => void; -}) { - const trpc = useTRPC(); - const revokeApiKeyMutation = useMutation( - trpc.apiKey.revoke.mutationOptions({ - onSuccess: () => refetch(), - }), - ); - +export function DataTable({ apiKeys }: { apiKeys: ApiKey[] }) { return ( -
- - - - Name - Description - Prefix - Access - Expires - - Actions - - - - - {apiKeys.map((apiKey) => ( - - {apiKey.name} - - {apiKey.description ? ( - apiKey.description - ) : ( - No description - )} - - - {apiKey.prefix}... - - - {scopeLabel(apiKey.scopes)} - - - {apiKey.expiresAt ? ( - formatDate(apiKey.expiresAt) - ) : ( - Never - )} - - -
- - await revokeApiKeyMutation.mutateAsync({ - keyId: apiKey.id, - }), - }} - /> -
-
-
- ))} -
-
-
+ ); } diff --git a/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx new file mode 100644 index 000000000..849232277 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { formatDate } from "@/lib/formatter"; + +import { TableCellEmpty } from "../../table-cell-empty"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Grant = RouterOutputs["oauth"]["listGrants"][number]; + +function scopeLabel(scope: Grant["scope"]): string { + return scope.includes("write") ? "Read & write" : "Read-only"; +} + +function userLabel(user: Grant["user"]): string { + if (!user) return "Removed user"; + const name = [user.firstName, user.lastName].filter(Boolean).join(" "); + return name || user.email || `User ${user.id}`; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "clientName", + header: "App", + meta: { cellClassName: "font-medium" }, + }, + { + accessorKey: "scope", + header: "Access", + cell: ({ row }) => ( + {scopeLabel(row.original.scope)} + ), + }, + { + id: "user", + header: "Connected by", + meta: { cellClassName: "text-muted-foreground max-w-[200px] truncate" }, + cell: ({ row }) => userLabel(row.original.user), + }, + { + accessorKey: "createdAt", + header: "Connected", + meta: { cellClassName: "text-sm" }, + cell: ({ row }) => + row.original.createdAt ? ( + formatDate(row.original.createdAt) + ) : ( + Unknown + ), + }, + { + accessorKey: "lastUsedAt", + header: "Last used", + meta: { cellClassName: "text-sm" }, + cell: ({ row }) => + row.original.lastUsedAt ? ( + formatDate(row.original.lastUsedAt) + ) : ( + Never + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + }, +]; diff --git a/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx new file mode 100644 index 000000000..b7e65f176 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { useTRPC } from "@/lib/trpc/client"; + +type Grant = RouterOutputs["oauth"]["listGrants"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const revokeGrantMutation = useMutation( + trpc.oauth.revokeGrant.mutationOptions({ + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: trpc.oauth.listGrants.queryKey(), + }), + }), + ); + + return ( +
+ { + await revokeGrantMutation.mutateAsync({ grantId: row.original.id }); + }, + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx new file mode 100644 index 000000000..793385d93 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { formatDate } from "@/lib/formatter"; + +import { TableCellEmpty } from "../../table-cell-empty"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Invitation = RouterOutputs["invitation"]["list"][number]; + +export const columns: ColumnDef[] = [ + { accessorKey: "email", header: "Email" }, + { accessorKey: "role", header: "Role" }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => + row.original.createdAt ? ( + formatDate(row.original.createdAt) + ) : ( + Unknown + ), + }, + { + accessorKey: "expiresAt", + header: "Expires At", + cell: ({ row }) => formatDate(row.original.expiresAt), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + }, +]; diff --git a/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx new file mode 100644 index 000000000..e8f0ddd18 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { useTRPC } from "@/lib/trpc/client"; + +type Invitation = RouterOutputs["invitation"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deleteInvitationMutation = useMutation( + trpc.invitation.delete.mutationOptions({ + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: trpc.invitation.list.queryKey(), + }), + }), + ); + + return ( +
+ { + await deleteInvitationMutation.mutateAsync({ id: row.original.id }); + }, + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx index 769b40e7e..1d25d173a 100644 --- a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx @@ -1,34 +1,19 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@openstatus/ui/components/ui/table"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; -import { QuickActions } from "@/components/dropdowns/quick-actions"; -import { formatDate } from "@/lib/formatter"; +import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; -import { TableCellEmpty } from "../../table-cell-empty"; +import { columns } from "./columns"; export function DataTable() { const trpc = useTRPC(); - const { data: invitations, refetch } = useQuery( - trpc.invitation.list.queryOptions(), - ); - const deleteInvitationMutation = useMutation( - trpc.invitation.delete.mutationOptions({ - onSuccess: () => refetch(), - }), - ); + const { data: invitations } = useQuery(trpc.invitation.list.queryOptions()); if (!invitations) return null; @@ -44,53 +29,10 @@ export function DataTable() { } return ( - - - - Email - Role - Created At - Expires At - Accepted At - - Actions - - - - - {invitations.map((item) => ( - - {item.email} - {item.role} - - {item.createdAt ? ( - formatDate(item.createdAt) - ) : ( - Unknown - )} - - {formatDate(item.expiresAt)} - - {item.acceptedAt ? ( - formatDate(item.acceptedAt) - ) : ( - Pending - )} - - -
- - deleteInvitationMutation.mutateAsync({ id: item.id }), - }} - /> -
-
-
- ))} -
-
+ ); } diff --git a/apps/dashboard/src/components/data-table/settings/members/columns.tsx b/apps/dashboard/src/components/data-table/settings/members/columns.tsx new file mode 100644 index 000000000..158628ad4 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/members/columns.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { useQuery } from "@tanstack/react-query"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { formatDate } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; + +import { TableCellEmpty } from "../../table-cell-empty"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Member = RouterOutputs["member"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + id: "name", + header: "Name", + cell: ({ row }) => , + }, + { + id: "email", + header: "Email", + cell: ({ row }) => row.original.user.email, + }, + { + accessorKey: "role", + header: "Role", + cell: ({ row }) => ( + + {row.original.role} + + ), + }, + { + id: "createdAt", + header: "Created", + cell: ({ row }) => + formatDate(row.original.user.createdAt ?? row.original.createdAt), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + }, +]; + +function MemberName({ member }: { member: Member }) { + const trpc = useTRPC(); + const { data: user } = useQuery(trpc.user.get.queryOptions()); + const isMe = Boolean(user?.id && member.user.id === user.id); + + return ( +
+ {member.user.name || No name} + {isMe ? You : null} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx new file mode 100644 index 000000000..b1fa7b39a --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { ShieldUser } from "@openstatus/icons"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useState } from "react"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { useTRPC } from "@/lib/trpc/client"; + +import { TransferOwnershipDialog } from "./transfer-ownership-dialog"; + +type Member = RouterOutputs["member"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: members } = useQuery(trpc.member.list.queryOptions()); + const { data: currentUser } = useQuery(trpc.user.get.queryOptions()); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const deleteMemberMutation = useMutation( + trpc.member.delete.mutationOptions({ + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: trpc.member.list.queryKey(), + }), + }), + ); + const [transferOpen, setTransferOpen] = useState(false); + + const member = row.original; + const { user } = member; + const isOwner = + members?.find((m) => m.user.id === currentUser?.id)?.role === "owner"; + const isMe = Boolean(currentUser?.id && user.id === currentUser.id); + const canTransfer = + member.role !== "owner" && !user.deletedAt && !!user.email; + + // The server rejects non-owners; don't offer what will fail. + if (!isOwner || isMe) return null; + + return ( +
+ setTransferOpen(true), + }, + ] + : undefined + } + deleteAction={{ + confirmationValue: user.email ?? "user", + description: workspace?.ssoEnabled + ? "This workspace uses SSO. They can sign in again and rejoin automatically unless you also remove them from your identity provider." + : undefined, + submitAction: async () => { + await deleteMemberMutation.mutateAsync({ id: user.id }); + }, + }} + /> + {/* Outside `QuickActions`: its dropdown item unmounts with the menu. */} + {canTransfer && user.email ? ( + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx index 6fa1ebc45..9b3a58692 100644 --- a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx @@ -1,154 +1,22 @@ -import { ShieldUser } from "@openstatus/icons"; -import { Badge } from "@openstatus/ui/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@openstatus/ui/components/ui/table"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; -import { QuickActions } from "@/components/dropdowns/quick-actions"; -import { formatDate } from "@/lib/formatter"; +import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; -import { TableCellEmpty } from "../../table-cell-empty"; -import { TransferOwnershipDialog } from "./transfer-ownership-dialog"; +import { columns } from "./columns"; export function DataTable() { const trpc = useTRPC(); - const { data: members, refetch } = useQuery(trpc.member.list.queryOptions()); - const { data: user } = useQuery(trpc.user.get.queryOptions()); - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const deleteMemberMutation = useMutation( - trpc.member.delete.mutationOptions({ - onSuccess: () => refetch(), - }), - ); + const { data: members } = useQuery(trpc.member.list.queryOptions()); if (!members) return null; - const currentUserId = user?.id; - const isOwner = - members.find((m) => m.user.id === currentUserId)?.role === "owner"; - - return ( - - - - Name - Email - Role - Created - - Actions - - - - - {members.map((item) => { - const isMe = Boolean(currentUserId && item.user.id === currentUserId); - // The server rejects non-owners; don't offer what will fail. - const canDelete = isOwner && !isMe; - - return ( - - -
- {item.user.name ?? No name} - {isMe ? You : null} -
-
- {item.user.email} - - - {item.role} - - - - {formatDate(item.user.createdAt ?? item.createdAt)} - - - {canDelete ? ( - - await deleteMemberMutation.mutateAsync({ - id: item.user.id, - }) - } - /> - ) : null} - -
- ); - })} -
-
- ); -} - -function MemberRowActions({ - member, - ssoEnabled, - onDelete, -}: { - member: { - role: string; - user: { - id: number; - name: string | null; - email: string | null; - deletedAt: Date | null; - }; - }; - ssoEnabled: boolean; - onDelete: () => Promise; -}) { - const [transferOpen, setTransferOpen] = useState(false); - const { user } = member; - const canTransfer = - member.role !== "owner" && !user.deletedAt && !!user.email; - return ( -
- setTransferOpen(true), - }, - ] - : undefined - } - deleteAction={{ - confirmationValue: user.email ?? "user", - description: ssoEnabled - ? "This workspace uses SSO. They can sign in again and rejoin automatically unless you also remove them from your identity provider." - : undefined, - submitAction: onDelete, - }} - /> - {/* Outside `QuickActions`: its dropdown item unmounts with the menu. */} - {canTransfer && user.email ? ( - - ) : null} -
+ ); } diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index 3d002a0ab..ee60e6817 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -26,9 +26,7 @@ import { useTRPC } from "@/lib/trpc/client"; export function FormApiKey() { const trpc = useTRPC(); - const { data: apiKeys = [], refetch } = useQuery( - trpc.apiKey.list.queryOptions(), - ); + const { data: apiKeys = [] } = useQuery(trpc.apiKey.list.queryOptions()); return ( @@ -47,7 +45,7 @@ export function FormApiKey() { ) : ( - + )} diff --git a/apps/dashboard/src/components/onboarding/checks-table.tsx b/apps/dashboard/src/components/onboarding/checks-table.tsx index 14f825ab4..97267e47a 100644 --- a/apps/dashboard/src/components/onboarding/checks-table.tsx +++ b/apps/dashboard/src/components/onboarding/checks-table.tsx @@ -92,10 +92,7 @@ export function OnboardingChecksTable({ if (rows.length === 0 && isStreaming) { return (
diff --git a/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx b/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx index 8c4863d3c..b9bceb830 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx @@ -48,7 +48,7 @@ export function DataTableFacetedFilter({ variant="outline" size="sm" className={cn( - "h-8 border-dashed", + "h-8 border-dashed shadow-none", selectedValues?.size > 0 && "border-solid", )} > diff --git a/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx b/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx index fded59948..33eaafca4 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx @@ -95,53 +95,121 @@ export function DataTablePagination({ ); } +/** First, last and the current page's neighbours; "…" never hides a single page. */ +function getPageItems(current: number, count: number): (number | "ellipsis")[] { + if (count <= 5) return Array.from({ length: count }, (_, i) => i); + const last = count - 1; + const pages = new Set([0, last, current - 1, current, current + 1]); + if (current <= 2) [1, 2, 3].forEach((p) => pages.add(p)); + if (current >= last - 2) + [last - 3, last - 2, last - 1].forEach((p) => pages.add(p)); + const sorted = [...pages] + .filter((p) => p >= 0 && p <= last) + .sort((x, y) => x - y); + + const items: (number | "ellipsis")[] = []; + for (const page of sorted) { + const prev = items.at(-1); + if (typeof prev === "number" && page - prev === 2) items.push(prev + 1); + else if (typeof prev === "number" && page - prev > 2) + items.push("ellipsis"); + items.push(page); + } + return items; +} + +const PAGE_SIZES = [10, 20, 50, 100]; + export function DataTablePaginationSimple({ table, }: DataTablePaginationProps) { + const { pageIndex, pageSize } = table.getState().pagination; + const total = table.getFilteredRowModel().rows.length; + const pageCount = table.getPageCount(); + const from = pageIndex * pageSize + 1; + const to = Math.min(total, (pageIndex + 1) * pageSize); + + if (total <= table.initialState.pagination.pageSize) return null; + + const pageSizes = [...new Set([...PAGE_SIZES, pageSize])].sort( + (a, b) => a - b, + ); + return ( -
-
- {table.getFilteredRowModel().rows.length} of{" "} - {table.getPreFilteredRowModel().rows.length} row(s) filtered. -
-
- - - - + {getPageItems(pageIndex, pageCount).map((item, i) => + item === "ellipsis" ? ( + + … + + ) : ( + + ), + )} + + + ) : null}
); } diff --git a/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx b/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx index 7298f07de..334ea6be7 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx @@ -18,45 +18,47 @@ interface DataTableSkeletonProps { export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { return ( - - - - - - - - - - - - - - - - - - - - {Array.from({ length: rows }).map((_, i) => ( - - - - - - - - +
+
+ + + - - - - - - - + + + + + + + + + + + - ))} - -
+ + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + + + + + + + + + + + ))} + + +
); } diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx index 06d616275..715a365b5 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx @@ -68,7 +68,7 @@ export function DataTable({ defaultSorting = [], defaultColumnVisibility = {}, defaultColumnFilters = [], - defaultPagination = { pageIndex: 0, pageSize: 20 }, + defaultPagination = { pageIndex: 0, pageSize: 10 }, autoResetPageIndex = true, columnFilters, setColumnFilters, @@ -105,6 +105,7 @@ export function DataTable({ const table = useReactTable({ data, columns, + initialState: { pagination: defaultPagination }, state: { sorting: sortingState, columnVisibility: columnVisibilityState, @@ -137,83 +138,88 @@ export function DataTable({ {toolbarComponent ? React.createElement(toolbarComponent, { table }) : null} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + { + // nested links/buttons own their click + if ((e.target as HTMLElement).closest("a,button")) return; + onRowClick?.(row); + }} + className="data-[state=selected]:bg-muted/50" > - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - { - // nested links/buttons own their click - if ((e.target as HTMLElement).closest("a,button")) return; - onRowClick?.(row); - }} - className="data-[state=selected]:bg-muted/50" - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - {row.getIsExpanded() && ( - - - {rowComponent - ? React.createElement(rowComponent, { row }) - : null} - + + ))} - )} - - )) - ) : ( - - - No results. - - - )} - - {actionBar ? React.createElement(actionBar, { table }) : null} -
- {paginationComponent - ? React.createElement(paginationComponent, { table }) - : null} + {row.getIsExpanded() && ( + + + {rowComponent + ? React.createElement(rowComponent, { row }) + : null} + + + )} + + )) + ) : ( + + + No results. + + + )} + + {actionBar ? React.createElement(actionBar, { table }) : null} + + {paginationComponent + ? React.createElement(paginationComponent, { table }) + : null} +
); } -- 2.54.0