mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-29 21:23:14 +08:00
[resumes][feat] add edit form functionality (#379)
* [resumes][feat] add edit form functionality * [resumes][chore] remove comment
This commit is contained in:
@ -4,12 +4,14 @@ import Error from 'next/error';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AcademicCapIcon,
|
AcademicCapIcon,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
} from '@heroicons/react/20/solid';
|
} from '@heroicons/react/20/solid';
|
||||||
import { Spinner } from '@tih/ui';
|
import { Spinner } from '@tih/ui';
|
||||||
@ -20,6 +22,8 @@ import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableTe
|
|||||||
|
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import SubmitResumeForm from './submit';
|
||||||
|
|
||||||
export default function ResumeReviewPage() {
|
export default function ResumeReviewPage() {
|
||||||
const ErrorPage = (
|
const ErrorPage = (
|
||||||
<Error statusCode={404} title="Requested resume does not exist" />
|
<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 = () => {
|
const onStarButtonClick = () => {
|
||||||
if (session?.user?.id == null) {
|
if (session?.user?.id == null) {
|
||||||
router.push('/api/auth/signin');
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
|
{(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
|
||||||
@ -80,45 +113,46 @@ export default function ResumeReviewPage() {
|
|||||||
<title>{detailsQuery.data.title}</title>
|
<title>{detailsQuery.data.title}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4">
|
<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">
|
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||||
{detailsQuery.data.title}
|
{detailsQuery.data.title}
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<div className="flex gap-4">
|
||||||
className={clsx(
|
<button
|
||||||
detailsQuery.data?.stars.length
|
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"
|
||||||
? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500'
|
disabled={starMutation.isLoading || unstarMutation.isLoading}
|
||||||
: '',
|
type="button"
|
||||||
'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',
|
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={
|
</div>
|
||||||
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="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">
|
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||||
|
@ -60,8 +60,26 @@ const selectors: Array<SelectorOptions> = [
|
|||||||
{ key: 'location', label: 'Location', options: LOCATION },
|
{ key: 'location', label: 'Location', options: LOCATION },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SubmitResumeForm() {
|
type InitFormDetails = {
|
||||||
const [resumeFile, setResumeFile] = useState<File | null>();
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
|
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
|
||||||
string | null
|
string | null
|
||||||
@ -70,7 +88,8 @@ export default function SubmitResumeForm() {
|
|||||||
|
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create');
|
const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
|
||||||
|
const isNewForm = initFormDetails == null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -81,6 +100,7 @@ export default function SubmitResumeForm() {
|
|||||||
} = useForm<IFormInput>({
|
} = useForm<IFormInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
isChecked: false,
|
isChecked: false,
|
||||||
|
...initFormDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -89,7 +109,9 @@ export default function SubmitResumeForm() {
|
|||||||
if (fileRejections.length === 0) {
|
if (fileRejections.length === 0) {
|
||||||
setInvalidFileUploadError('');
|
setInvalidFileUploadError('');
|
||||||
setResumeFile(acceptedFiles[0]);
|
setResumeFile(acceptedFiles[0]);
|
||||||
setValue('file', acceptedFiles[0]);
|
setValue('file', acceptedFiles[0], {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
|
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
|
||||||
}
|
}
|
||||||
@ -106,6 +128,30 @@ export default function SubmitResumeForm() {
|
|||||||
onDrop: onFileDrop,
|
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(() => {
|
useEffect(() => {
|
||||||
if (status !== 'loading') {
|
if (status !== 'loading') {
|
||||||
if (session?.user?.id == null) {
|
if (session?.user?.id == null) {
|
||||||
@ -114,6 +160,11 @@ export default function SubmitResumeForm() {
|
|||||||
}
|
}
|
||||||
}, [router, session, status]);
|
}, [router, session, status]);
|
||||||
|
|
||||||
|
// Fetch initial file PDF for edit form
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFilePdf();
|
||||||
|
}, [fetchFilePdf]);
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||||
if (resumeFile == null) {
|
if (resumeFile == null) {
|
||||||
return;
|
return;
|
||||||
@ -131,10 +182,11 @@ export default function SubmitResumeForm() {
|
|||||||
});
|
});
|
||||||
const { url } = res.data;
|
const { url } = res.data;
|
||||||
|
|
||||||
resumeCreateMutation.mutate(
|
resumeUpsertMutation.mutate(
|
||||||
{
|
{
|
||||||
additionalInfo: data.additionalInfo,
|
additionalInfo: data.additionalInfo,
|
||||||
experience: data.experience,
|
experience: data.experience,
|
||||||
|
id: initFormDetails?.resumeId,
|
||||||
location: data.location,
|
location: data.location,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@ -148,19 +200,26 @@ export default function SubmitResumeForm() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
router.push('/resumes/browse');
|
if (isNewForm) {
|
||||||
|
router.push('/resumes/browse');
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickClear = () => {
|
const onClickClear = () => {
|
||||||
if (isDirty || resumeFile != null) {
|
if (isDirty) {
|
||||||
setIsDialogShown(true);
|
setIsDialogShown(true);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickResetDialog = () => {
|
const onClickResetDialog = () => {
|
||||||
|
onClose();
|
||||||
setIsDialogShown(false);
|
setIsDialogShown(false);
|
||||||
reset();
|
reset();
|
||||||
setResumeFile(null);
|
setResumeFile(null);
|
||||||
@ -227,7 +286,11 @@ export default function SubmitResumeForm() {
|
|||||||
onClick={() => setIsDialogShown(false)}
|
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)}>
|
onClose={() => setIsDialogShown(false)}>
|
||||||
Note that your current input will not be saved!
|
Note that your current input will not be saved!
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -346,7 +409,7 @@ export default function SubmitResumeForm() {
|
|||||||
<Button
|
<Button
|
||||||
addonPosition="start"
|
addonPosition="start"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
label="Clear"
|
label={isNewForm ? 'Clear' : 'Cancel'}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
onClick={onClickClear}
|
onClick={onClickClear}
|
||||||
/>
|
/>
|
||||||
|
@ -5,11 +5,12 @@ import { createProtectedRouter } from '../context';
|
|||||||
import type { Resume } from '~/types/resume';
|
import type { Resume } from '~/types/resume';
|
||||||
|
|
||||||
export const resumesResumeUserRouter = createProtectedRouter()
|
export const resumesResumeUserRouter = createProtectedRouter()
|
||||||
.mutation('create', {
|
.mutation('upsert', {
|
||||||
// TODO: Use enums for experience, location, role
|
// TODO: Use enums for experience, location, role
|
||||||
input: z.object({
|
input: z.object({
|
||||||
additionalInfo: z.string().optional(),
|
additionalInfo: z.string().optional(),
|
||||||
experience: z.string(),
|
experience: z.string(),
|
||||||
|
id: z.string().optional(),
|
||||||
location: z.string(),
|
location: z.string(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@ -17,11 +18,29 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
|||||||
}),
|
}),
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
return await ctx.prisma.resumesResume.create({
|
|
||||||
data: {
|
return await ctx.prisma.resumesResume.upsert({
|
||||||
...input,
|
create: {
|
||||||
|
additionalInfo: input.additionalInfo,
|
||||||
|
experience: input.experience,
|
||||||
|
location: input.location,
|
||||||
|
role: input.role,
|
||||||
|
title: input.title,
|
||||||
|
url: input.url,
|
||||||
userId,
|
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 ?? '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user