Dashboard Tables
Dashboard tables made easy with a 2 files config!
Last updated
Dashboard tables made easy with a 2 files config!
Last updated
Component: <TableComponent/>
Path: /components/table/table.tsx
Import:
import { TableComponent } from "@/components/table/table";
Table components can be tedious to set up...
To add a new table to your dashboard (or wherever you need one), simply follow these 3 steps:
Add the Table component with your data to your page:
import { TableComponent } from "@/components/table/table";
import { [YOUR_FOLDER]_columns } from "data/dashboard/[YOUR_FOLDER]/columns";
...
<TableComponent
data={data}
columns={[YOUR_FOLDER]_columns}
isLoading={isLoading}
error={error}
sort={{ id: "title", desc: true }}
/>
Define the columns in /data/config/[YOUR_FOLDER]/columns.tsx
:
"use client";
import { Suggestion } from "@prisma/client";
import { ColumnDef } from "@tanstack/react-table";
import { ExternalLink } from "lucide-react";
import { DataTableColumnHeader } from "@/components/table/table-column-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { statuses } from "@/lib/tables/data";
import { DataTableRowActions } from "./actions";
interface SuggestionWithCategory extends Suggestion {
category: {
name: string;
};
}
export const suggestions_columns: ColumnDef<SuggestionWithCategory>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Suggestion" />
),
cell: ({ row }) => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-16 truncate">{row.getValue("id")}</div>
</TooltipTrigger>
<TooltipContent>
<p>{row.getValue("id")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "title",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Title" />
),
cell: ({ row }) => <div className="w-48">{row.getValue("title")}</div>,
enableHiding: false,
},
{
accessorKey: "owner",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Owner" />
),
cell: ({ row }) => <div className="w-32">{row.getValue("owner")}</div>,
enableHiding: false,
},
{
accessorKey: "category",
accessorFn: (row) => row.category?.name,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Category" />
),
cell: ({ row }) => (
<div className="w-32">
<Badge>{row.getValue("category")}</Badge>
</div>
),
enableHiding: false,
},
{
accessorKey: "link",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Link" />
),
cell: ({ row }) => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<a
href={row.getValue("link")}
target="_blank"
rel="noreferrer"
className="w-[80px]"
>
<Button variant="outline" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>{row.getValue("link")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
enableSorting: false,
},
{
accessorKey: "comment",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Comment" />
),
cell: ({ row }) => (
<div className="line-clamp-3 w-64">{row.getValue("comment")}</div>
),
enableSorting: false,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue("status"),
);
if (!status) {
return null;
}
return (
<Badge variant="secondary">
{status.icon && (
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{status.label}</span>
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
// {
// accessorKey: "priority",
// header: ({ column }) => (
// <DataTableColumnHeader column={column} title="Priority" />
// ),
// cell: ({ row }) => {
// const priority = priorities.find(
// (priority) => priority.value === row.getValue("priority"),
// );
// if (!priority) {
// return null;
// }
// return (
// <div className="flex items-center">
// {priority.icon && (
// <priority.icon className="mr-2 h-4 w-4 text-muted-foreground" />
// )}
// <span>{priority.label}</span>
// </div>
// );
// },
// filterFn: (row, id, value) => {
// return value.includes(row.getValue(id));
// },
// },
{
id: "actions",
cell: ({ row }) => <DataTableRowActions row={row} />,
},
];
Create your Actions page (delete/edit...) in the same folder /data/config/[YOUR_FOLDER]/actions.tsx
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Category } from "@prisma/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Row } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { useSession } from "next-auth/react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { LoadingSpinner } from "@/components/shared/icons";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast, useToast } from "@/components/ui/use-toast";
import { metadata } from "@/data/config/metadata";
interface DataTableRowActionsProps<TData> {
row: Row<TData>;
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DialogTrigger asChild>
<DropdownMenuItem>Edit</DropdownMenuItem>
</DialogTrigger>
<DropdownMenuSeparator />
<TableActionDelete row={row} />
</DropdownMenuContent>
</DropdownMenu>
<TableActionEdit setOpen={setOpen} row={row} />
</Dialog>
);
}
//EDIT
const formSchema = z.object({
title: z.string().min(2).max(150),
owner: z.string().min(2).max(150),
category: z.union([z.string().min(2).max(100).optional(), z.literal("")]),
link: z.union([z.string().min(5).max(255).optional(), z.literal("")]),
comment: z.union([z.string().min(3).max(255).optional(), z.literal("")]),
});
export const TableActionEdit = ({
setOpen,
row,
}: {
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
row: any;
}) => {
const queryClient: any = useQueryClient();
const session = useSession();
const userId = session?.data?.user?.id;
const {
isLoading,
error,
data: categories,
} = useQuery({
queryKey: ["categories"],
queryFn: async () => {
const res = await fetch("/api/categories");
return res.json();
},
});
const { toast } = useToast();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: row.original.title || undefined,
owner: row.original.owner || undefined,
category: row.original.category.slug || undefined,
link: row.original.link || undefined,
comment: row.original.comment || undefined,
},
});
const mutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
const res = await fetch(`/api/suggestions/${row.original.id}`, {
method: "PATCH",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`product-sub-${userId}`] });
toast({
title: `Updating ${metadata.productLabel} suggestion`,
description: `Your ${metadata.productLabel} suggestion has been updated before review.`,
});
setOpen(false);
form.reset();
},
onError: () => {
toast({
title: "Error",
description: `An error occurred while submitting your ${metadata.productLabel}.`,
variant: "destructive",
});
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
mutation.mutate(values);
}
return (
<>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit a {metadata.productLabel} suggestion</DialogTitle>
<DialogDescription>
You can edit your {metadata.productLabel} suggestion before it is
reviewed.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize">
{metadata.productLabel} Title
</FormLabel>
<FormControl>
<Input
placeholder={`An awesome ${metadata.productLabel}`}
defaultValue={field.value}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="owner"
render={({ field }) => (
<FormItem>
<FormLabel>Owner</FormLabel>
<FormControl>
<Input
placeholder="An awesome guy"
defaultValue={field.value}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoading && <LoadingSpinner />}
{error && "An error has occurred, please try again."}
{categories?.map((category: Category) => (
<SelectItem key={category.id} value={category.slug}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="link"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
placeholder="https://awesomeproduct.com"
defaultValue={field.value}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel>Comment</FormLabel>
<FormControl>
<Textarea
maxLength={255}
placeholder={`This ${metadata.productLabel} is a good addition because...`}
defaultValue={field.value}
{...field}
className="resize-none"
/>
</FormControl>
<FormDescription>
This is optional, but it will help us review your{" "}
{metadata.productLabel}
faster.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button type="submit">Suggest {metadata.productLabel}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</>
);
};
// DELETE
function TableActionDelete<TData>({ row }: DataTableRowActionsProps<TData>) {
const queryClient = useQueryClient();
const session = useSession();
const userId = session?.data?.user?.id;
const suggestion: any = row.original;
const deleteMutation = useMutation({
mutationKey: [`suggestion-${suggestion.id}`],
mutationFn: async () => {
const res = await fetch(`/api/suggestions/${suggestion.id}`, {
method: "DELETE",
});
return res.json();
},
onSuccess: () => {
toast({
title: "Suggestion deleted",
description: "The suggestion has been deleted.",
});
queryClient.invalidateQueries({ queryKey: [`product-sub-${userId}`] });
},
onError: () => {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
},
});
return (
<DropdownMenuItem onClick={() => deleteMutation.mutate()}>
Delete
</DropdownMenuItem>
);
}
Your Table is now functional!
DirectoryFast attempts to solve this problem by using and simplifying the already excellent components, and adding its own logic for maximum reusability.
Check for more informations.
If you want to build a more specific table, follow the comprehensive .