[resumes][feat] drag and drop for file upload (#378)

* [resumes][feat] drag and drop for file upload

* [resumes][chore] use .tsx instead for landing page

* [resumes][feat] use expandable text for additionalnfo

* [resumes][refactor] clean up submit form

* [resumes][fix] fix file upload error

* [feat][resumes] change button to Submit Resume

* [resumes][fix] fix expandable text
This commit is contained in:
Keane Chan
2022-10-14 21:47:05 +08:00
committed by GitHub
parent ff9cffa715
commit 7b51ee7e88
8 changed files with 170 additions and 125 deletions

View File

@ -30,6 +30,7 @@
"next-auth": "~4.10.3", "next-auth": "~4.10.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1", "react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2", "react-pdf": "^5.7.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",

View File

@ -10,7 +10,7 @@ export default function ResumeExpandableText({
children, children,
}: ResumeExpandableTextProps) { }: ResumeExpandableTextProps) {
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [descriptionOverflow, setDescriptionOverflow] = useState(false); const [descriptionOverflow, setDescriptionOverflow] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@ -20,29 +20,27 @@ export default function ResumeExpandableText({
}, [ref]); }, [ref]);
const onSeeActionClicked = () => { const onSeeActionClicked = () => {
setDescriptionExpanded(!descriptionExpanded); setIsExpanded((prevExpanded) => !prevExpanded);
}; };
return ( return (
<> <div>
<span <span
ref={ref} ref={ref}
className={clsx( className={clsx(
'whitespace-pre-wrap text-sm', 'whitespace-pre-wrap text-sm',
'line-clamp-3', 'line-clamp-3',
descriptionExpanded ? 'line-clamp-none' : '', isExpanded ? 'line-clamp-none' : '',
)}> )}>
{children} {children}
</span> </span>
{descriptionOverflow && ( {descriptionOverflow && (
<div className="flex flex-row"> <p
<div className="mt-1 cursor-pointer text-xs text-indigo-500 hover:text-indigo-300"
className="text-xs text-indigo-500 hover:text-indigo-300"
onClick={onSeeActionClicked}> onClick={onSeeActionClicked}>
{descriptionExpanded ? 'See Less' : 'See More'} {isExpanded ? 'See Less' : 'See More'}
</div> </p>
</div>
)} )}
</> </div>
); );
} }

View File

@ -0,0 +1,30 @@
export default function SubmissionGuidelines() {
return (
<div className="mb-4 text-left text-sm text-slate-700">
<h2 className="mb-2 text-xl font-medium">Submission Guidelines</h2>
<p>
Before you submit, please review and acknolwedge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any
<span className="font-bold">
{' '}
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold"> </span>
Proof-read your resumes to look for grammatical/spelling errors.
</p>
</div>
);
}

View File

@ -16,6 +16,7 @@ import { Spinner } from '@tih/ui';
import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection'; import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection';
import ResumePdf from '~/components/resumes/ResumePdf'; import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -158,7 +159,9 @@ export default function ResumeReviewPage() {
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/> />
<ResumeExpandableText>
{detailsQuery.data.additionalInfo} {detailsQuery.data.additionalInfo}
</ResumeExpandableText>
</div> </div>
)} )}
<div className="flex w-full flex-col py-4 lg:flex-row"> <div className="flex w-full flex-col py-4 lg:flex-row">

View File

@ -240,10 +240,10 @@ export default function ResumeHomePage() {
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<button <button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white" className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white"
type="button" type="button"
onClick={onSubmitResume}> onClick={onSubmitResume}>
Submit Submit Resume
</button> </button>
</div> </div>
</div> </div>

View File

@ -3,7 +3,9 @@ import clsx from 'clsx';
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 { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { PaperClipIcon } from '@heroicons/react/24/outline'; import { PaperClipIcon } from '@heroicons/react/24/outline';
@ -16,11 +18,13 @@ import {
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
import type { FilterOption } from '~/components/resumes/browse/resumeConstants';
import { import {
EXPERIENCE, EXPERIENCE,
LOCATION, LOCATION,
ROLE, ROLE,
} from '~/components/resumes/browse/resumeConstants'; } from '~/components/resumes/browse/resumeConstants';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -43,11 +47,20 @@ type IFormInput = {
title: string; title: string;
}; };
export default function SubmitResumeForm() { type SelectorType = 'experience' | 'location' | 'role';
const { data: session, status } = useSession(); type SelectorOptions = {
const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); key: SelectorType;
const router = useRouter(); label: string;
options: Array<FilterOption>;
};
const selectors: Array<SelectorOptions> = [
{ key: 'role', label: 'Role', options: ROLE },
{ key: 'experience', label: 'Experience Level', options: EXPERIENCE },
{ key: 'location', label: 'Location', options: LOCATION },
];
export default function SubmitResumeForm() {
const [resumeFile, setResumeFile] = useState<File | null>(); const [resumeFile, setResumeFile] = useState<File | null>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState< const [invalidFileUploadError, setInvalidFileUploadError] = useState<
@ -55,13 +68,9 @@ export default function SubmitResumeForm() {
>(null); >(null);
const [isDialogShown, setIsDialogShown] = useState(false); const [isDialogShown, setIsDialogShown] = useState(false);
useEffect(() => { const { data: session, status } = useSession();
if (status !== 'loading') { const router = useRouter();
if (session?.user?.id == null) { const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create');
router.push('/api/auth/signin');
}
}
}, [router, session, status]);
const { const {
register, register,
@ -75,9 +84,38 @@ export default function SubmitResumeForm() {
}, },
}); });
const onFileDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
if (fileRejections.length === 0) {
setInvalidFileUploadError('');
setResumeFile(acceptedFiles[0]);
setValue('file', acceptedFiles[0]);
} else {
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
}
},
[setValue],
);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
maxFiles: 1,
maxSize: FILE_SIZE_LIMIT_BYTES,
onDrop: onFileDrop,
});
useEffect(() => {
if (status !== 'loading') {
if (session?.user?.id == null) {
router.push('/api/auth/signin');
}
}
}, [router, session, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (resumeFile == null) { if (resumeFile == null) {
console.error('Resume file is empty');
return; return;
} }
setIsLoading(true); setIsLoading(true);
@ -103,62 +141,53 @@ export default function SubmitResumeForm() {
url, url,
}, },
{ {
onError: (error) => { onError(error) {
console.error(error); console.error(error);
}, },
onSettled: () => { onSettled() {
setIsLoading(false); setIsLoading(false);
}, },
onSuccess: () => { onSuccess() {
router.push('/resumes'); router.push('/resumes/browse');
}, },
}, },
); );
}; };
const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => { const onClickClear = () => {
const file = event.target.files?.item(0);
if (file == null) {
return;
}
if (file.type !== 'application/pdf' || file.size > FILE_SIZE_LIMIT_BYTES) {
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
return;
}
setInvalidFileUploadError('');
setResumeFile(file);
};
const onClickReset = () => {
if (isDirty || resumeFile != null) { if (isDirty || resumeFile != null) {
setIsDialogShown(true); setIsDialogShown(true);
} }
}; };
const onClickProceedDialog = () => { const onClickResetDialog = () => {
setIsDialogShown(false); setIsDialogShown(false);
reset(); reset();
setResumeFile(null); setResumeFile(null);
setInvalidFileUploadError(null);
}; };
const onClickDownload = async () => { const onClickDownload = async (
event: React.MouseEvent<HTMLParagraphElement, MouseEvent>,
) => {
if (resumeFile == null) { if (resumeFile == null) {
return; return;
} }
// Prevent click event from propagating up to dropzone
event.stopPropagation();
const url = window.URL.createObjectURL(resumeFile); const url = window.URL.createObjectURL(resumeFile);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.setAttribute('download', resumeFile.name); link.setAttribute('download', resumeFile.name);
// Append to html link element page
document.body.appendChild(link); document.body.appendChild(link);
// Start download // Start download
link.click(); link.click();
// Clean up and remove the link // Clean up and remove the link and object URL
link.remove(); link.remove();
URL.revokeObjectURL(url);
}; };
const fileUploadError = useMemo(() => { const fileUploadError = useMemo(() => {
@ -179,6 +208,7 @@ export default function SubmitResumeForm() {
<section <section
aria-labelledby="primary-heading" aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last"> className="flex h-full min-w-0 flex-1 flex-col lg:order-last">
{/* Reset Dialog component */}
<Dialog <Dialog
isShown={isDialogShown} isShown={isDialogShown}
primaryButton={ primaryButton={
@ -186,7 +216,7 @@ export default function SubmitResumeForm() {
display="block" display="block"
label="OK" label="OK"
variant="primary" variant="primary"
onClick={onClickProceedDialog} onClick={onClickResetDialog}
/> />
} }
secondaryButton={ secondaryButton={
@ -204,6 +234,7 @@ export default function SubmitResumeForm() {
<div className="mx-20 space-y-4 py-8"> <div className="mx-20 space-y-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<h1 className="mb-4 text-2xl font-bold">Upload a resume</h1> <h1 className="mb-4 text-2xl font-bold">Upload a resume</h1>
{/* Title Section */}
<div className="mb-4"> <div className="mb-4">
<TextInput <TextInput
{...register('title', { required: true })} {...register('title', { required: true })}
@ -214,37 +245,20 @@ export default function SubmitResumeForm() {
onChange={(val) => setValue('title', val)} onChange={(val) => setValue('title', val)}
/> />
</div> </div>
<div className="mb-4"> {/* Selectors */}
{selectors.map((item) => (
<div key={item.key} className="mb-4">
<Select <Select
{...register('role', { required: true })} {...register(item.key, { required: true })}
disabled={isLoading} disabled={isLoading}
label="Role" label={item.label}
options={ROLE} options={item.options}
required={true} required={true}
onChange={(val) => setValue('role', val)} onChange={(val) => setValue(item.key, val)}
/>
</div>
<div className="mb-4">
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCE}
required={true}
onChange={(val) => setValue('experience', val)}
/>
</div>
<div className="mb-4">
<Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
name="location"
options={LOCATION}
required={true}
onChange={(val) => setValue('location', val)}
/> />
</div> </div>
))}
{/* Upload Resume Section */}
<p className="text-sm font-medium text-slate-700"> <p className="text-sm font-medium text-slate-700">
Upload resume (PDF format) Upload resume (PDF format)
<span aria-hidden="true" className="text-danger-500"> <span aria-hidden="true" className="text-danger-500">
@ -252,8 +266,10 @@ export default function SubmitResumeForm() {
* *
</span> </span>
</p> </p>
{/* Upload Resume Box */}
<div className="mb-4"> <div className="mb-4">
<div <div
{...getRootProps()}
className={clsx( className={clsx(
fileUploadError ? 'border-danger-600' : 'border-gray-300', fileUploadError ? 'border-danger-600' : 'border-gray-300',
'mt-2 flex justify-center rounded-md border-2 border-dashed px-6 pt-5 pb-6', 'mt-2 flex justify-center rounded-md border-2 border-dashed px-6 pt-5 pb-6',
@ -276,20 +292,25 @@ export default function SubmitResumeForm() {
<label <label
className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2" className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2"
htmlFor="file-upload"> htmlFor="file-upload">
<p className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-500"> <div className="flex gap-1 ">
<p className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-400">
{resumeFile == null {resumeFile == null
? 'Upload a file' ? 'Upload a file'
: 'Replace file'} : 'Replace file'}
</p> </p>
<span className="text-gray-500">
or drag and drop
</span>
</div>
<input <input
{...register('file', { required: true })} {...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf" accept="application/pdf"
className="sr-only" className="sr-only"
disabled={isLoading} disabled={isLoading}
id="file-upload" id="file-upload"
name="file-upload" name="file-upload"
type="file" type="file"
onChange={onUploadFile}
/> />
</label> </label>
</div> </div>
@ -302,6 +323,7 @@ export default function SubmitResumeForm() {
<p className="text-danger-600 text-sm">{fileUploadError}</p> <p className="text-danger-600 text-sm">{fileUploadError}</p>
)} )}
</div> </div>
{/* Additional Info Section */}
<div className="mb-8"> <div className="mb-8">
<TextArea <TextArea
{...register('additionalInfo')} {...register('additionalInfo')}
@ -311,58 +333,28 @@ export default function SubmitResumeForm() {
onChange={(val) => setValue('additionalInfo', val)} onChange={(val) => setValue('additionalInfo', val)}
/> />
</div> </div>
<div className="mb-4 text-left text-sm text-slate-700"> {/* Submission Guidelines */}
<h2 className="mb-2 text-xl font-medium"> <SubmissionGuidelines />
Submission Guidelines
</h2>
<p>
Before you submit, please review and acknolwedge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any
<span className="font-bold">
{' '}
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold">• </span>
Proof-read your resumes to look for grammatical/spelling
errors.
</p>
</div>
<CheckboxInput <CheckboxInput
{...register('isChecked', { required: true })} {...register('isChecked', { required: true })}
disabled={isLoading} disabled={isLoading}
label="I have read and will follow the guidelines stated." label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)} onChange={(val) => setValue('isChecked', val)}
/> />
{/* Clear and Submit Buttons */}
<div className="mt-4 flex justify-end gap-4"> <div className="mt-4 flex justify-end gap-4">
<Button <Button
addonPosition="start" addonPosition="start"
disabled={isLoading} disabled={isLoading}
display="inline"
label="Clear" label="Clear"
size="md"
variant="tertiary" variant="tertiary"
onClick={onClickReset} onClick={onClickClear}
/> />
<Button <Button
addonPosition="start" addonPosition="start"
disabled={isLoading} disabled={isLoading}
display="inline"
isLoading={isLoading} isLoading={isLoading}
label="Submit" label="Submit"
size="md"
type="submit" type="submit"
variant="primary" variant="primary"
/> />

View File

@ -4605,6 +4605,11 @@ atob@^2.1.2:
resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz" resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
attr-accept@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
autoprefixer@^10.3.7, autoprefixer@^10.4.12, autoprefixer@^10.4.7: autoprefixer@^10.3.7, autoprefixer@^10.4.12, autoprefixer@^10.4.7:
version "10.4.12" version "10.4.12"
resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz"
@ -7733,6 +7738,13 @@ file-loader@^6.0.0, file-loader@^6.2.0:
loader-utils "^2.0.0" loader-utils "^2.0.0"
schema-utils "^3.0.0" schema-utils "^3.0.0"
file-selector@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
dependencies:
tslib "^2.4.0"
file-system-cache@^1.0.5: file-system-cache@^1.0.5:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz" resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz"
@ -12162,6 +12174,15 @@ react-dom@18.2.0, react-dom@^18.2.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.23.0" scheduler "^0.23.0"
react-dropzone@^14.2.3:
version "14.2.3"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
dependencies:
attr-accept "^2.2.2"
file-selector "^0.6.0"
prop-types "^15.8.1"
react-element-to-jsx-string@^14.3.4: react-element-to-jsx-string@^14.3.4:
version "14.3.4" version "14.3.4"
resolved "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz" resolved "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz"