From c085ffe03a1598ec43abb3945ff3277c01cc5028 Mon Sep 17 00:00:00 2001 From: Felix-Asante Date: Wed, 27 Dec 2023 16:11:30 +0100 Subject: [PATCH] categories implementation --- src/actions/category.ts | 49 ++++- src/actions/place.ts | 26 ++- src/app/(dashboard)/bookings/page.tsx | 6 +- .../categories/_sections/CategorySection.tsx | 38 ++++ .../categories/_sections/CategoryTable.tsx | 172 ++++++++++++++++++ .../categories/_sections/NewCategoryModal.tsx | 143 +++++++++++++++ src/app/(dashboard)/categories/page.tsx | 14 +- .../(dashboard)/places/edit/[slug]/page.tsx | 21 ++- src/app/(dashboard)/places/page.tsx | 19 +- src/config/constants/errors.ts | 2 + src/lib/apiHandler.ts | 1 + src/rules/validations/category.ts | 15 ++ src/rules/validations/place.ts | 2 +- src/types/index.ts | 6 + 14 files changed, 480 insertions(+), 34 deletions(-) create mode 100644 src/app/(dashboard)/categories/_sections/CategorySection.tsx create mode 100644 src/app/(dashboard)/categories/_sections/CategoryTable.tsx create mode 100644 src/app/(dashboard)/categories/_sections/NewCategoryModal.tsx create mode 100644 src/rules/validations/category.ts diff --git a/src/actions/category.ts b/src/actions/category.ts index 63f9ab1..fcd00ef 100644 --- a/src/actions/category.ts +++ b/src/actions/category.ts @@ -1,16 +1,61 @@ +"use server"; import { apiConfig } from "@/lib/apiConfig"; import { apiHandler } from "@/lib/apiHandler"; +import { SeverActionResponse } from "@/types"; import { Category } from "@/types/category"; import { getErrorMessage } from "@/utils/helpers"; +import { Tags } from "@/utils/tags"; +import { revalidateTag } from "next/cache"; -export async function getCategory(): Promise { +export async function getCategory(): Promise> { try { const endpoint = apiConfig.categories.list(); const categories = await apiHandler({ endpoint, method: "GET", + next: { tags: [Tags.categories] }, }); - return categories; + return { results: categories }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +} +export async function createCategory(body: FormData) { + try { + const endpoint = apiConfig.categories.create(); + await apiHandler({ + endpoint, + method: "POST", + json: false, + body, + }); + revalidateTag(Tags.categories); + } catch (error) { + throw new Error(getErrorMessage(error)); + } +} +export async function updateCategory(categoryId: string, body: FormData) { + try { + const endpoint = apiConfig.categories.update(categoryId); + await apiHandler({ + endpoint, + method: "PUT", + json: false, + body, + }); + revalidateTag(Tags.categories); + } catch (error) { + throw new Error(getErrorMessage(error)); + } +} +export async function deleteCategory(categoryId: string) { + try { + const endpoint = apiConfig.categories.delete(categoryId); + await apiHandler({ + endpoint, + method: "DELETE", + }); + revalidateTag(Tags.categories); } catch (error) { throw new Error(getErrorMessage(error)); } diff --git a/src/actions/place.ts b/src/actions/place.ts index c1c2600..e0d3521 100644 --- a/src/actions/place.ts +++ b/src/actions/place.ts @@ -3,7 +3,7 @@ import { DASHBOARD_PATHS } from "@/config/routes"; import { apiConfig } from "@/lib/apiConfig"; import { apiHandler } from "@/lib/apiHandler"; -import { ResponseMeta } from "@/types"; +import { ResponseMeta, SeverActionResponse } from "@/types"; import { User } from "@/types/auth"; import { CreatePlaceDto } from "@/types/dtos/places"; import { Place } from "@/types/place"; @@ -18,7 +18,9 @@ interface PlacesResponse { meta: ResponseMeta; } -export async function getPlaces(query: Query): Promise { +export async function getPlaces( + query: Query, +): Promise> { try { const endpoint = apiConfig.places.list(query); const places = await apiHandler({ @@ -26,9 +28,9 @@ export async function getPlaces(query: Query): Promise { method: "GET", next: { tags: [Tags.places] }, }); - return places; + return { results: places }; } catch (error) { - throw new Error(getErrorMessage(error)); + throw { error: getErrorMessage(error) }; } } @@ -54,28 +56,32 @@ export async function deletePlace(placeId: string) { throw new Error(getErrorMessage(error)); } } -export async function getPlaceBySlug(slug: string): Promise { +export async function getPlaceBySlug( + slug: string, +): Promise> { try { const endpoint = apiConfig.places.get_by_slug(slug); const place = await apiHandler({ endpoint, method: "GET", }); - return place; + return { results: place }; } catch (error) { - throw new Error(getErrorMessage(error)); + return { error: getErrorMessage(error) }; } } -export async function getPlaceAdmin(placeId: string): Promise { +export async function getPlaceAdmin( + placeId: string, +): Promise> { try { const endpoint = apiConfig.places.get_admin(placeId); const admin = await apiHandler({ endpoint, method: "GET", }); - return admin; + return { results: admin }; } catch (error) { - throw new Error(getErrorMessage(error)); + return { error: getErrorMessage(error) }; } } diff --git a/src/app/(dashboard)/bookings/page.tsx b/src/app/(dashboard)/bookings/page.tsx index ed1552f..3c1d56e 100644 --- a/src/app/(dashboard)/bookings/page.tsx +++ b/src/app/(dashboard)/bookings/page.tsx @@ -23,12 +23,12 @@ export default async function BookingsPage({ searchParams }: PageProps) { ]); const totalBookings = bookings?.results?.meta?.totalItems; - + const error = bookings?.error ?? categories?.error; return ( - +
+ + {totalPages > 1 && ( + + add("page", page.toString())} + /> + + )} + + ); +} diff --git a/src/app/(dashboard)/categories/_sections/CategoryTable.tsx b/src/app/(dashboard)/categories/_sections/CategoryTable.tsx new file mode 100644 index 0000000..85b6498 --- /dev/null +++ b/src/app/(dashboard)/categories/_sections/CategoryTable.tsx @@ -0,0 +1,172 @@ +import EmptyContent from "@/components/shared/EmptyContent"; +import HStack from "@/components/shared/layout/HStack"; +import { Category } from "@/types/category"; +import { + Button, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + useDisclosure, +} from "@nextui-org/react"; +import { PencilIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; +import NewCategoryModal from "./NewCategoryModal"; +import Modal from "@/components/shared/modal"; +import { ERRORS } from "@/config/constants/errors"; +import { useServerAction } from "@/hooks/useServerAction"; +import { deleteCategory } from "@/actions/category"; +import { toast } from "sonner"; +import { getErrorMessage } from "@/utils/helpers"; + +interface CategoryTableProp { + categories: Category[]; + totalCategories: number; +} +export default function CategoryTable(props: CategoryTableProp) { + const { categories, totalCategories } = props; + + const { isOpen, onOpen, onClose } = useDisclosure(); + const [selectedCategory, setSelectedCategory] = useState( + null, + ); + + const [runDeleteCategory, { loading }] = useServerAction< + any, + typeof deleteCategory + >(deleteCategory); + + const removeCategory = async () => { + try { + if (!selectedCategory?.id) { + toast.error("Operation not allowed"); + return; + } + + await runDeleteCategory(selectedCategory.id); + toast.success("Category deleted"); + setSelectedCategory(null); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }; + + useEffect(() => { + if (!isOpen) { + setSelectedCategory(null); + } + }, [isOpen]); + + return ( + <> + +
+

+ {totalCategories} {totalCategories > 1 ? "Category" : "Categories"} +

+

List of all categories

+
+ +
+
+ + + Name + {null} + + + } + > + {categories.map((category) => ( + + {category?.name} + + + + + + + + ))} + +
+ + setSelectedCategory(null)} + title='Delete Category' + description={ERRORS.MESSAGE.DELETE_PROMPT} + content={ + + + + + } + /> +
+ + ); +} diff --git a/src/app/(dashboard)/categories/_sections/NewCategoryModal.tsx b/src/app/(dashboard)/categories/_sections/NewCategoryModal.tsx new file mode 100644 index 0000000..53a854f --- /dev/null +++ b/src/app/(dashboard)/categories/_sections/NewCategoryModal.tsx @@ -0,0 +1,143 @@ +import { createCategory, updateCategory } from "@/actions/category"; +import FileUpload from "@/components/shared/input/FileUpload"; +import TextField from "@/components/shared/input/TextField"; +import { useReactHookForm } from "@/hooks/useReactHookForm"; +import { useServerAction } from "@/hooks/useServerAction"; +import { + CreateCategoryFields, + UpdateCategoryField, + UpdateCategorySchema, + createCategorySchema, +} from "@/rules/validations/category"; +import { createPlaceSchema } from "@/rules/validations/place"; +import { ModalProps } from "@/types"; +import { Category } from "@/types/category"; +import { getErrorMessage } from "@/utils/helpers"; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@nextui-org/react"; +import React, { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +type Props = ModalProps & { category: Category | null }; +type Types = CreateCategoryFields | UpdateCategoryField; + +export default function NewCategoryModal({ + isOpen, + onClose, + mode = "create", + category, +}: Props) { + const schema = + mode === "create" ? createCategorySchema : UpdateCategorySchema; + const { + handleSubmit, + control, + setValue, + register, + formState: { isValid, touchedFields }, + reset, + } = useReactHookForm(schema, { name: category?.name ?? "" }); + + const title = mode === "create" ? "Add new category" : "Update category"; + + const [runCreateCategory, { loading }] = useServerAction< + any, + typeof createCategory + >(createCategory); + const [runUpdateCategory, { loading: updating }] = useServerAction< + any, + typeof updateCategory + >(updateCategory); + + useEffect(() => { + if (category && isOpen) { + setValue("name", category?.name, { + shouldTouch: true, + shouldDirty: true, + shouldValidate: true, + }); + } else { + reset(); + } + }, [category, isOpen]); + + const onSubmit = async (data: Types) => { + try { + const formData = new FormData(); + formData.append("name", data?.name); + if (data?.picture) { + formData.append("picture", data?.picture?.[0]); + } + + if (mode === "create") { + await runCreateCategory(formData); + } else if (mode === "edit" && category) { + await runUpdateCategory(category?.id, formData); + } + onClose(); + const message = + mode === "create" + ? "New category added" + : "Category successfully updated"; + toast.success(message); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }; + + return ( + + + {(onClose) => ( + <> + {title} + + + + + + + + + + )} + + + ); +} diff --git a/src/app/(dashboard)/categories/page.tsx b/src/app/(dashboard)/categories/page.tsx index 607b776..a2b917d 100644 --- a/src/app/(dashboard)/categories/page.tsx +++ b/src/app/(dashboard)/categories/page.tsx @@ -1,5 +1,15 @@ +import { getCategory } from "@/actions/category"; +import WithServerError from "@/components/hoc/WithServerError"; import React from "react"; +import CategorySection from "./_sections/CategorySection"; -export default function CategoriesPage() { - return
CategoriesPage
; +export default async function CategoriesPage() { + const categories = await getCategory(); + return ( + +
+ +
+
+ ); } diff --git a/src/app/(dashboard)/places/edit/[slug]/page.tsx b/src/app/(dashboard)/places/edit/[slug]/page.tsx index f757200..f86cb99 100644 --- a/src/app/(dashboard)/places/edit/[slug]/page.tsx +++ b/src/app/(dashboard)/places/edit/[slug]/page.tsx @@ -1,6 +1,7 @@ import { getCategory } from "@/actions/category"; import { getPlaceAdmin, getPlaceBySlug } from "@/actions/place"; import UpdatePlaceSection from "../../sections/update/UpdatePlaceSection"; +import WithServerError from "@/components/hoc/WithServerError"; interface PageProps { params: { @@ -12,15 +13,19 @@ export default async function EditPlacePage({ params }: PageProps) { getCategory(), getPlaceBySlug(params.slug), ]); - const placeAdmin = await getPlaceAdmin(place?.id); + const placeAdmin = await getPlaceAdmin(place?.results!?.id); + + const error = categories?.error ?? place?.error; return ( -
- -
+ +
+ +
+
); } diff --git a/src/app/(dashboard)/places/page.tsx b/src/app/(dashboard)/places/page.tsx index ad54585..86c8610 100644 --- a/src/app/(dashboard)/places/page.tsx +++ b/src/app/(dashboard)/places/page.tsx @@ -1,6 +1,7 @@ import { getPlaces } from "@/actions/place"; import PlacesContentSection from "./sections/PlacesContentSection"; import { getCategory } from "@/actions/category"; +import WithServerError from "@/components/hoc/WithServerError"; interface PageProps { searchParams: { @@ -14,14 +15,16 @@ export default async function Places({ searchParams }: PageProps) { getPlaces(searchParams), getCategory(), ]); - + const error = categories?.error ?? places?.error; return ( -
- -
+ +
+ +
+
); } diff --git a/src/config/constants/errors.ts b/src/config/constants/errors.ts index 577a65e..8ec9dff 100644 --- a/src/config/constants/errors.ts +++ b/src/config/constants/errors.ts @@ -18,5 +18,7 @@ export const ERRORS = { }, MESSAGE: { NETWORK: "Network Error", + DELETE_PROMPT: + "Do you really want to delete this category? This operation cannot be undone.", }, }; diff --git a/src/lib/apiHandler.ts b/src/lib/apiHandler.ts index a257bcc..c0ac3e3 100644 --- a/src/lib/apiHandler.ts +++ b/src/lib/apiHandler.ts @@ -34,6 +34,7 @@ export const apiHandler = async ({ if (json) { requestHeaders["Content-Type"] = "application/json"; } + const res = await fetch(apiConfig.baseUrl + endpoint, { method, body: requestBody, diff --git a/src/rules/validations/category.ts b/src/rules/validations/category.ts new file mode 100644 index 0000000..4018794 --- /dev/null +++ b/src/rules/validations/category.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { File } from "./place"; + +export const createCategorySchema = z.object({ + name: z.string({ required_error: "Category name is required" }).min(1), + picture: z.any(), +}); + +export type CreateCategoryFields = z.infer; + +export const UpdateCategorySchema = createCategorySchema.partial({ + picture: true, +}); + +export type UpdateCategoryField = z.infer; diff --git a/src/rules/validations/place.ts b/src/rules/validations/place.ts index 1b540db..29e60dc 100644 --- a/src/rules/validations/place.ts +++ b/src/rules/validations/place.ts @@ -1,6 +1,6 @@ import * as z from "zod"; -const File = z.object({ +export const File = z.object({ name: z.string(), size: z.number(), type: z.string(), diff --git a/src/types/index.ts b/src/types/index.ts index 2e3cf91..f312748 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,12 @@ export interface ResponseMeta { currentPage: number; } +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + mode?: "edit" | "create"; +} + export interface SeverActionResponse { error?: string; results?: R;