Browse Source

basic table component testing

Milo Schwartz 8 months ago
parent
commit
c8c4a73e52

+ 1 - 0
package.json

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

+ 1 - 1
src/app/[orgId]/layout.tsx

@@ -53,7 +53,7 @@ export default async function ConfigurationLaytout({
 
     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">
                     <Header email={user.email} orgName={params.orgId} />
                     <TopbarNav items={topNavItems} orgId={params.orgId} />

+ 56 - 26
src/app/[orgId]/sites/[siteId]/layout.tsx

@@ -1,19 +1,21 @@
-import { Metadata } from "next"
-import Image from "next/image"
+import { Metadata } from "next";
+import Image from "next/image";
 
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/sidebar-nav"
-import SiteProvider from "@app/providers/SiteProvider"
-import { internal } from "@app/api"
-import { GetSiteResponse } from "@server/routers/site"
-import { AxiosResponse } from "axios"
-import { redirect } from "next/navigation"
-import { authCookieHeader } from "@app/api/cookies"
+import { Separator } from "@/components/ui/separator";
+import { SidebarNav } from "@/components/sidebar-nav";
+import SiteProvider from "@app/providers/SiteProvider";
+import { internal } from "@app/api";
+import { GetSiteResponse } from "@server/routers/site";
+import { AxiosResponse } from "axios";
+import { redirect } from "next/navigation";
+import { authCookieHeader } from "@app/api/cookies";
+import Link from "next/link";
+import { ArrowLeft, ChevronLeft } from "lucide-react";
 
 export const metadata: Metadata = {
     title: "Forms",
     description: "Advanced form example using react-hook-form and Zod.",
-}
+};
 
 const sidebarNavItems = [
     {
@@ -32,21 +34,27 @@ const sidebarNavItems = [
         title: "Display",
         href: "/{orgId}/sites/{siteId}/display",
     },
-]
+];
 
 interface SettingsLayoutProps {
-    children: React.ReactNode,
-    params: { siteId: string, orgId: string }
+    children: React.ReactNode;
+    params: { siteId: string; orgId: string };
 }
 
-export default async function SettingsLayout({ children, params }: SettingsLayoutProps) {
+export default async function SettingsLayout({
+    children,
+    params,
+}: SettingsLayoutProps) {
     let site = null;
     if (params.siteId !== "create") {
         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;
         } 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"
                 />
             </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="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">
-                        {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>
                 </div>
-                <Separator className="my-6" />
-                <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+                <div className="flex flex-col space-y-6 lg:flex-row lg:space-x-12 lg:space-y-0">
                     <aside className="-mx-4 lg:w-1/5">
-                        <SidebarNav items={sidebarNavItems} disabled={params.siteId == "create"} />
+                        <SidebarNav
+                            items={sidebarNavItems}
+                            disabled={params.siteId == "create"}
+                        />
                     </aside>
                     <div className="flex-1 lg:max-w-2xl">
-                        <SiteProvider site={site}>
-                            {children}
-                        </SiteProvider>
+                        <SiteProvider site={site}>{children}</SiteProvider>
                     </div>
                 </div>
             </div>
         </>
-    )
+    );
 }

+ 140 - 0
src/app/[orgId]/sites/components/DataTable.tsx

@@ -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>
+    );
+}

+ 102 - 0
src/app/[orgId]/sites/components/DataTablePagination.tsx

@@ -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>
+    );
+}

+ 110 - 0
src/app/[orgId]/sites/components/SitesTable.tsx

@@ -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`);
+            }}
+        />
+    );
+}

+ 14 - 1
src/app/[orgId]/sites/page.tsx

@@ -2,6 +2,7 @@ import { internal } from "@app/api";
 import { authCookieHeader } from "@app/api/cookies";
 import { ListSitesResponse } from "@server/routers/site";
 import { AxiosResponse } from "axios";
+import SitesTable, { SiteRow } from "./components/SitesTable";
 
 type SitesPageProps = {
     params: { orgId: string };
@@ -19,9 +20,19 @@ export default async function Page({ params }: SitesPageProps) {
         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 (
         <>
-            <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">
                     Manage Sites
                 </h2>
@@ -29,6 +40,8 @@ export default async function Page({ params }: SitesPageProps) {
                     Manage your existing sites here or create a new one.
                 </p>
             </div>
+
+            <SitesTable sites={siteRows} orgId={params.orgId} />
         </>
     );
 }

+ 1 - 1
src/app/globals.css

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

+ 117 - 0
src/components/ui/table.tsx

@@ -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,
+}