commit
d9c3c0e1e6
9 changed files with 463 additions and 4 deletions
49
src/app/categories/columns.tsx
Normal file
49
src/app/categories/columns.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Category } from '@prisma/client';
|
||||
import { CellContext, ColumnDefTemplate } from '@tanstack/table-core';
|
||||
|
||||
export const columns = (
|
||||
actionCell: ColumnDefTemplate<CellContext<Category, unknown>>,
|
||||
) => {
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'color',
|
||||
header: 'Color',
|
||||
cell: ({row}) => {
|
||||
return (
|
||||
<svg className="h-5" fill={row.original.color} viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="10"/>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
cell: ({row}) => {
|
||||
const date = row.getValue('createdAt') as Date;
|
||||
return date.toDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updatedAt',
|
||||
header: 'Updated at',
|
||||
cell: ({row}) => {
|
||||
const date = row.getValue('updatedAt') as Date;
|
||||
return date.toDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: actionCell,
|
||||
},
|
||||
] as ColumnDef<Category>[];
|
||||
};
|
|
@ -1,7 +1,30 @@
|
|||
import { getUser } from '@/auth';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import React from 'react';
|
||||
import CategoryPageClientContent from '@/components/categoryPageClientComponents';
|
||||
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
|
||||
import categoryDelete from '@/lib/actions/categoryDelete';
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
|
||||
const user = await getUser();
|
||||
|
||||
const categories = await prismaClient.category.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
name: 'asc',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex items-center justify-center min-h-screen text-3xl">
|
||||
Categories
|
||||
</main>
|
||||
<CategoryPageClientContent
|
||||
categories={categories}
|
||||
onSubmit={categoryCreateUpdate}
|
||||
onDelete={categoryDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import React from 'react';
|
||||
import EntityPageClientContent from '@/components/EntityPageClientComponents';
|
||||
import EntityPageClientContent from '@/components/entityPageClientComponents';
|
||||
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
|
||||
import entityDelete from '@/lib/actions/entityDelete';
|
||||
|
||||
|
|
146
src/components/categoryPageClientComponents.tsx
Normal file
146
src/components/categoryPageClientComponents.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
'use client';
|
||||
|
||||
import { Category } from '@prisma/client';
|
||||
import React, { useState } from 'react';
|
||||
import { CellContext } from '@tanstack/table-core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { columns } from '@/app/categories/columns';
|
||||
import { z } from 'zod';
|
||||
import { ActionResponse } from '@/lib/types/ActionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
import CategoryForm from '@/components/form/categoryForm';
|
||||
|
||||
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
|
||||
categories: Category[],
|
||||
onSubmit: (data: z.infer<typeof categoryFormSchema>) => Promise<ActionResponse>,
|
||||
onDelete: (id: number) => Promise<ActionResponse>,
|
||||
className: string,
|
||||
}) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>(undefined);
|
||||
|
||||
async function handleSubmit(data: z.infer<typeof categoryFormSchema>) {
|
||||
const response = await onSubmit(data);
|
||||
router.refresh();
|
||||
setIsEditDialogOpen(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleDelete(id: number | undefined) {
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await onDelete(id);
|
||||
toast(sonnerContent(response));
|
||||
if (response.redirect) {
|
||||
router.push(response.redirect);
|
||||
}
|
||||
router.refresh();
|
||||
setIsDeleteDialogOpen(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
const actionCell = ({row}: CellContext<Category, unknown>) => {
|
||||
const category = row.original as Category;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setSelectedCategory(category);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
<span className="sr-only">Edit category</span>
|
||||
<Edit className="h-4 w-4"/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setSelectedCategory(category);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Delete category</span>
|
||||
<Trash className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="text-3xl font-semibold">Entities</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
className="w-full"
|
||||
columns={columns(actionCell)}
|
||||
data={categories}
|
||||
pagination/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>Delete Category?</AlertDialogHeader>
|
||||
<p>Are your sure you want to delete the category {selectedCategory?.name}?</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(selectedCategory?.id)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
103
src/components/form/categoryForm.tsx
Normal file
103
src/components/form/categoryForm.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ActionResponse } from '@/lib/types/ActionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
import { Category } from '@prisma/client';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
|
||||
export default function CategoryForm({value, onSubmit, className}: {
|
||||
value: Category | undefined,
|
||||
onSubmit: (data: z.infer<typeof categoryFormSchema>) => Promise<ActionResponse>
|
||||
className?: string
|
||||
}) {
|
||||
|
||||
const router = useRouter();
|
||||
const colorInput = React.createRef<HTMLInputElement>();
|
||||
|
||||
const form = useForm<z.infer<typeof categoryFormSchema>>({
|
||||
resolver: zodResolver(categoryFormSchema),
|
||||
defaultValues: {
|
||||
id: value?.id ?? undefined,
|
||||
name: value?.name ?? '',
|
||||
color: value?.color ?? '#' + Math.floor(Math.random() * 16777215).toString(16),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof categoryFormSchema>) => {
|
||||
const response = await onSubmit(data);
|
||||
toast(sonnerContent(response));
|
||||
if (response.redirect) {
|
||||
router.push(response.redirect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input autoComplete="false" type="hidden" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={className}>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="color"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<div onClick={() => colorInput.current?.click()}
|
||||
className="rounded-md aspect-square w-10 cursor-pointer border border-black items-bottom"
|
||||
style={{backgroundColor: field.value}}>
|
||||
|
||||
<input id="color"
|
||||
name="color"
|
||||
type="color"
|
||||
className="opacity-0 w-10 h-8"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
ref={colorInput}
|
||||
required/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">{value?.id ? 'Update Category' : 'Create Category'}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
65
src/lib/actions/categoryCreateUpdate.ts
Normal file
65
src/lib/actions/categoryCreateUpdate.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { z } from 'zod';
|
||||
import { ActionResponse } from '@/lib/types/ActionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
|
||||
export default async function categoryCreateUpdate({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to create/update an category.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
|
||||
// create/update category
|
||||
try {
|
||||
if (id) {
|
||||
await prismaClient.category.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
name: name,
|
||||
color: color,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// return success
|
||||
return {
|
||||
type: 'success',
|
||||
message: `'${name}' updated`,
|
||||
};
|
||||
} else {
|
||||
await prismaClient.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: name,
|
||||
color: color,
|
||||
},
|
||||
});
|
||||
|
||||
// return success
|
||||
return {
|
||||
type: 'success',
|
||||
message: `'${name}' created`,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Failed creating/updating category',
|
||||
};
|
||||
}
|
||||
}
|
62
src/lib/actions/categoryDelete.ts
Normal file
62
src/lib/actions/categoryDelete.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { ActionResponse } from '@/lib/types/ActionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
|
||||
export default async function categoryDelete(id: number): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
// check that id is a number
|
||||
if (!id || isNaN(id)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Invalid category ID',
|
||||
};
|
||||
}
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to delete an category.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
|
||||
// check that category is associated with user
|
||||
const category = await prismaClient.category.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Category not found',
|
||||
};
|
||||
}
|
||||
|
||||
// delete category
|
||||
try {
|
||||
await prismaClient.category.delete({
|
||||
where: {
|
||||
id: category.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Failed deleting category',
|
||||
};
|
||||
}
|
||||
|
||||
// return success
|
||||
return {
|
||||
type: 'success',
|
||||
message: `'${category.name}' deleted`,
|
||||
};
|
||||
}
|
11
src/lib/form-schemas/categoryFormSchema.ts
Normal file
11
src/lib/form-schemas/categoryFormSchema.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const categoryFormSchema = z.object({
|
||||
id: z.number().positive().optional(),
|
||||
name: z.string().min(1).max(32),
|
||||
color: z.string()
|
||||
.min(7)
|
||||
.max(7)
|
||||
.startsWith('#')
|
||||
.refine(value => value.split('#')[1].match(/^[0-9a-fA-F]{6}$/)),
|
||||
});
|
Loading…
Add table
Reference in a new issue