¿Estás usando nextjs, shadcn y necesitas una tabla más robusta para mostrar tu información?
Puedes combinar los componentes de interfaz de usuario de Shadcn con TanStack Table en Next.js para crear tablas de datos dinámicas e interactivas. Esta integración aprovecha el diseño minimalista de Shadcn y las poderosas características de TanStack como la ordenación, el filtrado y la paginación.
Documentación de TanStack Table: TanStack TableExternal Link
Documentación de shadcn: shadcnExternal Link
Aquí tienes un ejemplo de la tabla que vamos a construir:
Si quieres ver el código completo: Shadcn Table ExampleExternal Link
1. Instalación
Shadcn tiene una excelente guía de instalación en caso de que quieras consultarla: Guía de instalaciónExternal Link
Si no has instalado shadcn, necesitarás comenzar con npx shadcn-ui@latest init
, luego necesitaremos instalar algunos otros componentes de shadcn:
1. Componentes de Shadcn
npx shadcn-ui@latest add table badge button command popover separator dropdown-menu input select
bash
2. TanStack Table
npm install @tanstack/react-table
bash
3. Instalaciones Opcionales
Iconos para hacer la información más atractiva Radix iconsExternal Link
npm install @radix-ui/react-icons
bash
Y en caso de que estés usando zod para la declaración y validación de esquemas.
Documentación de Zod: ZodExternal Link
npm install zod
bash
2. Data y Estructura
Necesitaremos crear un directorio bajo app/users y agregar los siguientes archivos:
app └── users ├── columns.tsx ├── data-table-column-header.tsx ├── data-table-faceted-filter.tsx ├── data-table-pagination.tsx ├── data-table-row-actions.tsx ├── data-table-toolbar.tsx ├── data-table-view-options.tsx ├── data-table.tsx ├── definitions.ts ├── users.ts ├── userSchema.ts └── page.tsx types.d.ts
Estructura de carpetas
- columns.tsx (componente del cliente) contendrá nuestras definiciones de columna.
- users.ts (datos locales de usuarios).
- data-table-column-header.tsx (componente de cliente) contendrá la ordenación del encabezado.
- data-table-faceted-filter.tsx (componente de cliente) ordenación y filtrado por estado.
- data-table-pagination.tsx (componente de cliente) manejará la paginación de los datos.
- data-table-row-actions.tsx (componente de cliente) opciones de la tabla (editar datos, enlazar a otras páginas).
- data-table-toolbar.tsx (componente de cliente) filtrado de los datos de la tabla.
- data-table-view-options.tsx (componente de cliente) ocultar y mostrar columnas.
- data-table.tsx (componente de cliente) contendrá nuestro componente
<DataTable />
. - definitions.ts (definiciones de datos de estado y rol)
- page.tsx (componente de servidor) es donde obtendremos los datos y renderizaremos nuestra tabla.
Estaremos usando información de usuario como datos locales. Así es como se verán los datos:
export const users: User[] = [ { id: '9953ed85-31a0-4db9-acc8-e25b76176443', userName: 'John Miller', phone: '+1-555-0101', email: 'john.miller@example.com', role: 'client', status: 'inactive', location: '4306 Highland Drive, Seattle, WA 98109', image: 'john.miller.jpg', rtn: 'US2347908701', otherInformation: 'John Miller works in a tech startup in Seattle.', }, // ... ]
app/users/users.ts
Puedes crear un archivo de tipos en la raíz de tu proyecto:
type User = { id: string; userName: string; phone: string; email: string; location: string; role: 'client' | 'provider'; status: 'active' | 'inactive'; image: string; rtn?: string; otherInformation?: string; createdAt?: date; updatedAt?: date; };
types.d.ts
Podemos definir las columnas que necesitamos y qué datos se mostrarán, formatearán, ordenarán y filtrarán.
Comencemos por crear las definiciones de las columnas:
"use client"; import { ColumnDef } from "@tanstack/react-table"; import clsx from "clsx"; import { DataTableColumnHeader } from "@/app/users/data-table-column-header"; import { DataTableRowActions } from "@/app/users/data-table-row-actions"; import { usersRole, usersStatus } from "@/app/users/definitions"; export const columns: ColumnDef<User>[] = [ { accessorKey: "userName", header: ({ column }) => <DataTableColumnHeader column={column} title={"User"} />, cell: ({ row }) => { return <div className="font-medium">{row.getValue("userName")}</div>; }, }, { accessorKey: "phone", header: ({ column }) => <DataTableColumnHeader column={column} title={"Phone"} />, }, { accessorKey: "email", header: ({ column }) => <DataTableColumnHeader column={column} title={"Email"} />, }, { accessorKey: "location", header: ({ column }) => <DataTableColumnHeader column={column} title={"Location"} />, }, { accessorKey: "role", header: ({ column }) => <DataTableColumnHeader column={column} title={"Role"} />, cell: ({ row }) => { const role = usersRole.find((role) => role.value === row.getValue("role")); if (!role) { // Si un valor no es el esperado o no existe, puedes devolver nulo. return null; } return <span>{role.label}</span>; }, }, { accessorKey: "rtn", header: ({ column }) => <DataTableColumnHeader column={column} title={"RTN"} />, }, { accessorKey: "otherInformation", header: ({ column }) => <DataTableColumnHeader column={column} title={"Other Info"} />, }, { accessorKey: "status", header: ({ column }) => <DataTableColumnHeader column={column} title={"Status"} />, cell: ({ row }) => { const status = usersStatus.find((status) => status.value === row.getValue("status")); if (!status) { return null; } return ( <div className={clsx("flex w-[100px] items-center", { "text-red-500": status.value === "inactive", "text-green-500": status.value === "active", })}> {status.icon && <status.icon className="mr-2 h-4 w-4 text-muted-foreground" />} <span>{status.label}</span> </div> ); }, filterFn: (row, id, value) => { return value.includes(row.getValue(id)); }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, }, ];
app/users/columns.tsx
Como podemos observar, todas las columnas tienen un accessorKey
y es la misma que los campos del tipo User
definidos anteriormente.
El header
y la cell
pueden modificarse para mostrar una interfaz específica según tus necesidades; por ejemplo, el estado mostrará texto verde si el estado es activo y texto rojo si es inactivo.
Si no estás seguro de si algunos valores en tus datos estarán disponibles o no, puedes simplemente devolver null
justo como en la linea 36.
Los otros archivos se verán así:
Las definiciones contienen información de lo que será filtrado, texto e iconos para la interfaz.
import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; export const usersStatus = [ { value: "active", label: "Active", icon: CheckCircledIcon, }, { value: "inactive", label: "Inactive", icon: CrossCircledIcon, }, ]; export const usersRole = [ { value: "client", label: "Client", }, { value: "provider", label: "Provider", }, ];
app/users/definitions.ts
Este archivo contiene los encabezados y también añade la funcionalidad de ordenación dependiendo de la columna.
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons"; import { Column } from "@tanstack/react-table"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> { column: Column<TData, TValue>; title: string; } export function DataTableColumnHeader<TData, TValue>({ column, title, className, }: DataTableColumnHeaderProps<TData, TValue>) { if (!column.getCanSort()) { return <div className={cn(className)}>{title}</div>; } return ( <div className={cn("flex items-center space-x-2", className)}> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent"> <span>{title}</span> {column.getIsSorted() === "desc" ? ( <ArrowDownIcon className="ml-2 h-4 w-4" /> ) : column.getIsSorted() === "asc" ? ( <ArrowUpIcon className="ml-2 h-4 w-4" /> ) : ( <CaretSortIcon className="ml-2 h-4 w-4" /> )} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuItem onClick={() => column.toggleSorting(false)}> <ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Asc"} </DropdownMenuItem> <DropdownMenuItem onClick={() => column.toggleSorting(true)}> <ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Desc"} </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Hide"} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ); }
app/users/data-table-column-header.tsx
Podemos añadir acciones como ver, editar y eliminar información de una fila en particular.
"use client"; // import { UserSchema } from "@/app/users/userSchema"; import Link from "next/link"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { Row } from "@tanstack/react-table"; import { Eye, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; interface DataTableRowActionsProps<TData> { row: Row<TData>; } export function DataTableRowActions<TData>({ row }: DataTableRowActionsProps<TData>) { // const user = UserSchema.parse(row.original); // console.log(user.id); // Nota: use el id para cualquier acción (ejemplo: eliminar, ver, editar) return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"> <DotsHorizontalIcon className="h-4 w-4" /> <span className="sr-only">{"Open Menu"}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"} asChild> <Link href={"#"}> <Eye className="w-4 h-4 text-blue-500" /> {<span className="ml-2">{"View"}</span>} </Link> </Button> </DropdownMenuItem> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"} asChild> <Link href={"#"}> <Pencil className="h-4 w-4 text-green-500" /> {<span className="ml-2">{"Update"}</span>} </Link> </Button> </DropdownMenuItem> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"}> <Trash2 className="h-4 w-4 text-red-500" /> {<span className="ml-2">{"Delete"}</span>} </Button> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }
app/users/data-table-row-actions.tsx
En caso de que decidas usar zod para la validación de tus datos, así es como podemos crear un esquema simple.
import { z } from "zod"; export enum Role { "provider" = "provider", "client" = "client", } export enum UserStatus { "active" = "active", "inactive" = "inactive", } export const UserSchema = z.object({ id: z.string(), userName: z.string({}).trim().min(5), phone: z.string({}).trim().min(8), email: z.string({}).email().trim().or(z.literal("")).optional(), location: z.string({}).trim().or(z.literal("")).optional(), role: z.nativeEnum(Role, {}), status: z.nativeEnum(UserStatus, {}), otherInformation: z.string({}).trim().or(z.literal("")).optional(), rtn: z.string({}).trim().or(z.literal("")).optional(), image: z.string().or(z.literal("")).optional(), createdAt: z.date().optional(), updatedAt: z.date().optional(), });
app/users/userSchema.ts
A continuación, crearemos un componente <DataTable />
para ayudarnos a renderizar nuestra tabla. Este archivo contendrá <DataTablePagination />
y <DataTableToolbar />
.
"use client"; import React from "react"; import { ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, SortingState, useReactTable, VisibilityState, } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { DataTablePagination } from "./data-table-pagination"; import { DataTableToolbar } from "./data-table-toolbar"; interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; } export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { const [sorting, setSorting] = React.useState<SortingState>([]); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({ location: false, otherInformation: false, }); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const table = useReactTable({ data, columns, state: { sorting, columnVisibility, columnFilters, }, enableRowSelection: true, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnVisibilityChange: setColumnVisibility, getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, }); return ( <div className="space-y-4"> <DataTableToolbar table={table} /> <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 data results"} </TableCell> </TableRow> )} </TableBody> </Table> </div> <DataTablePagination table={table} /> </div> ); }
app/users/data-table.tsx
La paginación de la tabla de datos tendrá botones para moverse a través de las páginas de datos.
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon, } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; 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> <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> ); }
app/users/data-table-pagination.tsx
La barra de herramientas de la tabla de datos contendrá las opciones de visualización (mostrar/ocultar columnas), ordenación y filtrado.
"use client"; import { Cross2Icon } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { usersStatus } from "@/app/users/definitions"; import { DataTableFacetedFilter } from "./data-table-faceted-filter"; import { DataTableViewOptions } from "./data-table-view-options"; interface DataTableToolbarProps<TData> { table: Table<TData>; } export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) { const isFiltered = table.getState().columnFilters.length > 0; return ( <div className="flex w-full items-center justify-between"> <div className="flex flex-1 items-center space-x-2"> <Input placeholder={"Filter"} value={(table.getColumn("userName")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("userName")?.setFilterValue(event.target.value)} className="h-8 w-[150px] lg:w-[250px]" /> {table.getColumn("status") && ( <DataTableFacetedFilter column={table.getColumn("status")} title={"Status"} options={usersStatus} /> )} {isFiltered && ( <Button variant="outline" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3"> {"Clean Filters"} <Cross2Icon className="ml-2 h-4 w-4" /> </Button> )} </div> <DataTableViewOptions table={table} /> </div> ); }
app/users/data-table-toolbar.tsx
La tabla puede ocultar y mostrar columnas que elijamos. En nuestro ejemplo, Location y OtherInformation están ocultos por defecto.
"use client"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import { MixerHorizontalIcon } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; interface DataTableViewOptionsProps<TData> { table: Table<TData>; } export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex"> <MixerHorizontalIcon className="mr-2 h-4 w-4" /> {"See Columns"} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[160px]"> <DropdownMenuLabel>{"Edit Columns"}</DropdownMenuLabel> <DropdownMenuSeparator /> {table .getAllColumns() .filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)}> {column.id} </DropdownMenuCheckboxItem> ); })} </DropdownMenuContent> </DropdownMenu> ); }
app/users/data-table-view-options.tsx
También podemos filtrar basados en un campo, en nuestro ejemplo podemos filtrar por el estado Active
e Inactive
.
import * as React from "react"; import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; import { Column } from "@tanstack/react-table"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; interface DataTableFacetedFilterProps<TData, TValue> { column?: Column<TData, TValue>; title?: string; options: { label: string; value: string; icon?: React.ComponentType<{ className?: string }>; }[]; } export function DataTableFacetedFilter<TData, TValue>({ column, title, options, }: DataTableFacetedFilterProps<TData, TValue>) { const facets = column?.getFacetedUniqueValues(); const selectedValues = new Set(column?.getFilterValue() as string[]); return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-8 border-dashed"> <PlusCircledIcon className="mr-2 h-4 w-4" /> {title} {selectedValues?.size > 0 && ( <> <Separator orientation="vertical" className="mx-2 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden"> {selectedValues.size} </Badge> <div className="hidden space-x-1 lg:flex"> {selectedValues.size > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal"> {selectedValues.size} {"Selected"} </Badge> ) : ( options .filter((option) => selectedValues.has(option.value)) .map((option) => ( <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal"> {option.label} </Badge> )) )} </div> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0" align="start"> <Command> <CommandInput placeholder={title} /> <CommandList> <CommandEmpty>{"No Filter results"}</CommandEmpty> <CommandGroup> {options.map((option) => { const isSelected = selectedValues.has(option.value); return ( <CommandItem key={option.value} onSelect={() => { if (isSelected) { selectedValues.delete(option.value); } else { selectedValues.add(option.value); } const filterValues = Array.from(selectedValues); column?.setFilterValue(filterValues.length ? filterValues : undefined); }}> <div className={cn( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible" )}> <CheckIcon className={cn("h-4 w-4")} /> </div> {option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />} <span>{option.label}</span> {facets?.get(option.value) && ( <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> {facets.get(option.value)} </span> )} </CommandItem> ); })} </CommandGroup> {selectedValues.size > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem onSelect={() => column?.setFilterValue(undefined)} className="justify-center text-center"> {"Clean Filters"} </CommandItem> </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> ); }
app/users/data-table-faceted-filter.tsx
Finalmente, renderizaremos nuestra tabla en nuestro componente de página.
import { columns } from "@/app/users/columns"; import DataTable from "@/app/users/data-table"; import { users } from "@/app/users/users"; export default async function Home() { // Aquí es donde buscarías datos externos: // const exampleExternalData = await fetchExternalDataFunction(); // In our example we use local data return ( <div className="container p-2"> <DataTable data={users} columns={columns} /> </div> ); }
app/page.tsx