basic table component testing

This commit is contained in:
Milo Schwartz 2024-10-14 23:05:07 -04:00
parent 4c27d1302e
commit c8c4a73e52
No known key found for this signature in database
9 changed files with 542 additions and 29 deletions

View file

@ -34,6 +34,7 @@
"@radix-ui/react-toast": "1.2.2", "@radix-ui/react-toast": "1.2.2",
"@react-email/components": "0.0.25", "@react-email/components": "0.0.25",
"@react-email/tailwind": "0.1.0", "@react-email/tailwind": "0.1.0",
"@tanstack/react-table": "8.20.5",
"axios": "1.7.7", "axios": "1.7.7",
"better-sqlite3": "11.3.0", "better-sqlite3": "11.3.0",
"class-variance-authority": "0.7.0", "class-variance-authority": "0.7.0",

View file

@ -53,7 +53,7 @@ export default async function ConfigurationLaytout({
return ( return (
<> <>
<div className="w-full bg-neutral-100 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-900 mb-6 select-none sm:px-0 px-3 pt-3"> <div className="w-full bg-muted border-b border-bg mb-6 select-none sm:px-0 px-3 pt-3">
<div className="container mx-auto flex flex-col content-between gap-4"> <div className="container mx-auto flex flex-col content-between gap-4">
<Header email={user.email} orgName={params.orgId} /> <Header email={user.email} orgName={params.orgId} />
<TopbarNav items={topNavItems} orgId={params.orgId} /> <TopbarNav items={topNavItems} orgId={params.orgId} />

View file

@ -1,19 +1,21 @@
import { Metadata } from "next" import { Metadata } from "next";
import Image from "next/image" import Image from "next/image";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/sidebar-nav" import { SidebarNav } from "@/components/sidebar-nav";
import SiteProvider from "@app/providers/SiteProvider" import SiteProvider from "@app/providers/SiteProvider";
import { internal } from "@app/api" import { internal } from "@app/api";
import { GetSiteResponse } from "@server/routers/site" import { GetSiteResponse } from "@server/routers/site";
import { AxiosResponse } from "axios" import { AxiosResponse } from "axios";
import { redirect } from "next/navigation" import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies" import { authCookieHeader } from "@app/api/cookies";
import Link from "next/link";
import { ArrowLeft, ChevronLeft } from "lucide-react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Forms", title: "Forms",
description: "Advanced form example using react-hook-form and Zod.", description: "Advanced form example using react-hook-form and Zod.",
} };
const sidebarNavItems = [ const sidebarNavItems = [
{ {
@ -32,21 +34,27 @@ const sidebarNavItems = [
title: "Display", title: "Display",
href: "/{orgId}/sites/{siteId}/display", href: "/{orgId}/sites/{siteId}/display",
}, },
] ];
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode, children: React.ReactNode;
params: { siteId: string, orgId: string } params: { siteId: string; orgId: string };
} }
export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { export default async function SettingsLayout({
children,
params,
}: SettingsLayoutProps) {
let site = null; let site = null;
if (params.siteId !== "create") { if (params.siteId !== "create") {
try { try {
const res = await internal.get<AxiosResponse<GetSiteResponse>>(`/site/${params.siteId}`, authCookieHeader()); const res = await internal.get<AxiosResponse<GetSiteResponse>>(
`/site/${params.siteId}`,
authCookieHeader(),
);
site = res.data.data; site = res.data.data;
} catch { } catch {
redirect(`/${params.orgId}/sites`) redirect(`/${params.orgId}/sites`);
} }
} }
@ -68,25 +76,47 @@ export default async function SettingsLayout({ children, params }: SettingsLayou
className="hidden dark:block" className="hidden dark:block"
/> />
</div> </div>
<div className="mb-4">
<Link
href={`/${params.orgId}/sites`}
className="text-primary font-medium"
>
<div className="flex items-center gap-0.5 hover:underline">
<ChevronLeft />
<span>View all sites</span>
</div>
</Link>
</div>
<div className="hidden space-y-6 0 pb-16 md:block"> <div className="hidden space-y-6 0 pb-16 md:block">
<div className="space-y-0.5"> <div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2> <h2 className="text-2xl font-bold tracking-tight">
{params.siteId == "create"
? "New Site"
: site?.name + " Settings" || "Site Settings"
}
</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{params.siteId == "create" ? "Create site..." : "Manage settings on " + site?.name || ""}. {params.siteId == "create"
? "Create a new site"
: "Configure the settings on your site: " +
site?.name || ""}
.
</p> </p>
</div> </div>
<Separator className="my-6" /> <div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5"> <aside className="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={params.siteId == "create"} /> <SidebarNav
items={sidebarNavItems}
disabled={params.siteId == "create"}
/>
</aside> </aside>
<div className="flex-1 lg:max-w-2xl"> <div className="flex-1 lg:max-w-2xl">
<SiteProvider site={site}> <SiteProvider site={site}>{children}</SiteProvider>
{children}
</SiteProvider>
</div> </div>
</div> </div>
</div> </div>
</> </>
) );
} }

View file

@ -0,0 +1,140 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "./DataTablePagination";
import { Plus } from "lucide-react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addSite?: () => void;
}
export function DataTable<TData, TValue>({
addSite,
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
return (
<div>
<div className="flex items-center justify-between pb-4">
<Input
placeholder="Search sites"
value={
(table.getColumn("name")?.getFilterValue() as string) ??
""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<Button onClick={() => {
if (addSite) {
addSite();
}
}}>
<Plus className="mr-2 h-4 w-4" /> Add Site
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
);
}

View file

@ -0,0 +1,102 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-end px-2">
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue
placeholder={
table.getState().pagination.pageSize
}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "./DataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export type SiteRow = {
id: string;
name: string;
mbIn: number;
mbOut: number;
orgId: string;
};
export const columns: ColumnDef<SiteRow>[] = [
{
accessorKey: "id",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "mbIn",
header: "MB In",
},
{
accessorKey: "mbOut",
header: "MB Out",
},
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link
href={`/${siteRow.orgId}/sites/${siteRow.id}`}
>
View settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
type SitesTableProps = {
sites: SiteRow[];
orgId: string;
};
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
return (
<DataTable
columns={columns}
data={sites}
addSite={() => {
router.push(`/${orgId}/sites/create`);
}}
/>
);
}

View file

@ -2,6 +2,7 @@ import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./components/SitesTable";
type SitesPageProps = { type SitesPageProps = {
params: { orgId: string }; params: { orgId: string };
@ -19,9 +20,19 @@ export default async function Page({ params }: SitesPageProps) {
console.error("Error fetching sites", e); console.error("Error fetching sites", e);
} }
const siteRows: SiteRow[] = sites.map((site) => {
return {
id: site.siteId.toString(),
name: site.name,
mbIn: site.megabytesIn || 0,
mbOut: site.megabytesOut || 0,
orgId: params.orgId,
};
});
return ( return (
<> <>
<div className="space-y-0.5 select-none"> <div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
Manage Sites Manage Sites
</h2> </h2>
@ -29,6 +40,8 @@ export default async function Page({ params }: SitesPageProps) {
Manage your existing sites here or create a new one. Manage your existing sites here or create a new one.
</p> </p>
</div> </div>
<SitesTable sites={siteRows} orgId={params.orgId} />
</> </>
); );
} }

View file

@ -36,7 +36,7 @@
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 231 10% 20%; --secondary: 231 10% 20%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
--muted: 193 10% 25%; --muted: 231 10% 18%;
--muted-foreground: 231 0% 65%; --muted-foreground: 231 0% 65%;
--accent: 193 10% 25%; --accent: 193 10% 25%;
--accent-foreground: 231 0% 95%; --accent-foreground: 231 0% 95%;

117
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}