From 2e2f99e0ec3398bfabc8f0a011ea460c6dbb781f Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Wed, 30 Aug 2023 21:48:04 -0500 Subject: [PATCH 01/14] add admin bar with create button --- src/pages/resources/index.tsx | 83 +++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/src/pages/resources/index.tsx b/src/pages/resources/index.tsx index 87a1f4c..6779419 100644 --- a/src/pages/resources/index.tsx +++ b/src/pages/resources/index.tsx @@ -1,5 +1,5 @@ import { LinkIcon } from "@heroicons/react/20/solid"; -import { PrinterIcon } from "@heroicons/react/24/solid"; +import { PrinterIcon, PlusCircleIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; import { useRouter } from "next/router"; import ResourceTable from "~/components/ResourceTable"; @@ -8,6 +8,42 @@ import { parseQueryData } from "~/utils/parseSearchForm"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { type AuditoryResource } from "@prisma/client"; import { QueryWaitWrapper } from "~/components/LoadingWrapper"; +import { AdminBarLayout } from "~/components/admin/ControlBar"; +import { AdminActionLink } from "~/components/admin/common"; + +const PageHeader = ({ printLink }: { printLink: string }) => { + return ( + <div className="mb-2 flex flex-row justify-between p-2 print:hidden sm:mb-4 sm:p-4"> + <section className="space-y-2"> + <h1 className="text-3xl font-bold">All Resources</h1> + <div className=""> + <p className="inline">Fill out the </p> + <Link + href="/resources/search" + className="inline rounded-lg border border-neutral-800 bg-neutral-200 px-2 py-[4px] hover:bg-neutral-900 hover:text-white" + > + search form + <LinkIcon className="inline w-4" /> + </Link> + <p className="inline"> + {" "} + for a list of auditory training resource recommendations. + </p> + </div> + </section> + + <section className="mt-auto"> + <Link + href={printLink} + className="inline-block whitespace-nowrap rounded-md border border-neutral-900 bg-yellow-200 px-4 py-2 align-middle font-semibold shadow shadow-black/50 duration-200 ease-out hover:bg-yellow-300 hover:shadow-md print:hidden sm:space-x-2" + > + <span className="hidden sm:inline-block">Print Results</span> + <PrinterIcon className="inline-block w-6" /> + </Link> + </section> + </div> + ); +}; const Resources = () => { const router = useRouter(); @@ -49,39 +85,22 @@ const Resources = () => { return ( <HeaderFooterLayout> - <div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2"> - <div className="mb-2 flex flex-row justify-between p-2 print:hidden sm:mb-4 sm:p-4"> - <section className="space-y-2"> - <h1 className="text-3xl font-bold">All Resources</h1> - <div className=""> - <p className="inline">Fill out the </p> - <Link - href="/resources/search" - className="inline rounded-lg border border-neutral-800 bg-neutral-200 px-2 py-[4px] hover:bg-neutral-900 hover:text-white" - > - search form - <LinkIcon className="inline w-4" /> - </Link> - <p className="inline"> - {" "} - for a list of auditory training resource recommendations. - </p> - </div> - </section> + <AdminBarLayout + actions={[ + <AdminActionLink + key="cancel" + symbol={<PlusCircleIcon className="w-4" />} + label="Create New" + href={`/resources/create`} + />, + ]} + > + <div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2"> + <PageHeader printLink={printLink} /> - <section className="mt-auto"> - <Link - href={printLink} - className="inline-block whitespace-nowrap rounded-md border border-neutral-900 bg-yellow-200 px-4 py-2 align-middle font-semibold shadow shadow-black/50 duration-200 ease-out hover:bg-yellow-300 hover:shadow-md print:hidden sm:space-x-2" - > - <span className="hidden sm:inline-block">Print Results</span> - <PrinterIcon className="inline-block w-6" /> - </Link> - </section> + <QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} /> </div> - - <QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} /> - </div> + </AdminBarLayout> </HeaderFooterLayout> ); }; -- 2.47.2 From 6e4efe2842e842518f064bd1846c4b9a9a65f4ae Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Wed, 30 Aug 2023 22:07:56 -0500 Subject: [PATCH 02/14] reuse form to create resource create page --- src/components/admin/resources/form.tsx | 29 +++++++++------ src/pages/resources/create.tsx | 49 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 src/pages/resources/create.tsx diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index 6d6ac69..ceb2fa5 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -86,15 +86,22 @@ const SelectImageInput = () => { htmlFor="resource-image-file" className="bg-whit group relative cursor-pointer overflow-hidden rounded-xl border border-neutral-400 drop-shadow-lg" > - <ResourcePhoto - name={name ?? "unknown resource logo"} - photo={photo ?? null} - src={icon} - /> - - <div className="absolute bottom-0 left-0 right-0 top-0 hidden place-items-center group-hover:grid group-hover:bg-white/70"> - <PencilSquareIcon className="w-16 text-black/50" /> - </div> + {photo ? ( + <> + <ResourcePhoto + name={name ?? "unknown resource logo"} + photo={photo} + src={icon} + /> + <div className="absolute bottom-0 left-0 right-0 top-0 hidden place-items-center group-hover:grid group-hover:bg-white/70"> + <PencilSquareIcon className="w-16 text-black/50" /> + </div> + </> + ) : ( + <div className="grid aspect-square place-items-center hover:bg-white/70"> + <PencilSquareIcon className="h-16 w-16 text-black/50" /> + </div> + )} </label> <input onChange={onChange} @@ -372,9 +379,7 @@ function ResourceSummarySubForm({ <MultiSelectorMany details={register("payment_options", { required: true })} label="Price Category" - defaultValues={ - resource?.payment_options ?? [PaymentType.FREE.toString()] - } + defaultValues={resource?.payment_options ?? []} > <PaymentTypeOption type={PaymentType.FREE} label="Free" /> <PaymentTypeOption diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx new file mode 100644 index 0000000..2a0ced8 --- /dev/null +++ b/src/pages/resources/create.tsx @@ -0,0 +1,49 @@ +import { XCircleIcon, PlusCircleIcon } from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { type SubmitHandler, useForm } from "react-hook-form"; +import { AdminBarLayout } from "~/components/admin/ControlBar"; +import { AdminActionButton, AdminActionLink } from "~/components/admin/common"; +import { + ResourceForm, + type ResourceUpdateInput, +} from "~/components/admin/resources/form"; +import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; + +const EditResourcePage = () => { + const formMethods = useForm<ResourceUpdateInput>(); + + const [serverError, _setServerError] = useState<string | undefined>(undefined); + + const onSubmit: SubmitHandler<ResourceUpdateInput> = () => { + // TODO: TRPC request to create resource + }; + + return ( + <HeaderFooterLayout> + <AdminBarLayout + actions={[ + <AdminActionButton + key="create" + symbol={<PlusCircleIcon className="w-4" />} + label="Create" + onClick={() => { + onSubmit(formMethods.getValues()); + }} + />, + <AdminActionLink + key="cancel" + symbol={<XCircleIcon className="w-4" />} + label="Cancel" + href={`/resources`} + />, + ]} + > + <div className="mb-12"> + <ResourceForm methods={formMethods} error={serverError} /> + </div> + </AdminBarLayout> + </HeaderFooterLayout> + ); +}; + +export default EditResourcePage; -- 2.47.2 From 7fc0895177554fd3921d928c85cd580b804e9ecd Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Wed, 30 Aug 2023 22:13:41 -0500 Subject: [PATCH 03/14] fix issue where edit page resource query was refetching causing icon file to be reset --- src/pages/resources/[id]/edit.tsx | 3 +++ src/pages/resources/create.tsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index a5d33c2..471db47 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -25,6 +25,9 @@ const EditResourcePage = () => { retry(_failureCount, error) { return error.data?.httpStatus !== 404; }, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchInterval: false, } ); diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx index 2a0ced8..9c8dcb9 100644 --- a/src/pages/resources/create.tsx +++ b/src/pages/resources/create.tsx @@ -12,7 +12,9 @@ import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; const EditResourcePage = () => { const formMethods = useForm<ResourceUpdateInput>(); - const [serverError, _setServerError] = useState<string | undefined>(undefined); + const [serverError, _setServerError] = useState<string | undefined>( + undefined + ); const onSubmit: SubmitHandler<ResourceUpdateInput> = () => { // TODO: TRPC request to create resource -- 2.47.2 From c243fda8e1b878ae085ead600d4e0741bb4728de Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Wed, 30 Aug 2023 22:22:32 -0500 Subject: [PATCH 04/14] fix link add button alignment --- src/components/admin/resources/form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index ceb2fa5..9e2d574 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -266,15 +266,15 @@ const ResourceLinkSubForm = () => { <h1 className="text-xl">Links</h1> <button type="button" - className="h-6 rounded-full border border-neutral-900 bg-neutral-200 px-2 leading-tight hover:bg-yellow-400" + className="flex h-6 flex-row items-center rounded-full border border-neutral-900 bg-neutral-200 px-2 leading-tight drop-shadow-sm hover:bg-yellow-400" onClick={() => { setLinkModalOpen(!linkModalOpen); }} > - <span className="my-auto inline-block align-middle text-sm font-normal text-neutral-700"> + <span className="text-sm font-normal leading-3 text-neutral-700"> Add </span> - <PlusIcon className="my-auto inline-block w-4 align-middle" /> + <PlusIcon className="w-4 leading-3" /> </button> </div> -- 2.47.2 From cd1dc2a555b42c578ca54930f46319720430d4a9 Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Mon, 4 Sep 2023 00:03:42 -0500 Subject: [PATCH 05/14] add create resource trpc function --- src/components/admin/common.tsx | 5 +- src/components/admin/resources/form.tsx | 5 +- src/components/forms/textInput.tsx | 4 +- src/pages/resources/[id]/edit.tsx | 4 +- src/pages/resources/create.tsx | 41 ++++++++--- src/server/api/routers/auditoryResources.ts | 81 +++++++++++---------- 6 files changed, 82 insertions(+), 58 deletions(-) diff --git a/src/components/admin/common.tsx b/src/components/admin/common.tsx index ce1ae95..0f8abd3 100644 --- a/src/components/admin/common.tsx +++ b/src/components/admin/common.tsx @@ -42,13 +42,16 @@ const AdminActionButton = ({ label, onClick, symbol, + type = "button", }: { label: string; - onClick: () => void; + onClick?: () => void; symbol: JSX.Element | undefined; + type?: HTMLButtonElement["type"]; }) => { return ( <button + type={type} className="py-auto group my-auto h-full space-x-2 rounded-lg border border-neutral-400 bg-neutral-800 px-2 hover:border-neutral-800 hover:bg-white" onClick={onClick} > diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index 9e2d574..9bc14ec 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -47,6 +47,7 @@ import { ResourcePhoto } from "~/components/ResourcePhoto"; Modal.setAppElement("#__next"); export type ResourceUpdateInput = RouterInputs["auditoryResource"]["update"]; +export type ResourceCreateInput = RouterInputs["auditoryResource"]["create"]; /** * Renders the image selector for resource form. @@ -361,14 +362,14 @@ function ResourceSummarySubForm({ <InfoInputLine details={register("manufacturer.name", { required: true })} placeholder="manufacturer" - value={resource?.manufacturer?.name ?? ""} hint="manufacturer" + value={resource?.name} /> </span> <InfoInputLine details={register("name", { required: true })} placeholder="name" - value={resource?.name ?? ""} + value={resource?.name} hint="name" /> <span className="my-1 block w-full text-center text-xs italic text-neutral-400"> diff --git a/src/components/forms/textInput.tsx b/src/components/forms/textInput.tsx index 29e51ac..0bd6860 100644 --- a/src/components/forms/textInput.tsx +++ b/src/components/forms/textInput.tsx @@ -2,7 +2,9 @@ import { type HTMLInputTypeAttribute, useState } from "react"; import { type UseFormRegisterReturn, type InternalFieldName, + useFormContext, } from "react-hook-form"; +import { ResourceCreateInput } from "../admin/resources/form"; /** * Single line input for the fields found to the right of the @@ -14,7 +16,7 @@ function InfoInputLine<TFieldName extends InternalFieldName>({ hint, details, }: { - value: string; + value?: string | undefined; placeholder: string; hint?: string; details: UseFormRegisterReturn<TFieldName>; diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index 471db47..61d9b20 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -40,8 +40,8 @@ const EditResourcePage = () => { }); const { mutate } = api.auditoryResource.update.useMutation({ - onSuccess: async (_resData) => { - if (!data) { + onSuccess: async (resData) => { + if (!resData) { setServerError("An unexpected error has occured"); return; } diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx index 9c8dcb9..684a642 100644 --- a/src/pages/resources/create.tsx +++ b/src/pages/resources/create.tsx @@ -1,23 +1,40 @@ import { XCircleIcon, PlusCircleIcon } from "@heroicons/react/20/solid"; -import { useState } from "react"; -import { type SubmitHandler, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { + type SubmitHandler, + useForm, + type UseFormReturn, +} from "react-hook-form"; import { AdminBarLayout } from "~/components/admin/ControlBar"; import { AdminActionButton, AdminActionLink } from "~/components/admin/common"; import { + type ResourceCreateInput, ResourceForm, type ResourceUpdateInput, } from "~/components/admin/resources/form"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; +import { api } from "~/utils/api"; const EditResourcePage = () => { - const formMethods = useForm<ResourceUpdateInput>(); + const router = useRouter(); + const formMethods = useForm<ResourceCreateInput>(); - const [serverError, _setServerError] = useState<string | undefined>( - undefined - ); + const [serverError, setServerError] = useState<string | undefined>(undefined); - const onSubmit: SubmitHandler<ResourceUpdateInput> = () => { - // TODO: TRPC request to create resource + const { mutate } = api.auditoryResource.create.useMutation({ + onSuccess: async (resData) => { + if (!resData) { + setServerError("An unexpected error has occured"); + } + + setServerError(undefined); + await router.push(`/resources/${resData.id}`); + }, + }); + + const onSubmit: SubmitHandler<ResourceCreateInput> = (data) => { + mutate(data); }; return ( @@ -28,9 +45,6 @@ const EditResourcePage = () => { key="create" symbol={<PlusCircleIcon className="w-4" />} label="Create" - onClick={() => { - onSubmit(formMethods.getValues()); - }} />, <AdminActionLink key="cancel" @@ -41,7 +55,10 @@ const EditResourcePage = () => { ]} > <div className="mb-12"> - <ResourceForm methods={formMethods} error={serverError} /> + <ResourceForm + methods={formMethods as UseFormReturn<ResourceUpdateInput>} + error={serverError} + /> </div> </AdminBarLayout> </HeaderFooterLayout> diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index fde2735..4f6a705 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -16,6 +16,38 @@ const emptyStringToUndefined = (val: string | undefined | null) => { return val; }; +const AuditoryResourceSchema = z.object({ + id: z.string(), + icon: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + manufacturer: z.object({ + name: z.string().min(1), + required: z.boolean(), + notice: z + .string() + + .nullable() + .transform(emptyStringToUndefined), + }), + ages: z.object({ min: z.number().int(), max: z.number().int() }), + skills: z.array(z.nativeEnum(Skill)), + skill_levels: z.array(z.nativeEnum(SkillLevel)), + payment_options: z.array(z.nativeEnum(PaymentType)), + photo: z + .object({ + name: z.string(), + data: z.instanceof(Buffer), + }) + .nullable(), + platform_links: z.array( + z.object({ + platform: z.nativeEnum(Platform), + link: z.string().min(1), + }) + ), +}); + export const auditoryResourceRouter = createTRPCRouter({ byId: publicProcedure .input(z.object({ id: z.string() })) @@ -45,47 +77,16 @@ export const auditoryResourceRouter = createTRPCRouter({ return ctx.prisma.auditoryResource.findMany(); }), + create: protectedProcedure + .input(AuditoryResourceSchema) + .mutation(async ({ input, ctx }) => { + return await ctx.prisma.auditoryResource.create({ + data: input, + }); + }), + update: protectedProcedure - .input( - z.object({ - id: z.string(), - icon: z.string().min(1).optional(), - name: z.string().min(1).optional(), - description: z.string().min(1).optional(), - manufacturer: z - .object({ - name: z.string().min(1), - required: z.boolean(), - notice: z - .string() - .optional() - .nullable() - .transform(emptyStringToUndefined), - }) - .optional(), - ages: z - .object({ min: z.number().int(), max: z.number().int() }) - .optional(), - skills: z.array(z.nativeEnum(Skill)).optional(), - skill_levels: z.array(z.nativeEnum(SkillLevel)).optional(), - payment_options: z.array(z.nativeEnum(PaymentType)).optional(), - photo: z - .object({ - name: z.string(), - data: z.instanceof(Buffer), - }) - .nullable() - .optional(), - platform_links: z - .array( - z.object({ - platform: z.nativeEnum(Platform), - link: z.string().min(1), - }) - ) - .optional(), - }) - ) + .input(AuditoryResourceSchema.partial()) .mutation(async ({ input, ctx }) => { return await ctx.prisma.auditoryResource.update({ where: { -- 2.47.2 From f7144e7cf4016c25901f2b37522a8ec8b536dda0 Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Mon, 4 Sep 2023 00:25:10 -0500 Subject: [PATCH 06/14] switch to handle submit --- src/components/admin/resources/form.tsx | 19 ++++++++++++------- src/components/forms/textInput.tsx | 2 -- src/pages/resources/create.tsx | 7 ++++++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index 9bc14ec..fa689f8 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -346,7 +346,10 @@ function ResourceSummarySubForm({ }: { resource?: ResourceUpdateInput; }) { - const { register } = useFormContext<ResourceUpdateInput>(); + const { + register, + formState: { errors }, + } = useFormContext<ResourceUpdateInput>(); return ( <div className="space-y-4 px-4"> @@ -360,14 +363,16 @@ function ResourceSummarySubForm({ </h2> <span className="text-md"> <InfoInputLine - details={register("manufacturer.name", { required: true })} + details={register("manufacturer.name", { + required: "Field required", + })} placeholder="manufacturer" hint="manufacturer" value={resource?.name} /> </span> <InfoInputLine - details={register("name", { required: true })} + details={register("name", { required: "Field required" })} placeholder="name" value={resource?.name} hint="name" @@ -378,7 +383,7 @@ function ResourceSummarySubForm({ </div> </div> <MultiSelectorMany - details={register("payment_options", { required: true })} + details={register("payment_options", { required: "Field required" })} label="Price Category" defaultValues={resource?.payment_options ?? []} > @@ -394,7 +399,7 @@ function ResourceSummarySubForm({ </MultiSelectorMany> <MultiSelectorMany - details={register("skill_levels", { required: true })} + details={register("skill_levels", { required: "Field required" })} label="Skill Level" defaultValues={resource?.skill_levels ?? []} > @@ -410,7 +415,7 @@ function ResourceSummarySubForm({ </MultiSelectorMany> <MultiSelectorMany - details={register("skills", { required: true })} + details={register("skills", { required: "Field required" })} label="Skills Covered" defaultValues={resource?.skills ?? []} > @@ -452,7 +457,7 @@ const ResourceDescriptionSubForm = () => { <ChevronDownIcon className="mx-2 my-auto w-4 text-white group-hover:animate-bounce" /> </button> <textarea - {...register("description", { required: true })} + {...register("description", { required: "Field required" })} className={ "h-48 w-full rounded-b-xl p-2" + (dropdownOpen ? " hidden" : "") } diff --git a/src/components/forms/textInput.tsx b/src/components/forms/textInput.tsx index 0bd6860..4c3384e 100644 --- a/src/components/forms/textInput.tsx +++ b/src/components/forms/textInput.tsx @@ -2,9 +2,7 @@ import { type HTMLInputTypeAttribute, useState } from "react"; import { type UseFormRegisterReturn, type InternalFieldName, - useFormContext, } from "react-hook-form"; -import { ResourceCreateInput } from "../admin/resources/form"; /** * Single line input for the fields found to the right of the diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx index 684a642..7b40181 100644 --- a/src/pages/resources/create.tsx +++ b/src/pages/resources/create.tsx @@ -1,6 +1,6 @@ import { XCircleIcon, PlusCircleIcon } from "@heroicons/react/20/solid"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { type SubmitHandler, useForm, @@ -45,6 +45,11 @@ const EditResourcePage = () => { key="create" symbol={<PlusCircleIcon className="w-4" />} label="Create" + onClick={() => { + formMethods + .handleSubmit(onSubmit)() + .catch((error) => console.error(error)); + }} />, <AdminActionLink key="cancel" -- 2.47.2 From 2edc5d57b6abf56e7d99ac546fdb2c65955a5350 Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Mon, 4 Sep 2023 00:33:54 -0500 Subject: [PATCH 07/14] improve resource schema to reflect new icon structure --- prisma/schema.prisma | 2 +- src/components/ResourcePhoto.tsx | 2 +- src/components/admin/resources/form.tsx | 5 +--- src/server/api/routers/auditoryResources.ts | 27 +++++++++------------ 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ca430b..0a9465a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,7 +70,7 @@ type Photo { model AuditoryResource { id String @id @default(auto()) @map("_id") @db.ObjectId - icon String + icon String? name String description String photo Photo? diff --git a/src/components/ResourcePhoto.tsx b/src/components/ResourcePhoto.tsx index df56ba2..443b26d 100644 --- a/src/components/ResourcePhoto.tsx +++ b/src/components/ResourcePhoto.tsx @@ -8,7 +8,7 @@ type ResourcePhotoProps = ( src: string | undefined; } | { - src: string; + src: string | undefined; photo: null; } ) & { name: string }; diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index fa689f8..cc24ff9 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -346,10 +346,7 @@ function ResourceSummarySubForm({ }: { resource?: ResourceUpdateInput; }) { - const { - register, - formState: { errors }, - } = useFormContext<ResourceUpdateInput>(); + const { register } = useFormContext<ResourceUpdateInput>(); return ( <div className="space-y-4 px-4"> diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index 4f6a705..e631fa6 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -17,18 +17,13 @@ const emptyStringToUndefined = (val: string | undefined | null) => { }; const AuditoryResourceSchema = z.object({ - id: z.string(), - icon: z.string().min(1), + icon: z.string().min(1).optional(), name: z.string().min(1), description: z.string().min(1), manufacturer: z.object({ name: z.string().min(1), - required: z.boolean(), - notice: z - .string() - - .nullable() - .transform(emptyStringToUndefined), + required: z.boolean().default(false), + notice: z.string().nullable().transform(emptyStringToUndefined), }), ages: z.object({ min: z.number().int(), max: z.number().int() }), skills: z.array(z.nativeEnum(Skill)), @@ -40,12 +35,14 @@ const AuditoryResourceSchema = z.object({ data: z.instanceof(Buffer), }) .nullable(), - platform_links: z.array( - z.object({ - platform: z.nativeEnum(Platform), - link: z.string().min(1), - }) - ), + platform_links: z + .array( + z.object({ + platform: z.nativeEnum(Platform), + link: z.string().min(1), + }) + ) + .default([]), }); export const auditoryResourceRouter = createTRPCRouter({ @@ -86,7 +83,7 @@ export const auditoryResourceRouter = createTRPCRouter({ }), update: protectedProcedure - .input(AuditoryResourceSchema.partial()) + .input(AuditoryResourceSchema.partial().extend({ id: z.string() })) .mutation(async ({ input, ctx }) => { return await ctx.prisma.auditoryResource.update({ where: { -- 2.47.2 From 634f35657e9771126cfbe815e22cd088bb97db6e Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 19:45:05 -0500 Subject: [PATCH 08/14] improve readability of server error for user --- src/pages/resources/[id]/edit.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index 61d9b20..e228ce7 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -49,7 +49,20 @@ const EditResourcePage = () => { setServerError(undefined); await router.push(`/resources/${data.id}`); }, - onError: (error) => setServerError(error.message), + onError: (error) => { + try { + const zodErrors = JSON.parse(error.message) as unknown as { message: string }[]; + setServerError( + zodErrors + .map((error) => { + return error.message; + }) + .join(", ") + ); + } catch { + setServerError(error.message); + } + }, }); const onSubmit: SubmitHandler<ResourceUpdateInput> = (data) => { -- 2.47.2 From cad4b78f4767b8ad358fa08fb71dca1190116dcf Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 19:45:32 -0500 Subject: [PATCH 09/14] add input fields for age range --- src/components/admin/resources/form.tsx | 28 +++++++++++++++++++++ src/components/forms/inputLabel.tsx | 16 ++++++++++++ src/components/forms/selectors.tsx | 11 +++----- src/components/forms/textInput.tsx | 10 +++++--- src/server/api/routers/auditoryResources.ts | 9 ++++++- 5 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 src/components/forms/inputLabel.tsx diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index cc24ff9..9043c17 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -41,6 +41,7 @@ import Modal from "react-modal"; import { type RouterInputs } from "~/utils/api"; import { PlatformLinkButton } from "~/pages/resources/[id]"; import { ResourcePhoto } from "~/components/ResourcePhoto"; +import { FieldLabel } from "~/components/forms/inputLabel"; // Required for accessibility // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access @@ -379,6 +380,33 @@ function ResourceSummarySubForm({ </span> </div> </div> + + <div> + <FieldLabel + heading="Age Range" + subheading="Specify the minimum and maximum age range supported by the resource" + /> + <div className="mt-2 flex flex-row space-x-4"> + <GenericInput + type="number" + placeholder="minimum age" + details={register("ages.min", { + required: "Field required", + valueAsNumber: true, + })} + /> + <span className="text-xl">-</span> + <GenericInput + type="number" + placeholder="maximum age" + details={register("ages.max", { + required: "Field required", + valueAsNumber: true, + })} + /> + </div> + </div> + <MultiSelectorMany details={register("payment_options", { required: "Field required" })} label="Price Category" diff --git a/src/components/forms/inputLabel.tsx b/src/components/forms/inputLabel.tsx new file mode 100644 index 0000000..17443c5 --- /dev/null +++ b/src/components/forms/inputLabel.tsx @@ -0,0 +1,16 @@ +export const FieldLabel = ({ + heading, + subheading, +}: { + heading: string; + subheading: string; +}) => { + return ( + <div> + <label className="text-md block font-semibold">{heading}</label> + <span className="block text-sm italic text-neutral-400"> + {subheading} + </span> + </div> + ); +}; diff --git a/src/components/forms/selectors.tsx b/src/components/forms/selectors.tsx index 59a3082..ff8a34d 100644 --- a/src/components/forms/selectors.tsx +++ b/src/components/forms/selectors.tsx @@ -4,6 +4,7 @@ import { useFormContext, type UseFormRegisterReturn, } from "react-hook-form"; +import { FieldLabel } from "./inputLabel"; // generics interface ToStringable { @@ -60,10 +61,7 @@ function MultiSelectorMany<T extends ToStringable>({ <SelectorContext.Provider value={{ type: "many", updateCallback }}> <SelectedManyContext.Provider value={selected}> <div className="flex flex-col"> - <label className="text-md block font-semibold">{label}</label> - <span className="block text-sm italic text-neutral-400"> - Select all that apply - </span> + <FieldLabel heading={label} subheading="Select all that apply" /> <input {...details} readOnly type="text" className="hidden" /> <div className="mt-2 space-x-2 space-y-2 overflow-x-auto"> {children} @@ -100,10 +98,7 @@ function MultiSelector<T extends ToStringable>({ > <SelectedUniqueContext.Provider value={selected}> <div className="flex flex-col"> - <label className="text-md block font-semibold">{label}</label> - <span className="block text-sm italic text-neutral-400"> - Select one from below - </span> + <FieldLabel heading={label} subheading="Select one from below" /> <input {...details} readOnly type="text" className="hidden" /> <div className="space-x-2 space-y-2 overflow-x-auto">{children}</div> </div> diff --git a/src/components/forms/textInput.tsx b/src/components/forms/textInput.tsx index 4c3384e..f479d36 100644 --- a/src/components/forms/textInput.tsx +++ b/src/components/forms/textInput.tsx @@ -50,16 +50,18 @@ function GenericInput<TFieldName extends InternalFieldName>({ type = "text", details, }: { - label: string; + label?: string; placeholder?: string; type: HTMLInputTypeAttribute; details: UseFormRegisterReturn<TFieldName>; }) { return ( <section className="w-full space-y-1"> - <label className="text-md block px-1 font-semibold text-neutral-600"> - {label} - </label> + {label ? ( + <label className="text-md block px-1 font-semibold text-neutral-600"> + {label} + </label> + ) : undefined} <input className="block h-8 w-full rounded-lg border border-neutral-600 px-2 py-1" {...details} diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index e631fa6..cfeb071 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -25,7 +25,14 @@ const AuditoryResourceSchema = z.object({ required: z.boolean().default(false), notice: z.string().nullable().transform(emptyStringToUndefined), }), - ages: z.object({ min: z.number().int(), max: z.number().int() }), + ages: z.object({ min: z.number().int(), max: z.number().int() }).refine( + (ages) => { + return ages.min < ages.max; + }, + { + message: "Minimum supported age must be less than maximum supported age.", + } + ), skills: z.array(z.nativeEnum(Skill)), skill_levels: z.array(z.nativeEnum(SkillLevel)), payment_options: z.array(z.nativeEnum(PaymentType)), -- 2.47.2 From ee7268e724fd778031b56b5aead26f43107d5443 Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 19:48:02 -0500 Subject: [PATCH 10/14] make input strict --- src/server/api/routers/auditoryResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index cfeb071..4da6867 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -82,7 +82,7 @@ export const auditoryResourceRouter = createTRPCRouter({ }), create: protectedProcedure - .input(AuditoryResourceSchema) + .input(AuditoryResourceSchema.strict()) .mutation(async ({ input, ctx }) => { return await ctx.prisma.auditoryResource.create({ data: input, -- 2.47.2 From 8b423774535b939fa2135b51af78dcf59c484a60 Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 20:03:38 -0500 Subject: [PATCH 11/14] reduce redundant trpc error handling code to function --- src/pages/resources/[id]/edit.tsx | 14 ++------------ src/pages/resources/create.tsx | 8 +++++++- src/server/api/routers/auditoryResources.ts | 2 +- src/utils/parseTRPCError.ts | 12 ++++++++++++ 4 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 src/utils/parseTRPCError.ts diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index e228ce7..7641bc5 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -13,6 +13,7 @@ import { useRouter } from "next/router"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { QueryWaitWrapper } from "~/components/LoadingWrapper"; import { type AuditoryResource } from "@prisma/client"; +import { parseTRPCErrorMessage } from "~/utils/parseTRPCError"; const EditResourcePage = () => { const router = useRouter(); @@ -50,18 +51,7 @@ const EditResourcePage = () => { await router.push(`/resources/${data.id}`); }, onError: (error) => { - try { - const zodErrors = JSON.parse(error.message) as unknown as { message: string }[]; - setServerError( - zodErrors - .map((error) => { - return error.message; - }) - .join(", ") - ); - } catch { - setServerError(error.message); - } + setServerError(parseTRPCErrorMessage(error.message)); }, }); diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx index 7b40181..454d26a 100644 --- a/src/pages/resources/create.tsx +++ b/src/pages/resources/create.tsx @@ -15,6 +15,7 @@ import { } from "~/components/admin/resources/form"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { api } from "~/utils/api"; +import { parseTRPCErrorMessage } from "~/utils/parseTRPCError"; const EditResourcePage = () => { const router = useRouter(); @@ -31,6 +32,9 @@ const EditResourcePage = () => { setServerError(undefined); await router.push(`/resources/${resData.id}`); }, + onError: (error) => { + setServerError(parseTRPCErrorMessage(error.message)); + }, }); const onSubmit: SubmitHandler<ResourceCreateInput> = (data) => { @@ -61,7 +65,9 @@ const EditResourcePage = () => { > <div className="mb-12"> <ResourceForm - methods={formMethods as UseFormReturn<ResourceUpdateInput>} + methods={ + formMethods as unknown as UseFormReturn<ResourceUpdateInput> + } error={serverError} /> </div> diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index 4da6867..a88bf5d 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -17,7 +17,7 @@ const emptyStringToUndefined = (val: string | undefined | null) => { }; const AuditoryResourceSchema = z.object({ - icon: z.string().min(1).optional(), + icon: z.string().min(1).optional().nullable(), name: z.string().min(1), description: z.string().min(1), manufacturer: z.object({ diff --git a/src/utils/parseTRPCError.ts b/src/utils/parseTRPCError.ts new file mode 100644 index 0000000..5d29b30 --- /dev/null +++ b/src/utils/parseTRPCError.ts @@ -0,0 +1,12 @@ +export const parseTRPCErrorMessage = (message: string) => { + try { + const zodErrors = JSON.parse(message) as unknown as { message: string }[]; + return zodErrors + .map((error) => { + return error.message; + }) + .join(", "); + } catch { + return message; + } +}; -- 2.47.2 From f66c35a225de33711c1887d883bcab96ff88669e Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 20:27:33 -0500 Subject: [PATCH 12/14] add confirm button for dangerous admin actions --- src/components/admin/common.tsx | 51 +++++++++++++++++++++++++++++- src/pages/resources/[id]/edit.tsx | 13 ++++++-- src/pages/resources/[id]/index.tsx | 22 ++++++++++--- src/pages/resources/create.tsx | 13 ++++++-- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/components/admin/common.tsx b/src/components/admin/common.tsx index 0f8abd3..71beaa8 100644 --- a/src/components/admin/common.tsx +++ b/src/components/admin/common.tsx @@ -1,4 +1,6 @@ +import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; +import { useEffect, useState } from "react"; const AdminActionBody = ({ label, @@ -38,6 +40,53 @@ const AdminActionLink = ({ ); }; +const AdminActionConfirmButton = ({ + label, + onConfirm, + symbol, + type = "button", +}: { + label: string; + onConfirm?: () => void; + symbol: JSX.Element | undefined; + type?: HTMLButtonElement["type"]; +}) => { + const [isConfirmView, setConfirmView] = useState(false); + + useEffect(() => { + if (!isConfirmView) { + return; + } + + setTimeout(() => { + if (isConfirmView) { + setConfirmView(false); + } + }, 5000); + }, [isConfirmView]); + + if (isConfirmView) { + return ( + <AdminActionButton + symbol={<ExclamationCircleIcon className="w-4 animate-ping" />} + label={`Confirm ${label}`} + onClick={onConfirm} + type={type} + /> + ); + } + + return ( + <AdminActionButton + symbol={symbol} + label={label} + onClick={() => { + setConfirmView(true); + }} + /> + ); +}; + const AdminActionButton = ({ label, onClick, @@ -65,4 +114,4 @@ const AdminActionButton = ({ ); }; -export { AdminActionLink, AdminActionButton }; +export { AdminActionLink, AdminActionButton, AdminActionConfirmButton }; diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index 7641bc5..817d3b0 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -1,6 +1,9 @@ import { XCircleIcon } from "@heroicons/react/20/solid"; import { AdminBarLayout } from "~/components/admin/ControlBar"; -import { AdminActionButton, AdminActionLink } from "~/components/admin/common"; +import { + AdminActionButton, + AdminActionConfirmButton, +} from "~/components/admin/common"; import Image from "next/image"; import { ResourceForm, @@ -87,11 +90,15 @@ const EditResourcePage = () => { onSubmit(formMethods.getValues()); }} />, - <AdminActionLink + <AdminActionConfirmButton key="cancel" symbol={<XCircleIcon className="w-4" />} label="Cancel" - href={`/resources/${data.id}`} + onConfirm={() => { + router.push(`/resources/${data.id}`).catch((error) => { + console.error(error); + }); + }} />, ]} > diff --git a/src/pages/resources/[id]/index.tsx b/src/pages/resources/[id]/index.tsx index b4471ba..9770b1c 100644 --- a/src/pages/resources/[id]/index.tsx +++ b/src/pages/resources/[id]/index.tsx @@ -6,10 +6,14 @@ import { type AuditoryResource, type PlatformLink } from "@prisma/client"; import Image from "next/image"; import Link from "next/link"; import { AdminBarLayout } from "~/components/admin/ControlBar"; -import { AdminActionLink } from "~/components/admin/common"; +import { + AdminActionConfirmButton, + AdminActionLink, +} from "~/components/admin/common"; import { useRouter } from "next/router"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { QueryWaitWrapper } from "~/components/LoadingWrapper"; +import { TrashIcon } from "@heroicons/react/24/outline"; export const PlatformLinkButton = ({ platformLink, @@ -122,13 +126,23 @@ const ResourceViewPage = () => { return ( <HeaderFooterLayout> <AdminBarLayout - actions={ + actions={[ <AdminActionLink + key="edit" symbol={<PencilSquareIcon className="w-4" />} label="Edit Page" href={`${router.asPath}/edit`} - /> - } + />, + <AdminActionConfirmButton + key="delete" + label="Delete" + symbol={<TrashIcon className="w-4" />} + onConfirm={() => { + // todo + console.log("deleting"); + }} + />, + ]} > <div className="mb-12"> <QueryWaitWrapper query={resourceQuery} Render={ConditionalView} /> diff --git a/src/pages/resources/create.tsx b/src/pages/resources/create.tsx index 454d26a..0920379 100644 --- a/src/pages/resources/create.tsx +++ b/src/pages/resources/create.tsx @@ -7,7 +7,10 @@ import { type UseFormReturn, } from "react-hook-form"; import { AdminBarLayout } from "~/components/admin/ControlBar"; -import { AdminActionButton, AdminActionLink } from "~/components/admin/common"; +import { + AdminActionButton, + AdminActionConfirmButton, +} from "~/components/admin/common"; import { type ResourceCreateInput, ResourceForm, @@ -55,11 +58,15 @@ const EditResourcePage = () => { .catch((error) => console.error(error)); }} />, - <AdminActionLink + <AdminActionConfirmButton key="cancel" symbol={<XCircleIcon className="w-4" />} label="Cancel" - href={`/resources`} + onConfirm={() => { + router.push("/resources").catch((error) => { + console.error(error); + }); + }} />, ]} > -- 2.47.2 From 2ef07fd37a3daa2bb2910f1cf6762b97e1f53b5b Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 20:38:05 -0500 Subject: [PATCH 13/14] add delete function --- src/pages/resources/[id]/index.tsx | 14 ++++++++++++-- src/server/api/routers/auditoryResources.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/pages/resources/[id]/index.tsx b/src/pages/resources/[id]/index.tsx index 9770b1c..d40e43c 100644 --- a/src/pages/resources/[id]/index.tsx +++ b/src/pages/resources/[id]/index.tsx @@ -95,6 +95,15 @@ const ResourceViewPage = () => { } ); + const { mutate: mutateDelete } = api.auditoryResource.delete.useMutation({ + onSuccess: async () => { + await router.push(`/resources`); + }, + onError: (error) => { + console.error(error); + }, + }); + const ConditionalView = (data: AuditoryResource) => { return ( <div className="mx-auto flex max-w-2xl flex-col flex-col-reverse divide-x py-4 sm:flex-row"> @@ -138,8 +147,9 @@ const ResourceViewPage = () => { label="Delete" symbol={<TrashIcon className="w-4" />} onConfirm={() => { - // todo - console.log("deleting"); + mutateDelete({ + id, + }); }} />, ]} diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index a88bf5d..9fb4728 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -89,6 +89,20 @@ export const auditoryResourceRouter = createTRPCRouter({ }); }), + delete: protectedProcedure + .input( + z.object({ + id: z.string(), + }) + ) + .mutation(async ({ input, ctx }) => { + return await ctx.prisma.auditoryResource.delete({ + where: { + id: input.id, + }, + }); + }), + update: protectedProcedure .input(AuditoryResourceSchema.partial().extend({ id: z.string() })) .mutation(async ({ input, ctx }) => { -- 2.47.2 From 23e9f913235c6e991539c4b73ca1bf146ce5470f Mon Sep 17 00:00:00 2001 From: Brandon Egger <brandonegger64@gmail.com> Date: Tue, 5 Sep 2023 21:47:50 -0500 Subject: [PATCH 14/14] minor styling changes --- src/components/admin/resources/form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/admin/resources/form.tsx b/src/components/admin/resources/form.tsx index 9043c17..13c124e 100644 --- a/src/components/admin/resources/form.tsx +++ b/src/components/admin/resources/form.tsx @@ -386,7 +386,7 @@ function ResourceSummarySubForm({ heading="Age Range" subheading="Specify the minimum and maximum age range supported by the resource" /> - <div className="mt-2 flex flex-row space-x-4"> + <div className="mt-4 flex flex-row space-x-4"> <GenericInput type="number" placeholder="minimum age" -- 2.47.2