From 11df1e1f1ceb83bd4d99ca7a57abe2170592fbfa Mon Sep 17 00:00:00 2001 From: Ai Ling <50992674+ailing35@users.noreply.github.com> Date: Fri, 21 Oct 2022 02:19:29 +0800 Subject: [PATCH] [offers][feat] Integrate offers profile edit (#403) * [offers][fix] Fix offer analysis and save * [offers][fix] Fix profile view page * [offers][feat] Add offers profile edit --- .../portal/src/components/offers/constants.ts | 24 +- .../OfferProfileSave.tsx | 38 ++- .../offersSubmission/OffersSubmissionForm.tsx | 243 ++++++++++++++++++ .../analysis}/OfferAnalysis.tsx | 63 +++-- .../analysis/OfferPercentileAnalysis.tsx | 0 .../analysis/OfferProfileCard.tsx | 2 +- .../submissionForm}/BackgroundForm.tsx | 6 +- .../submissionForm}/OfferDetailsForm.tsx | 21 +- .../offers/profile/ProfileComments.tsx | 18 +- .../offers/profile/ProfileDetails.tsx | 4 +- .../offers/profile/ProfileHeader.tsx | 18 +- .../src/components/offers/table/OffersRow.tsx | 4 +- apps/portal/src/pages/offers/index.tsx | 20 +- .../pages/offers/profile/[offerProfileId].tsx | 79 +++--- .../offers/profile/edit/[offerProfileId].tsx | 79 ++++++ apps/portal/src/pages/offers/submit.tsx | 214 +-------------- .../router/offers/offers-analysis-router.ts | 2 +- .../src/utils/offers/currency/index.tsx | 2 +- apps/portal/src/utils/offers/link.tsx | 19 ++ apps/portal/src/utils/offers/time.tsx | 14 +- 20 files changed, 496 insertions(+), 374 deletions(-) rename apps/portal/src/components/offers/{offers-submission => offersSubmission}/OfferProfileSave.tsx (70%) create mode 100644 apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx rename apps/portal/src/components/offers/{offers-submission => offersSubmission/analysis}/OfferAnalysis.tsx (69%) rename apps/portal/src/components/offers/{ => offersSubmission}/analysis/OfferPercentileAnalysis.tsx (100%) rename apps/portal/src/components/offers/{ => offersSubmission}/analysis/OfferProfileCard.tsx (97%) rename apps/portal/src/components/offers/{offers-submission => offersSubmission/submissionForm}/BackgroundForm.tsx (98%) rename apps/portal/src/components/offers/{offers-submission => offersSubmission/submissionForm}/OfferDetailsForm.tsx (97%) create mode 100644 apps/portal/src/pages/offers/profile/edit/[offerProfileId].tsx create mode 100644 apps/portal/src/utils/offers/link.tsx diff --git a/apps/portal/src/components/offers/constants.ts b/apps/portal/src/components/offers/constants.ts index f289a91e..63a57d0e 100644 --- a/apps/portal/src/components/offers/constants.ts +++ b/apps/portal/src/components/offers/constants.ts @@ -5,20 +5,20 @@ export const emptyOption = '----'; // TODO: use enums export const titleOptions = [ { - label: 'Software engineer', - value: 'Software engineer', + label: 'Software Engineer', + value: 'Software Engineer', }, { - label: 'Frontend engineer', - value: 'Frontend engineer', + label: 'Frontend Engineer', + value: 'Frontend Engineer', }, { - label: 'Backend engineer', - value: 'Backend engineer', + label: 'Backend Engineer', + value: 'Backend Engineer', }, { - label: 'Full-stack engineer', - value: 'Full-stack engineer', + label: 'Full-stack Engineer', + value: 'Full-stack Engineer', }, ]; @@ -95,10 +95,18 @@ export const educationFieldOptions = [ label: 'Information Security', value: 'Information Security', }, + { + label: 'Information Systems', + value: 'Information Systems', + }, { label: 'Business Analytics', value: 'Business Analytics', }, + { + label: 'Data Science and Analytics', + value: 'Data Science and Analytics', + }, ]; export enum FieldError { diff --git a/apps/portal/src/components/offers/offers-submission/OfferProfileSave.tsx b/apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx similarity index 70% rename from apps/portal/src/components/offers/offers-submission/OfferProfileSave.tsx rename to apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx index 44c61ed7..071da82a 100644 --- a/apps/portal/src/components/offers/offers-submission/OfferProfileSave.tsx +++ b/apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx @@ -1,13 +1,30 @@ +import { useRouter } from 'next/router'; import { useState } from 'react'; import { setTimeout } from 'timers'; import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; import { Button, TextInput } from '@tih/ui'; -export default function OfferProfileSave() { +import { + copyProfileLink, + getProfileLink, + getProfilePath, +} from '~/utils/offers/link'; + +type OfferProfileSaveProps = Readonly<{ + profileId: string; + token?: string; +}>; + +export default function OfferProfileSave({ + profileId, + token, +}: OfferProfileSaveProps) { const [linkCopied, setLinkCopied] = useState(false); const [isSaving, setSaving] = useState(false); const [isSaved, setSaved] = useState(false); + const router = useRouter(); + const saveProfile = () => { setSaving(true); setTimeout(() => { @@ -27,13 +44,13 @@ export default function OfferProfileSave() { To keep you offer profile strictly anonymous, only people who have the link below can edit it.

-
+
-
+
{linkCopied && (

Link copied to clipboard!

)} @@ -60,13 +79,18 @@ export default function OfferProfileSave() { disabled={isSaved} icon={isSaved ? CheckIcon : BookmarkSquareIcon} isLoading={isSaving} - label="Save to user profile" + label={isSaved ? 'Saved to user profile' : 'Save to user profile'} variant="primary" onClick={saveProfile} />
-
diff --git a/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx b/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx new file mode 100644 index 00000000..10f92618 --- /dev/null +++ b/apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx @@ -0,0 +1,243 @@ +import { useRef, useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; +import { Button } from '@tih/ui'; + +import { Breadcrumbs } from '~/components/offers/Breadcrumb'; +import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis'; +import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave'; +import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; +import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; +import type { + OfferFormData, + OffersProfileFormData, +} from '~/components/offers/types'; +import { JobType } from '~/components/offers/types'; +import type { Month } from '~/components/shared/MonthYearPicker'; + +import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form'; +import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; +import { trpc } from '~/utils/trpc'; + +import type { CreateOfferProfileResponse } from '~/types/offers'; + +const defaultOfferValues = { + comments: '', + companyId: '', + jobType: JobType.FullTime, + location: '', + monthYearReceived: { + month: getCurrentMonth() as Month, + year: getCurrentYear(), + }, + negotiationStrategy: '', +}; + +export const defaultFullTimeOfferValues = { + ...defaultOfferValues, + jobType: JobType.FullTime, +}; + +export const defaultInternshipOfferValues = { + ...defaultOfferValues, + jobType: JobType.Intern, +}; + +const defaultOfferProfileValues = { + background: { + educations: [], + experiences: [{ jobType: JobType.FullTime }], + specificYoes: [], + totalYoe: 0, + }, + offers: [defaultOfferValues], +}; + +type FormStep = { + component: JSX.Element; + hasNext: boolean; + hasPrevious: boolean; + label: string; +}; + +type Props = Readonly<{ + initialOfferProfileValues?: OffersProfileFormData; + profileId?: string; + token?: string; +}>; + +export default function OffersSubmissionForm({ + initialOfferProfileValues = defaultOfferProfileValues, + profileId, + token, +}: Props) { + const [formStep, setFormStep] = useState(0); + const [createProfileResponse, setCreateProfileResponse] = + useState({ + id: profileId || '', + token: token || '', + }); + + const pageRef = useRef(null); + const scrollToTop = () => + pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); + const formMethods = useForm({ + defaultValues: initialOfferProfileValues, + mode: 'all', + }); + const { handleSubmit, trigger } = formMethods; + + const formSteps: Array = [ + { + component: , + hasNext: true, + hasPrevious: false, + label: 'Offer details', + }, + { + component: , + hasNext: false, + hasPrevious: true, + label: 'Background', + }, + { + component: , + hasNext: true, + hasPrevious: false, + label: 'Analysis', + }, + { + component: ( + + ), + hasNext: false, + hasPrevious: false, + label: 'Save', + }, + ]; + + const formStepsLabels = formSteps.map((step) => step.label); + + const nextStep = async (currStep: number) => { + if (currStep === 0) { + const result = await trigger('offers'); + if (!result) { + return; + } + } + setFormStep(formStep + 1); + scrollToTop(); + }; + + const previousStep = () => { + setFormStep(formStep - 1); + scrollToTop(); + }; + + const generateAnalysisMutation = trpc.useMutation( + ['offers.analysis.generate'], + { + onError(error) { + console.error(error.message); + }, + }, + ); + + const mutationpath = + profileId && token ? 'offers.profile.update' : 'offers.profile.create'; + + const createOrUpdateMutation = trpc.useMutation([mutationpath], { + onError(error) { + console.error(error.message); + }, + onSuccess(data) { + generateAnalysisMutation.mutate({ + profileId: data?.id || '', + }); + setCreateProfileResponse(data); + setFormStep(formStep + 1); + scrollToTop(); + }, + }); + + const onSubmit: SubmitHandler = async (data) => { + const result = await trigger(); + if (!result) { + return; + } + + data = removeInvalidMoneyData(data); + + const background = cleanObject(data.background); + background.specificYoes = data.background.specificYoes.filter( + (specificYoe) => specificYoe.domain && specificYoe.yoe > 0, + ); + if (Object.entries(background.experiences[0]).length === 1) { + background.experiences = []; + } + + const offers = data.offers.map((offer: OfferFormData) => ({ + ...offer, + monthYearReceived: new Date( + offer.monthYearReceived.year, + offer.monthYearReceived.month - 1, // Convert month to monthIndex + ), + })); + + if (profileId && token) { + createOrUpdateMutation.mutate({ + background, + id: profileId, + offers, + token, + }); + } else { + createOrUpdateMutation.mutate({ background, offers }); + } + }; + + return ( +
+
+
+
+ +
+ +
+ {formSteps[formStep].component} + {/*
{JSON.stringify(formMethods.watch(), null, 2)}
*/} + {formSteps[formStep].hasNext && ( +
+
+ )} + {formStep === 1 && ( +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/offers/offers-submission/OfferAnalysis.tsx b/apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx similarity index 69% rename from apps/portal/src/components/offers/offers-submission/OfferAnalysis.tsx rename to apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx index 6a0717da..4b45b3f2 100644 --- a/apps/portal/src/components/offers/offers-submission/OfferAnalysis.tsx +++ b/apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx @@ -1,13 +1,12 @@ -import Error from 'next/error'; import { useEffect } from 'react'; import { useState } from 'react'; import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; import { trpc } from '~/utils/trpc'; -import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis'; -import OfferProfileCard from '../analysis/OfferProfileCard'; -import { OVERALL_TAB } from '../constants'; +import OfferPercentileAnalysis from './OfferPercentileAnalysis'; +import OfferProfileCard from './OfferProfileCard'; +import { OVERALL_TAB } from '../../constants'; import type { Analysis, @@ -105,34 +104,32 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) { ]; return ( - <> - {getAnalysisResult.isError && ( - - )} - {!getAnalysisResult.isError && analysis && ( -
-
- Result -
- {getAnalysisResult.isLoading ? ( - - ) : ( -
- - - -
- )} -
- )} - + analysis && ( +
+
+ Result +
+ {getAnalysisResult.isError && ( +

+ An error occurred while generating profile analysis. +

+ )} + {getAnalysisResult.isLoading && ( + + )} + {!getAnalysisResult.isError && !getAnalysisResult.isLoading && ( +
+ + + +
+ )} +
+ ) ); } diff --git a/apps/portal/src/components/offers/analysis/OfferPercentileAnalysis.tsx b/apps/portal/src/components/offers/offersSubmission/analysis/OfferPercentileAnalysis.tsx similarity index 100% rename from apps/portal/src/components/offers/analysis/OfferPercentileAnalysis.tsx rename to apps/portal/src/components/offers/offersSubmission/analysis/OfferPercentileAnalysis.tsx diff --git a/apps/portal/src/components/offers/analysis/OfferProfileCard.tsx b/apps/portal/src/components/offers/offersSubmission/analysis/OfferProfileCard.tsx similarity index 97% rename from apps/portal/src/components/offers/analysis/OfferProfileCard.tsx rename to apps/portal/src/components/offers/offersSubmission/analysis/OfferProfileCard.tsx index 9969c953..8d087a0c 100644 --- a/apps/portal/src/components/offers/analysis/OfferProfileCard.tsx +++ b/apps/portal/src/components/offers/offersSubmission/analysis/OfferProfileCard.tsx @@ -3,7 +3,7 @@ import { UserCircleIcon } from '@heroicons/react/24/outline'; import { HorizontalDivider } from '~/../../../packages/ui/dist'; import { formatDate } from '~/utils/offers/time'; -import { JobType } from '../types'; +import { JobType } from '../../types'; import type { AnalysisOffer } from '~/types/offers'; diff --git a/apps/portal/src/components/offers/offers-submission/BackgroundForm.tsx b/apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx similarity index 98% rename from apps/portal/src/components/offers/offers-submission/BackgroundForm.tsx rename to apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx index 645d60ca..534108b5 100644 --- a/apps/portal/src/components/offers/offers-submission/BackgroundForm.tsx +++ b/apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx @@ -15,9 +15,9 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; -import FormRadioList from '../forms/FormRadioList'; -import FormSelect from '../forms/FormSelect'; -import FormTextInput from '../forms/FormTextInput'; +import FormRadioList from '../../forms/FormRadioList'; +import FormSelect from '../../forms/FormSelect'; +import FormTextInput from '../../forms/FormTextInput'; function YoeSection() { const { register, formState } = useFormContext<{ diff --git a/apps/portal/src/components/offers/offers-submission/OfferDetailsForm.tsx b/apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx similarity index 97% rename from apps/portal/src/components/offers/offers-submission/OfferDetailsForm.tsx rename to apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx index eee37504..d380002c 100644 --- a/apps/portal/src/components/offers/offers-submission/OfferDetailsForm.tsx +++ b/apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx @@ -16,8 +16,7 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import { defaultFullTimeOfferValues, defaultInternshipOfferValues, -} from '~/pages/offers/submit'; - +} from '../OffersSubmissionForm'; import { emptyOption, FieldError, @@ -25,15 +24,15 @@ import { locationOptions, titleOptions, yearOptions, -} from '../constants'; -import FormMonthYearPicker from '../forms/FormMonthYearPicker'; -import FormSelect from '../forms/FormSelect'; -import FormTextArea from '../forms/FormTextArea'; -import FormTextInput from '../forms/FormTextInput'; -import type { OfferFormData } from '../types'; -import { JobTypeLabel } from '../types'; -import { JobType } from '../types'; -import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; +} from '../../constants'; +import FormMonthYearPicker from '../../forms/FormMonthYearPicker'; +import FormSelect from '../../forms/FormSelect'; +import FormTextArea from '../../forms/FormTextArea'; +import FormTextInput from '../../forms/FormTextInput'; +import type { OfferFormData } from '../../types'; +import { JobTypeLabel } from '../../types'; +import { JobType } from '../../types'; +import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; type FullTimeOfferDetailsFormProps = Readonly<{ index: number; diff --git a/apps/portal/src/components/offers/profile/ProfileComments.tsx b/apps/portal/src/components/offers/profile/ProfileComments.tsx index ecf1843d..d30645a8 100644 --- a/apps/portal/src/components/offers/profile/ProfileComments.tsx +++ b/apps/portal/src/components/offers/profile/ProfileComments.tsx @@ -5,6 +5,7 @@ import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; +import { copyProfileLink } from '~/utils/offers/link'; import { trpc } from '~/utils/trpc'; import type { OffersDiscussion, Reply } from '~/types/offers'; @@ -84,19 +85,6 @@ export default function ProfileComments({ } } - function handleCopyEditLink() { - // TODO: Add notification - navigator.clipboard.writeText( - `${window.location.origin}/offers/profile/${profileId}?token=${token}`, - ); - } - - function handleCopyPublicLink() { - navigator.clipboard.writeText( - `${window.location.origin}/offers/profile/${profileId}`, - ); - } - if (isLoading) { return (
@@ -116,7 +104,7 @@ export default function ProfileComments({ label="Copy profile edit link" size="sm" variant="secondary" - onClick={handleCopyEditLink} + onClick={() => copyProfileLink(profileId, token)} /> )}

Discussions

diff --git a/apps/portal/src/components/offers/profile/ProfileDetails.tsx b/apps/portal/src/components/offers/profile/ProfileDetails.tsx index 6799efc5..1707fe42 100644 --- a/apps/portal/src/components/offers/profile/ProfileDetails.tsx +++ b/apps/portal/src/components/offers/profile/ProfileDetails.tsx @@ -27,10 +27,10 @@ export default function ProfileDetails({ ); } if (selectedTab === 'offers') { - if (offers && offers.length !== 0) { + if (offers.length !== 0) { return ( <> - {[...offers].map((offer) => ( + {offers.map((offer) => ( ))} diff --git a/apps/portal/src/components/offers/profile/ProfileHeader.tsx b/apps/portal/src/components/offers/profile/ProfileHeader.tsx index c37b40ff..ceab5e0e 100644 --- a/apps/portal/src/components/offers/profile/ProfileHeader.tsx +++ b/apps/portal/src/components/offers/profile/ProfileHeader.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'next/router'; import { useState } from 'react'; import { BookmarkSquareIcon, @@ -11,6 +12,8 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; import type { BackgroundCard } from '~/components/offers/types'; +import { getProfileEditPath } from '~/utils/offers/link'; + type ProfileHeaderProps = Readonly<{ background?: BackgroundCard; handleDelete: () => void; @@ -29,6 +32,12 @@ export default function ProfileHeader({ setSelectedTab, }: ProfileHeaderProps) { const [isDialogOpen, setIsDialogOpen] = useState(false); + const router = useRouter(); + const { offerProfileId = '', token = '' } = router.query; + + const handleEditClick = () => { + router.push(getProfileEditPath(offerProfileId as string, token as string)); + }; function renderActionList() { return ( @@ -48,6 +57,7 @@ export default function ProfileHeader({ label="Edit" size="md" variant="tertiary" + onClick={handleEditClick} />