diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx index bdb0a94d..611f0204 100644 --- a/apps/portal/src/pages/resumes/[resumeId].tsx +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -4,12 +4,14 @@ import Error from 'next/error'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; +import { useState } from 'react'; import { AcademicCapIcon, BriefcaseIcon, CalendarIcon, InformationCircleIcon, MapPinIcon, + PencilSquareIcon, StarIcon, } from '@heroicons/react/20/solid'; import { Spinner } from '@tih/ui'; @@ -20,6 +22,8 @@ import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableTe import { trpc } from '~/utils/trpc'; +import SubmitResumeForm from './submit'; + export default function ResumeReviewPage() { const ErrorPage = ( <Error statusCode={404} title="Requested resume does not exist" /> @@ -46,6 +50,11 @@ export default function ResumeReviewPage() { }, }); + const userIsOwner = + session?.user?.id != null && session.user.id === detailsQuery.data?.userId; + + const [isEditMode, setIsEditMode] = useState(false); + const onStarButtonClick = () => { if (session?.user?.id == null) { router.push('/api/auth/signin'); @@ -65,6 +74,30 @@ export default function ResumeReviewPage() { } }; + const onEditButtonClick = () => { + setIsEditMode(true); + }; + + if (isEditMode && detailsQuery.data != null) { + return ( + <SubmitResumeForm + initFormDetails={{ + additionalInfo: detailsQuery.data.additionalInfo ?? '', + experience: detailsQuery.data.experience, + location: detailsQuery.data.location, + resumeId: resumeId as string, + role: detailsQuery.data.role, + title: detailsQuery.data.title, + url: detailsQuery.data.url, + }} + onClose={() => { + utils.invalidateQueries(['resumes.resume.findOne']); + setIsEditMode(false); + }} + /> + ); + } + return ( <> {(detailsQuery.isError || detailsQuery.data === null) && ErrorPage} @@ -80,45 +113,46 @@ export default function ResumeReviewPage() { <title>{detailsQuery.data.title}</title> </Head> <main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4"> - <div className="flex flex-row space-x-8"> + <div className="flex space-x-8"> <h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> {detailsQuery.data.title} </h1> - <button - className={clsx( - detailsQuery.data?.stars.length - ? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500' - : '', - 'isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white', + <div className="flex gap-4"> + <button + className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white" + disabled={starMutation.isLoading || unstarMutation.isLoading} + type="button" + onClick={onStarButtonClick}> + <span className="relative inline-flex"> + <div className="-ml-1 mr-2 h-5 w-5"> + {starMutation.isLoading || unstarMutation.isLoading ? ( + <Spinner className="mt-0.5" size="xs" /> + ) : ( + <StarIcon + aria-hidden="true" + className={clsx( + detailsQuery.data?.stars.length + ? 'text-orange-400' + : 'text-gray-400', + )} + /> + )} + </div> + Star + </span> + <span className="relative -ml-px inline-flex"> + {detailsQuery.data?._count.stars} + </span> + </button> + {userIsOwner && ( + <button + className="p h-10 rounded-md border border-gray-300 bg-white py-1 px-2 text-center" + type="button" + onClick={onEditButtonClick}> + <PencilSquareIcon className="h-6 w-6 text-indigo-500 hover:text-indigo-300" /> + </button> )} - disabled={ - session?.user === undefined || - starMutation.isLoading || - unstarMutation.isLoading - } - type="button" - onClick={onStarButtonClick}> - <span className="relative inline-flex"> - <div className="-ml-1 mr-2 h-5 w-5"> - {starMutation.isLoading || unstarMutation.isLoading ? ( - <Spinner className="mt-0.5" size="xs" /> - ) : ( - <StarIcon - aria-hidden="true" - className={clsx( - detailsQuery.data?.stars.length - ? 'text-orange-400' - : 'text-gray-400', - )} - /> - )} - </div> - Star - </span> - <span className="relative -ml-px inline-flex"> - {detailsQuery.data?._count.stars} - </span> - </button> + </div> </div> <div className="flex flex-col pt-1 lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8"> <div className="mt-2 flex items-center text-sm text-gray-500"> diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index 49763d8d..12568b25 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -60,8 +60,26 @@ const selectors: Array<SelectorOptions> = [ { key: 'location', label: 'Location', options: LOCATION }, ]; -export default function SubmitResumeForm() { - const [resumeFile, setResumeFile] = useState<File | null>(); +type InitFormDetails = { + additionalInfo?: string; + experience: string; + location: string; + resumeId: string; + role: string; + title: string; + url: string; +}; + +type Props = Readonly<{ + initFormDetails?: InitFormDetails | null; + onClose: () => void; +}>; + +export default function SubmitResumeForm({ + initFormDetails, + onClose = () => undefined, +}: Props) { + const [resumeFile, setResumeFile] = useState<File | null>(null); const [isLoading, setIsLoading] = useState(false); const [invalidFileUploadError, setInvalidFileUploadError] = useState< string | null @@ -70,7 +88,8 @@ export default function SubmitResumeForm() { const { data: session, status } = useSession(); const router = useRouter(); - const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); + const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert'); + const isNewForm = initFormDetails == null; const { register, @@ -81,6 +100,7 @@ export default function SubmitResumeForm() { } = useForm<IFormInput>({ defaultValues: { isChecked: false, + ...initFormDetails, }, }); @@ -89,7 +109,9 @@ export default function SubmitResumeForm() { if (fileRejections.length === 0) { setInvalidFileUploadError(''); setResumeFile(acceptedFiles[0]); - setValue('file', acceptedFiles[0]); + setValue('file', acceptedFiles[0], { + shouldDirty: true, + }); } else { setInvalidFileUploadError(FILE_UPLOAD_ERROR); } @@ -106,6 +128,30 @@ export default function SubmitResumeForm() { onDrop: onFileDrop, }); + const fetchFilePdf = useCallback(async () => { + const fileUrl = initFormDetails?.url; + + if (fileUrl == null) { + return; + } + + const data = await axios + .get(fileUrl, { + responseType: 'blob', + }) + .then((res) => res.data); + + const keyAndFileName = fileUrl.substring(fileUrl.indexOf('resumes')); + const fileName = keyAndFileName.substring(keyAndFileName.indexOf('-') + 1); + + const file = new File([data], fileName); + setResumeFile(file); + setValue('file', file, { + shouldDirty: false, + }); + }, [initFormDetails?.url, setValue]); + + // Route user to sign in if not logged in useEffect(() => { if (status !== 'loading') { if (session?.user?.id == null) { @@ -114,6 +160,11 @@ export default function SubmitResumeForm() { } }, [router, session, status]); + // Fetch initial file PDF for edit form + useEffect(() => { + fetchFilePdf(); + }, [fetchFilePdf]); + const onSubmit: SubmitHandler<IFormInput> = async (data) => { if (resumeFile == null) { return; @@ -131,10 +182,11 @@ export default function SubmitResumeForm() { }); const { url } = res.data; - resumeCreateMutation.mutate( + resumeUpsertMutation.mutate( { additionalInfo: data.additionalInfo, experience: data.experience, + id: initFormDetails?.resumeId, location: data.location, role: data.role, title: data.title, @@ -148,19 +200,26 @@ export default function SubmitResumeForm() { setIsLoading(false); }, onSuccess() { - router.push('/resumes/browse'); + if (isNewForm) { + router.push('/resumes/browse'); + } else { + onClose(); + } }, }, ); }; const onClickClear = () => { - if (isDirty || resumeFile != null) { + if (isDirty) { setIsDialogShown(true); + } else { + onClose(); } }; const onClickResetDialog = () => { + onClose(); setIsDialogShown(false); reset(); setResumeFile(null); @@ -227,7 +286,11 @@ export default function SubmitResumeForm() { onClick={() => setIsDialogShown(false)} /> } - title="Are you sure you want to clear?" + title={ + isNewForm + ? 'Are you sure you want to clear?' + : 'Are you sure you want to leave?' + } onClose={() => setIsDialogShown(false)}> Note that your current input will not be saved! </Dialog> @@ -346,7 +409,7 @@ export default function SubmitResumeForm() { <Button addonPosition="start" disabled={isLoading} - label="Clear" + label={isNewForm ? 'Clear' : 'Cancel'} variant="tertiary" onClick={onClickClear} /> diff --git a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts index b443c3e2..366385ad 100644 --- a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts +++ b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts @@ -5,11 +5,12 @@ import { createProtectedRouter } from '../context'; import type { Resume } from '~/types/resume'; export const resumesResumeUserRouter = createProtectedRouter() - .mutation('create', { + .mutation('upsert', { // TODO: Use enums for experience, location, role input: z.object({ additionalInfo: z.string().optional(), experience: z.string(), + id: z.string().optional(), location: z.string(), role: z.string(), title: z.string(), @@ -17,11 +18,29 @@ export const resumesResumeUserRouter = createProtectedRouter() }), async resolve({ ctx, input }) { const userId = ctx.session?.user.id; - return await ctx.prisma.resumesResume.create({ - data: { - ...input, + + return await ctx.prisma.resumesResume.upsert({ + create: { + additionalInfo: input.additionalInfo, + experience: input.experience, + location: input.location, + role: input.role, + title: input.title, + url: input.url, + userId, + }, + update: { + additionalInfo: input.additionalInfo, + experience: input.experience, + location: input.location, + role: input.role, + title: input.title, + url: input.url, userId, }, + where: { + id: input.id ?? '', + }, }); }, })