[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
pull/372/head
Ai Ling 2 years ago committed by GitHub
parent 596a555d78
commit f179c4ef1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,7 +7,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
return (
<div className="flex space-x-1">
{stepLabels.map((label, index) => (
<>
<div key={label}>
{index === currentStep ? (
<p className="text-sm text-purple-700">{label}</p>
) : (
@ -16,7 +16,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
{index !== stepLabels.length - 1 && (
<p className="text-sm text-gray-400">{'>'}</p>
)}
</>
</div>
))}
</div>
);

@ -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' },
];

@ -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.',
}

@ -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<OfferDetailsFormData>;
const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>;
}>();
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 (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
required: true,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
required: true,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, { required: true })}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, { required: true })}
{...register(`offers.${index}.job.level`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
label="Location"
options={locationOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.location`, { required: true })}
{...register(`offers.${index}.location`, {
required: FieldError.Required,
})}
/>
<FormMonthYearPicker
{...register(`offers.${index}.monthYearReceived`, { required: true })}
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5">
@ -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)}
/>
)}
</div>
@ -216,125 +257,103 @@ function FullTimeOfferDetailsForm({
);
}
type OfferDetailsFormArrayProps = Readonly<{
fieldArrayValues: UseFieldArrayReturn<FieldValues, 'offers', 'id'>;
jobType: JobType;
}>;
function OfferDetailsFormArray({
fieldArrayValues,
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
return (
<div>
{fields.map((item, index) =>
jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
) : (
<InternshipOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
),
)}
<Button
display="block"
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
onClick={() => append({})}
/>
</div>
);
}
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
remove: UseFieldArrayRemove;
setDialogOpen: (isOpen: boolean) => void;
}>;
function InternshipOfferDetailsForm({
index,
remove,
setDialogOpen,
}: InternshipOfferDetailsFormProps) {
const { register } = useFormContext<{
offers: Array<OfferDetailsFormData>;
const { register, formState } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
minLength: 1,
required: true,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
minLength: 1,
required: true,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: true,
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
label="Location"
options={locationOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.location`, {
required: true,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.internshipCycle`, {
required: true,
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.job?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.startYear`, {
required: true,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 flex items-center space-x-9">
<p className="text-sm">Date received:</p>
<div className="mb-5">
<FormMonthYearPicker
{...register(`offers.${index}.monthYearReceived`, { required: true })}
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5">
@ -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);
}}
/>
)}
</div>
@ -391,20 +414,97 @@ function InternshipOfferDetailsForm({
);
}
type OfferDetailsFormArrayProps = Readonly<{
fieldArrayValues: UseFieldArrayReturn<FieldValues, 'offers', 'id'>;
jobType: JobType;
}>;
function OfferDetailsFormArray({
fieldArrayValues,
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
const [isDialogOpen, setDialogOpen] = useState(false);
return (
<div>
{fields.map((item, index) => {
return (
<div key={item.id}>
{jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
) : (
<InternshipOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
)}
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => {
remove(index);
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title="Remove this offer"
onClose={() => setDialogOpen(false)}>
<p>
Are you sure you want to remove this offer? This action cannot
be reversed.
</p>
</Dialog>
</div>
);
})}
<Button
display="block"
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
onClick={() => append({})}
/>
</div>
);
}
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 (
<div className="mb-5">
<h5 className="mb-8 text-center text-4xl font-bold text-gray-900">
@ -414,20 +514,30 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3">
<Button
display="block"
label="Full-time"
label={JobTypeLabel.FULLTIME}
size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.FullTime)}
onClick={() => {
if (jobType === JobType.FullTime) {
return;
}
setDialogOpen(true);
}}
{...register(`offers.${0}.jobType`)}
/>
</div>
<div className="mx-5 w-1/3">
<Button
display="block"
label="Internship"
label={JobTypeLabel.INTERNSHIP}
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.Internship)}
onClick={() => {
if (jobType === JobType.Internship) {
return;
}
setDialogOpen(true);
}}
{...register(`offers.${0}.jobType`)}
/>
</div>
@ -436,6 +546,32 @@ export default function OfferDetailsForm() {
fieldArrayValues={fieldArrayValues}
jobType={jobType}
/>
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Switch"
variant="primary"
onClick={() => {
toggleJobType();
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title={`Switch to ${switchJobTypeLabel()}`}
onClose={() => setDialogOpen(false)}>
{`Are you sure you want to switch to ${switchJobTypeLabel()}? The data you
entered in the ${JobTypeLabel[jobType]} section will disappear.`}
</Dialog>
</div>
);
}

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

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

@ -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<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OfferProfileFormData>({
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 (
<div className="fixed h-full w-full overflow-y-scroll">
<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">

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

Loading…
Cancel
Save