Bläddra i källkod

feat(dashboard): Implement data table pagination

Michael Bromley 10 månader sedan
förälder
incheckning
fd6e1efb62

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 137 - 313
package-lock.json


+ 1 - 0
packages/dashboard/package.json

@@ -17,6 +17,7 @@
     "@radix-ui/react-dialog": "^1.1.6",
     "@radix-ui/react-dropdown-menu": "^2.1.6",
     "@radix-ui/react-label": "^2.1.2",
+    "@radix-ui/react-select": "^2.1.6",
     "@radix-ui/react-separator": "^1.1.2",
     "@radix-ui/react-slot": "^1.1.2",
     "@radix-ui/react-tooltip": "^1.1.8",

+ 179 - 0
packages/dashboard/src/components/ui/select.tsx

@@ -0,0 +1,179 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+  return <SelectPrimitive.Root data-slot="select" {...props} />
+}
+
+function SelectGroup({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+  return <SelectPrimitive.Group data-slot="select-group" {...props} />
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+  return <SelectPrimitive.Value data-slot="select-value" {...props} />
+}
+
+function SelectTrigger({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      className={cn(
+        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon asChild>
+        <ChevronDownIcon className="size-4 opacity-50" />
+      </SelectPrimitive.Icon>
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "popper",
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Content
+        data-slot="select-content"
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
+          position === "popper" &&
+            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+          className
+        )}
+        position={position}
+        {...props}
+      >
+        <SelectScrollUpButton />
+        <SelectPrimitive.Viewport
+          className={cn(
+            "p-1",
+            position === "popper" &&
+              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
+          )}
+        >
+          {children}
+        </SelectPrimitive.Viewport>
+        <SelectScrollDownButton />
+      </SelectPrimitive.Content>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+  return (
+    <SelectPrimitive.Label
+      data-slot="select-label"
+      className={cn("px-2 py-1.5 text-sm font-medium", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className
+      )}
+      {...props}
+    >
+      <span className="absolute right-2 flex size-3.5 items-center justify-center">
+        <SelectPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </SelectPrimitive.ItemIndicator>
+      </span>
+      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+  return (
+    <SelectPrimitive.ScrollUpButton
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronUpIcon className="size-4" />
+    </SelectPrimitive.ScrollUpButton>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+  return (
+    <SelectPrimitive.ScrollDownButton
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronDownIcon className="size-4" />
+    </SelectPrimitive.ScrollDownButton>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

+ 0 - 1
packages/dashboard/src/framework/internal/component-registry/data-types/boolean.tsx

@@ -1,6 +1,5 @@
 import { CheckIcon, XIcon, LucideIcon } from 'lucide-react';
 
 export function BooleanDisplayCheckbox({ value }: { value: boolean }) {
-    console.log(`value`, value);
     return value ? <CheckIcon /> : <XIcon />;
 }

+ 83 - 0
packages/dashboard/src/framework/internal/data-table/data-table-pagination.tsx

@@ -0,0 +1,83 @@
+import { Table } from '@tanstack/react-table';
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
+interface DataTablePaginationProps<TData> {
+    table: Table<TData>;
+}
+
+export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
+    return (
+        <div className="flex items-center justify-between px-2">
+            <div className="flex-1 text-sm text-muted-foreground">
+                {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length}{' '}
+                row(s) selected.
+            </div>
+            <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>
+                        <ChevronsLeft />
+                    </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>
+                        <ChevronLeft />
+                    </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>
+                        <ChevronRight />
+                    </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>
+                        <ChevronsRight />
+                    </Button>
+                </div>
+            </div>
+        </div>
+    );
+}

+ 81 - 40
packages/dashboard/src/framework/internal/data-table/data-table.tsx

@@ -1,59 +1,100 @@
 'use client';
 
-import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
-
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { DataTablePagination } from '@/framework/internal/data-table/data-table-pagination.js';
+
+import {
+    ColumnDef,
+    flexRender,
+    getCoreRowModel,
+    getPaginationRowModel,
+    PaginationState,
+    useReactTable,
+} from '@tanstack/react-table';
+import React, { useEffect } from 'react';
 
 interface DataTableProps<TData, TValue> {
     columns: ColumnDef<TData, TValue>[];
     data: TData[];
+    totalItems: number;
+    page?: number;
+    itemsPerPage?: number;
+    onPageChange?: (page: number, itemsPerPage: number) => void;
 }
 
-export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
+export function DataTable<TData, TValue>({
+    columns,
+    data,
+    totalItems,
+    page,
+    itemsPerPage,
+    onPageChange,
+}: DataTableProps<TData, TValue>) {
+    const [pagination, setPagination] = React.useState<PaginationState>({
+        pageIndex: (page ?? 1) - 1,
+        pageSize: itemsPerPage ?? 10,
+    });
     const table = useReactTable({
         data,
         columns,
         getCoreRowModel: getCoreRowModel(),
+        getPaginationRowModel: getPaginationRowModel(),
+        manualPagination: true,
+        rowCount: totalItems,
+        onPaginationChange: setPagination,
+        state: {
+            pagination,
+        },
     });
 
+    useEffect(() => {
+        onPageChange?.(pagination.pageIndex + 1, pagination.pageSize);
+    }, [pagination]);
+
     return (
-        <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>
-                                ))}
+        <>
+            <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>
-                        ))
-                    ) : (
-                        <TableRow>
-                            <TableCell colSpan={columns.length} className="h-24 text-center">
-                                No results.
-                            </TableCell>
-                        </TableRow>
-                    )}
-                </TableBody>
-            </Table>
-        </div>
+                        )}
+                    </TableBody>
+                </Table>
+            </div>
+            <DataTablePagination table={table} />
+        </>
     );
 }

+ 33 - 4
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -5,8 +5,12 @@ import {
     getQueryName,
 } from '@/framework/internal/document-introspection/get-document-structure.js';
 import { api } from '@/graphql/api.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { useQuery } from '@tanstack/react-query';
+import { AnyRouter, Route, useNavigate } from '@tanstack/react-router';
+import { AnyRoute } from '@tanstack/react-router';
 import { createColumnHelper } from '@tanstack/react-table';
 import { ResultOf } from 'gql.tada';
 import React from 'react';
@@ -25,26 +29,42 @@ export type CustomizeColumnConfig<T extends TypedDocumentNode> = {
     };
 };
 
-export interface ListPageProps<T extends TypedDocumentNode<U>, U extends { [key: string]: any }> {
+export type ListQueryShape = {
+    [key: string]: {
+        items: any[];
+        totalItems: number;
+    };
+};
+
+export interface ListPageProps<T extends TypedDocumentNode<U>, U extends ListQueryShape> {
     title: string;
     listQuery: T;
     customizeColumns?: CustomizeColumnConfig<T>;
+    route: AnyRoute;
 }
 
 export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string, any> = any>({
     title,
     listQuery,
     customizeColumns,
+    route,
 }: ListPageProps<T, U>) {
     const { getComponent } = useComponentRegistry();
+    const routeSearch = route.useSearch();
+    const navigate = useNavigate<AnyRouter>({ from: route.fullPath });
+    const pagination = {
+        page: routeSearch.page ? parseInt(routeSearch.page) : 1,
+        itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : 10,
+    };
     const { data } = useQuery({
         queryFn: () =>
             api.query(listQuery, {
                 options: {
-                    take: 10,
+                    take: pagination.itemsPerPage,
+                    skip: (pagination.page - 1) * pagination.itemsPerPage,
                 },
             }),
-        queryKey: ['ProductList'],
+        queryKey: ['ListPage', route.id, pagination],
     });
     const fields = getListQueryFields(listQuery);
     const queryName = getQueryName(listQuery);
@@ -78,7 +98,16 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
     return (
         <div className="m-4">
             <h1 className="text-2xl font-bold">{title}</h1>
-            <DataTable columns={columns} data={(data as any)?.[queryName]?.items ?? []}></DataTable>
+            <DataTable
+                columns={columns}
+                data={(data as any)?.[queryName]?.items ?? []}
+                page={pagination.page}
+                itemsPerPage={pagination.itemsPerPage}
+                totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
+                onPageChange={(page, perPage) => {
+                    navigate({ search: () => ({ page, perPage }) as never });
+                }}
+            ></DataTable>
         </div>
     );
 }

+ 5 - 4
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -13,10 +13,11 @@ const productListDocument = graphql(`
             items {
                 id
                 createdAt
-                updatedAt
                 name
-                description
+                updatedAt
+                enabled
             }
+            totalItems
         }
     }
 `);
@@ -27,9 +28,9 @@ export function ProductListPage() {
             title="Products"
             listQuery={productListDocument}
             customizeColumns={{
-                id: { header: 'ID' },
-                name: { header: 'Name' },
+                name: { header: 'Product Name' },
             }}
+            route={Route}
         />
     );
 }

Vissa filer visades inte eftersom för många filer har ändrats