From f179c4ef1f9d3212ff901979db91eda30612d68b Mon Sep 17 00:00:00 2001 From: Ai Ling <50992674+ailing35@users.noreply.github.com> Date: Wed, 12 Oct 2022 21:30:47 +0800 Subject: [PATCH] [offers][feat] Enhance submit offers form (#366) * [eslint] Replace no-shadow with typescript no-shadow * [offers][feat] Add auto scroll to top * [offers][feat] Add error messages for text input fields * [offers][fix] Add warning dialogs * [offers][fix] Auto change currency according to TC currency * [offers][fix] Add select error messages and fix date picker labels * [offers][fix] Fix console warnings --- .../src/components/offers/Breadcrumb.tsx | 4 +- .../src/components/offers/OffersNavigation.ts | 1 - .../portal/src/components/offers/constants.ts | 31 +- .../offers/forms/OfferDetailsForm.tsx | 328 +++++++++++++----- .../forms/components/FormMonthYearPicker.tsx | 7 +- apps/portal/src/components/offers/types.ts | 21 +- apps/portal/src/pages/offers/submit.tsx | 14 +- packages/eslint-config-tih/index.js | 3 +- 8 files changed, 284 insertions(+), 125 deletions(-) diff --git a/apps/portal/src/components/offers/Breadcrumb.tsx b/apps/portal/src/components/offers/Breadcrumb.tsx index 9700e486..899d6f9e 100644 --- a/apps/portal/src/components/offers/Breadcrumb.tsx +++ b/apps/portal/src/components/offers/Breadcrumb.tsx @@ -7,7 +7,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) { return (
{stepLabels.map((label, index) => ( - <> +
{index === currentStep ? (

{label}

) : ( @@ -16,7 +16,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) { {index !== stepLabels.length - 1 && (

{'>'}

)} - +
))}
); diff --git a/apps/portal/src/components/offers/OffersNavigation.ts b/apps/portal/src/components/offers/OffersNavigation.ts index a1974d94..cfa314dc 100644 --- a/apps/portal/src/components/offers/OffersNavigation.ts +++ b/apps/portal/src/components/offers/OffersNavigation.ts @@ -1,7 +1,6 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; const navigation: ProductNavigationItems = [ - { href: '/offers', name: 'Home' }, { href: '/offers/submit', name: 'Benchmark your offer' }, ]; diff --git a/apps/portal/src/components/offers/constants.ts b/apps/portal/src/components/offers/constants.ts index a5dc2706..e360a505 100644 --- a/apps/portal/src/components/offers/constants.ts +++ b/apps/portal/src/components/offers/constants.ts @@ -1,13 +1,9 @@ import { EducationBackgroundType } from './types'; -const emptyOption = { - label: '----', - value: '', -}; +export const emptyOption = '----'; // TODO: use enums export const titleOptions = [ - emptyOption, { label: 'Software engineer', value: 'Software engineer', @@ -27,7 +23,6 @@ export const titleOptions = [ ]; export const companyOptions = [ - emptyOption, { label: 'Amazon', value: 'cl93patjt0000txewdi601mub', @@ -51,7 +46,6 @@ export const companyOptions = [ ]; export const locationOptions = [ - emptyOption, { label: 'Singapore, Singapore', value: 'Singapore, Singapore', @@ -67,7 +61,6 @@ export const locationOptions = [ ]; export const internshipCycleOptions = [ - emptyOption, { label: 'Summer', value: 'Summer', @@ -91,7 +84,6 @@ export const internshipCycleOptions = [ ]; export const yearOptions = [ - emptyOption, { label: '2021', value: '2021', @@ -110,17 +102,14 @@ export const yearOptions = [ }, ]; -const educationBackgroundTypes = Object.entries(EducationBackgroundType).map( - ([key, value]) => ({ - label: key, - value, - }), -); - -export const educationLevelOptions = [emptyOption, ...educationBackgroundTypes]; +export const educationLevelOptions = Object.entries( + EducationBackgroundType, +).map(([key, value]) => ({ + label: key, + value, +})); export const educationFieldOptions = [ - emptyOption, { label: 'Computer Science', value: 'Computer Science', @@ -134,3 +123,9 @@ export const educationFieldOptions = [ value: 'Business Analytics', }, ]; + +export enum FieldError { + NonNegativeNumber = 'Please fill in a non-negative number in this field.', + Number = 'Please fill in a number in this field.', + Required = 'Please fill in this field.', +} diff --git a/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx b/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx index 6348360f..ffdbecd5 100644 --- a/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx +++ b/apps/portal/src/components/offers/forms/OfferDetailsForm.tsx @@ -1,14 +1,11 @@ -import { useState } from 'react'; -import type { - FieldValues, - UseFieldArrayRemove, - UseFieldArrayReturn, -} from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form'; +import { useWatch } from 'react-hook-form'; import { useFormContext } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form'; import { PlusIcon } from '@heroicons/react/20/solid'; import { TrashIcon } from '@heroicons/react/24/outline'; -import { Button } from '@tih/ui'; +import { Button, Dialog } from '@tih/ui'; import FormMonthYearPicker from './components/FormMonthYearPicker'; import FormSelect from './components/FormSelect'; @@ -16,74 +13,110 @@ import FormTextArea from './components/FormTextArea'; import FormTextInput from './components/FormTextInput'; import { companyOptions, + emptyOption, + FieldError, internshipCycleOptions, locationOptions, titleOptions, yearOptions, } from '../constants'; -import type { OfferDetailsFormData } from '../types'; +import type { + FullTimeOfferDetailsFormData, + InternshipOfferDetailsFormData, +} from '../types'; +import { JobTypeLabel } from '../types'; import { JobType } from '../types'; import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; type FullTimeOfferDetailsFormProps = Readonly<{ index: number; - remove: UseFieldArrayRemove; + setDialogOpen: (isOpen: boolean) => void; }>; function FullTimeOfferDetailsForm({ index, - remove, + setDialogOpen, }: FullTimeOfferDetailsFormProps) { - const { register } = useFormContext<{ - offers: Array; + const { register, formState, setValue } = useFormContext<{ + offers: Array; }>(); + const offerFields = formState.errors.offers?.[index]; + + const watchCurrency = useWatch({ + name: `offers.${index}.job.totalCompensation.currency`, + }); + + useEffect(() => { + setValue(`offers.${index}.job.base.currency`, watchCurrency); + setValue(`offers.${index}.job.bonus.currency`, watchCurrency); + setValue(`offers.${index}.job.stocks.currency`, watchCurrency); + }, [watchCurrency, index, setValue]); return (
@@ -95,19 +128,21 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.job.totalCompensation.currency`, { - required: true, + required: FieldError.Required, })} /> } endAddOnType="element" + errorMessage={offerFields?.job?.totalCompensation?.value?.message} label="Total Compensation (Annual)" - placeholder="0.00" + placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" {...register(`offers.${index}.job.totalCompensation.value`, { - required: true, + min: { message: FieldError.NonNegativeNumber, value: 0 }, + required: FieldError.Required, valueAsNumber: true, })} /> @@ -121,19 +156,21 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.job.base.currency`, { - required: true, + required: FieldError.Required, })} /> } endAddOnType="element" + errorMessage={offerFields?.job?.base?.value?.message} label="Base Salary (Annual)" - placeholder="0.00" + placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" {...register(`offers.${index}.job.base.value`, { - required: true, + min: { message: FieldError.NonNegativeNumber, value: 0 }, + required: FieldError.Required, valueAsNumber: true, })} /> @@ -145,19 +182,21 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.job.bonus.currency`, { - required: true, + required: FieldError.Required, })} /> } endAddOnType="element" + errorMessage={offerFields?.job?.bonus?.value?.message} label="Bonus (Annual)" - placeholder="0.00" + placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" {...register(`offers.${index}.job.bonus.value`, { - required: true, + min: { message: FieldError.NonNegativeNumber, value: 0 }, + required: FieldError.Required, valueAsNumber: true, })} /> @@ -171,19 +210,21 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.job.stocks.currency`, { - required: true, + required: FieldError.Required, })} /> } endAddOnType="element" + errorMessage={offerFields?.job?.stocks?.value?.message} label="Stocks (Annual)" - placeholder="0.00" + placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" {...register(`offers.${index}.job.stocks.value`, { - required: true, + min: { message: FieldError.NonNegativeNumber, value: 0 }, + required: FieldError.Required, valueAsNumber: true, })} /> @@ -208,7 +249,7 @@ function FullTimeOfferDetailsForm({ icon={TrashIcon} label="Delete" variant="secondary" - onClick={() => remove(index)} + onClick={() => setDialogOpen(true)} /> )}
@@ -216,125 +257,103 @@ function FullTimeOfferDetailsForm({ ); } -type OfferDetailsFormArrayProps = Readonly<{ - fieldArrayValues: UseFieldArrayReturn; - jobType: JobType; -}>; - -function OfferDetailsFormArray({ - fieldArrayValues, - jobType, -}: OfferDetailsFormArrayProps) { - const { append, remove, fields } = fieldArrayValues; - return ( -
- {fields.map((item, index) => - jobType === JobType.FullTime ? ( - - ) : ( - - ), - )} -
- ); -} - type InternshipOfferDetailsFormProps = Readonly<{ index: number; - remove: UseFieldArrayRemove; + setDialogOpen: (isOpen: boolean) => void; }>; function InternshipOfferDetailsForm({ index, - remove, + setDialogOpen, }: InternshipOfferDetailsFormProps) { - const { register } = useFormContext<{ - offers: Array; + const { register, formState } = useFormContext<{ + offers: Array; }>(); + const offerFields = formState.errors.offers?.[index]; + return (
-
-

Date received:

+
@@ -346,19 +365,21 @@ function InternshipOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.job.monthlySalary.currency`, { - required: true, + required: FieldError.Required, })} /> } endAddOnType="element" + errorMessage={offerFields?.job?.monthlySalary?.value?.message} label="Salary (Monthly)" - placeholder="0.00" + placeholder="0" required={true} startAddOn="$" startAddOnType="label" type="number" {...register(`offers.${index}.job.monthlySalary.value`, { - required: true, + min: { message: FieldError.NonNegativeNumber, value: 0 }, + required: FieldError.Required, valueAsNumber: true, })} /> @@ -383,7 +404,9 @@ function InternshipOfferDetailsForm({ icon={TrashIcon} label="Delete" variant="secondary" - onClick={() => remove(index)} + onClick={() => { + setDialogOpen(true); + }} /> )}
@@ -391,20 +414,97 @@ function InternshipOfferDetailsForm({ ); } +type OfferDetailsFormArrayProps = Readonly<{ + fieldArrayValues: UseFieldArrayReturn; + jobType: JobType; +}>; + +function OfferDetailsFormArray({ + fieldArrayValues, + jobType, +}: OfferDetailsFormArrayProps) { + const { append, remove, fields } = fieldArrayValues; + const [isDialogOpen, setDialogOpen] = useState(false); + + return ( +
+ {fields.map((item, index) => { + return ( +
+ {jobType === JobType.FullTime ? ( + + ) : ( + + )} + { + remove(index); + setDialogOpen(false); + }} + /> + } + secondaryButton={ + +
+ ); + })} +
+ ); +} + export default function OfferDetailsForm() { const [jobType, setJobType] = useState(JobType.FullTime); + const [isDialogOpen, setDialogOpen] = useState(false); const { control, register } = useFormContext(); const fieldArrayValues = useFieldArray({ control, name: 'offers' }); - const changeJobType = (jobTypeChosen: JobType) => () => { - if (jobType === jobTypeChosen) { - return; + const toggleJobType = () => { + if (jobType === JobType.FullTime) { + setJobType(JobType.Internship); + } else { + setJobType(JobType.FullTime); } - - setJobType(jobTypeChosen); fieldArrayValues.remove(); }; + const switchJobTypeLabel = () => + jobType === JobType.FullTime + ? JobTypeLabel.INTERNSHIP + : JobTypeLabel.FULLTIME; + return (
@@ -414,20 +514,30 @@ export default function OfferDetailsForm() {
@@ -436,6 +546,32 @@ export default function OfferDetailsForm() { fieldArrayValues={fieldArrayValues} jobType={jobType} /> + { + toggleJobType(); + setDialogOpen(false); + }} + /> + } + secondaryButton={ +
); } diff --git a/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx b/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx index 62967117..2b6c414a 100644 --- a/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx +++ b/apps/portal/src/components/offers/forms/components/FormMonthYearPicker.tsx @@ -1,4 +1,5 @@ import type { ComponentProps } from 'react'; +import { forwardRef } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import MonthYearPicker from '~/components/shared/MonthYearPicker'; @@ -14,7 +15,7 @@ type FormMonthYearPickerProps = Omit< name: string; }; -export default function FormMonthYearPicker({ +function FormMonthYearPickerWithRef({ name, ...rest }: FormMonthYearPickerProps) { @@ -35,3 +36,7 @@ export default function FormMonthYearPicker({ /> ); } + +const FormMonthYearPicker = forwardRef(FormMonthYearPickerWithRef); + +export default FormMonthYearPicker; diff --git a/apps/portal/src/components/offers/types.ts b/apps/portal/src/components/offers/types.ts index dfc46b6b..3ddf56b4 100644 --- a/apps/portal/src/components/offers/types.ts +++ b/apps/portal/src/components/offers/types.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-shadow */ import type { MonthYear } from '~/components/shared/MonthYearPicker'; /* @@ -10,6 +9,11 @@ export enum JobType { Internship = 'INTERNSHIP', } +export const JobTypeLabel = { + FULLTIME: 'Full-time', + INTERNSHIP: 'Internship', +}; + export enum EducationBackgroundType { Bachelor = 'Bachelor', Diploma = 'Diploma', @@ -43,16 +47,27 @@ type InternshipJobData = { title: string; }; -export type OfferDetailsFormData = { +type OfferDetailsGeneralData = { comments: string; companyId: string; - job: FullTimeJobData | InternshipJobData; jobType: string; location: string; monthYearReceived: MonthYear; negotiationStrategy: string; }; +export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & { + job: FullTimeJobData; +}; + +export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & { + job: InternshipJobData; +}; + +export type OfferDetailsFormData = + | FullTimeOfferDetailsFormData + | InternshipOfferDetailsFormData; + export type OfferDetailsPostData = Omit< OfferDetailsFormData, 'monthYearReceived' diff --git a/apps/portal/src/pages/offers/submit.tsx b/apps/portal/src/pages/offers/submit.tsx index 2ce5fd36..f0a46984 100644 --- a/apps/portal/src/pages/offers/submit.tsx +++ b/apps/portal/src/pages/offers/submit.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +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'; @@ -51,6 +51,9 @@ type FormStep = { export default function OffersSubmissionPage() { const [formStep, setFormStep] = useState(0); + const pageRef = useRef(null); + const scrollToTop = () => + pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); const formMethods = useForm({ defaultValues: defaultOfferValues, mode: 'all', @@ -94,9 +97,13 @@ export default function OffersSubmissionPage() { } } setFormStep(formStep + 1); + scrollToTop(); }; - const previousStep = () => setFormStep(formStep - 1); + const previousStep = () => { + setFormStep(formStep - 1); + scrollToTop(); + }; const createMutation = trpc.useMutation(['offers.profile.create'], { onError(error) { @@ -105,6 +112,7 @@ export default function OffersSubmissionPage() { onSuccess() { alert('offer profile submit success!'); setFormStep(formStep + 1); + scrollToTop(); }, }); @@ -135,7 +143,7 @@ export default function OffersSubmissionPage() { }; return ( -
+
diff --git a/packages/eslint-config-tih/index.js b/packages/eslint-config-tih/index.js index 8ee2fa69..3894879c 100644 --- a/packages/eslint-config-tih/index.js +++ b/packages/eslint-config-tih/index.js @@ -43,7 +43,7 @@ module.exports = { 'no-else-return': [ERROR, { allowElseIf: false }], 'no-extra-boolean-cast': ERROR, 'no-lonely-if': ERROR, - 'no-shadow': ERROR, + 'no-shadow': OFF, 'no-unused-vars': OFF, // Use @typescript-eslint/no-unused-vars instead. 'object-shorthand': ERROR, 'one-var': [ERROR, 'never'], @@ -100,6 +100,7 @@ module.exports = { '@typescript-eslint/no-for-in-array': ERROR, '@typescript-eslint/no-non-null-assertion': OFF, '@typescript-eslint/no-unused-vars': [ERROR, { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-shadow': ERROR, '@typescript-eslint/prefer-optional-chain': ERROR, '@typescript-eslint/require-array-sort-compare': ERROR, '@typescript-eslint/restrict-plus-operands': ERROR,