[offers][feat] Add offers profile edit

pull/403/head
Ai Ling 3 years ago
parent 1bbb91e3c9
commit b04739f9f3

@ -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 {

@ -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<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
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<OffersProfileFormData> = 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 (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -4,9 +4,9 @@ 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,

@ -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<{

@ -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;

@ -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) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>

@ -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}
/>
<Button
disabled={isLoading}

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Select } from '@tih/ui';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -20,24 +21,7 @@ export default function OffersHomePage() {
<Select
isLabelHidden={true}
label="Select a job title"
options={[
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
]}
options={titleOptions}
value={jobTitleFilter}
onChange={setjobTitleFilter}
/>

@ -0,0 +1,79 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link';
import { convertToMonthYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
export default function OffersEditPage() {
const [initialData, setInitialData] = useState<OffersProfileFormData>();
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const getProfileResult = trpc.useQuery(
[
'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string },
],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
const { educations, experiences, specificYoes, totalYoe } =
data.background!;
setInitialData({
background: {
educations,
experiences:
experiences.length === 0
? [{ jobType: JobType.FullTime }]
: experiences,
specificYoes,
totalYoe,
},
offers: data.offers.map((offer) => ({
comments: offer.comments,
companyId: offer.company.id,
id: offer.id,
jobType: offer.jobType,
location: offer.location,
monthYearReceived: convertToMonthYear(offer.monthYearReceived),
negotiationStrategy: offer.negotiationStrategy,
offersFullTime: offer.offersFullTime,
offersIntern: offer.offersIntern,
})),
});
},
},
);
const profile = getProfileResult.data;
if (profile && !profile.isEditable) {
router.push(getProfilePath(profile.id));
}
return (
<>
{getProfileResult.isLoading && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
)}
{!getProfileResult.isLoading && (
<OffersSubmissionForm
initialOfferProfileValues={initialData}
profileId={profile?.id}
token={profile?.editToken || undefined}
/>
)}
</>
);
}

@ -1,221 +1,5 @@
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 BackgroundForm from '~/components/offers/offers-submission/BackgroundForm';
import OfferAnalysis from '~/components/offers/offers-submission/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/offers-submission/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/offers-submission/OfferProfileSave';
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: [],
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse?.id || ''}
token={createProfileResponse?.token}
/>
),
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 createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = 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,
),
}));
const postData = { background, offers };
createMutation.mutate(postData);
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
return <OffersSubmissionForm />;
}

@ -13,3 +13,7 @@ export function getProfilePath(profileId: string, token?: string) {
}
return `/offers/profile/${profileId}`;
}
export function getProfileEditPath(profileId: string, token: string) {
return `/offers/profile/edit/${profileId}?token=${token}`;
}

@ -32,22 +32,20 @@ export function timeSinceNow(date: Date | number | string) {
export function formatDate(value: Date | number | string) {
const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`;
}
export function formatMonthYear({ month, year }: MonthYear) {
const monthString = month < 10 ? month.toString() : `0${month}`;
const yearString = year.toString();
return `${monthString}/${yearString}`;
}
export function getCurrentMonth() {
return getMonth(Date.now());
// `getMonth` returns a zero-based month index
return getMonth(Date.now()) + 1;
}
export function getCurrentYear() {
return getYear(Date.now());
}
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}

Loading…
Cancel
Save