mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 12:43:12 +08:00
[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
This commit is contained in:
@ -7,7 +7,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
{stepLabels.map((label, index) => (
|
{stepLabels.map((label, index) => (
|
||||||
<>
|
<div key={label}>
|
||||||
{index === currentStep ? (
|
{index === currentStep ? (
|
||||||
<p className="text-sm text-purple-700">{label}</p>
|
<p className="text-sm text-purple-700">{label}</p>
|
||||||
) : (
|
) : (
|
||||||
@ -16,7 +16,7 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
|
|||||||
{index !== stepLabels.length - 1 && (
|
{index !== stepLabels.length - 1 && (
|
||||||
<p className="text-sm text-gray-400">{'>'}</p>
|
<p className="text-sm text-gray-400">{'>'}</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||||
|
|
||||||
const navigation: ProductNavigationItems = [
|
const navigation: ProductNavigationItems = [
|
||||||
{ href: '/offers', name: 'Home' },
|
|
||||||
{ href: '/offers/submit', name: 'Benchmark your offer' },
|
{ href: '/offers/submit', name: 'Benchmark your offer' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
import { EducationBackgroundType } from './types';
|
import { EducationBackgroundType } from './types';
|
||||||
|
|
||||||
const emptyOption = {
|
export const emptyOption = '----';
|
||||||
label: '----',
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: use enums
|
// TODO: use enums
|
||||||
export const titleOptions = [
|
export const titleOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: 'Software engineer',
|
label: 'Software engineer',
|
||||||
value: 'Software engineer',
|
value: 'Software engineer',
|
||||||
@ -27,7 +23,6 @@ export const titleOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const companyOptions = [
|
export const companyOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: 'Amazon',
|
label: 'Amazon',
|
||||||
value: 'cl93patjt0000txewdi601mub',
|
value: 'cl93patjt0000txewdi601mub',
|
||||||
@ -51,7 +46,6 @@ export const companyOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const locationOptions = [
|
export const locationOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: 'Singapore, Singapore',
|
label: 'Singapore, Singapore',
|
||||||
value: 'Singapore, Singapore',
|
value: 'Singapore, Singapore',
|
||||||
@ -67,7 +61,6 @@ export const locationOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const internshipCycleOptions = [
|
export const internshipCycleOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: 'Summer',
|
label: 'Summer',
|
||||||
value: 'Summer',
|
value: 'Summer',
|
||||||
@ -91,7 +84,6 @@ export const internshipCycleOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const yearOptions = [
|
export const yearOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: '2021',
|
label: '2021',
|
||||||
value: '2021',
|
value: '2021',
|
||||||
@ -110,17 +102,14 @@ export const yearOptions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const educationBackgroundTypes = Object.entries(EducationBackgroundType).map(
|
export const educationLevelOptions = Object.entries(
|
||||||
([key, value]) => ({
|
EducationBackgroundType,
|
||||||
label: key,
|
).map(([key, value]) => ({
|
||||||
value,
|
label: key,
|
||||||
}),
|
value,
|
||||||
);
|
}));
|
||||||
|
|
||||||
export const educationLevelOptions = [emptyOption, ...educationBackgroundTypes];
|
|
||||||
|
|
||||||
export const educationFieldOptions = [
|
export const educationFieldOptions = [
|
||||||
emptyOption,
|
|
||||||
{
|
{
|
||||||
label: 'Computer Science',
|
label: 'Computer Science',
|
||||||
value: 'Computer Science',
|
value: 'Computer Science',
|
||||||
@ -134,3 +123,9 @@ export const educationFieldOptions = [
|
|||||||
value: 'Business Analytics',
|
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 { useEffect, useState } from 'react';
|
||||||
import type {
|
import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form';
|
||||||
FieldValues,
|
import { useWatch } from 'react-hook-form';
|
||||||
UseFieldArrayRemove,
|
|
||||||
UseFieldArrayReturn,
|
|
||||||
} from 'react-hook-form';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useFieldArray } from 'react-hook-form';
|
import { useFieldArray } from 'react-hook-form';
|
||||||
import { PlusIcon } from '@heroicons/react/20/solid';
|
import { PlusIcon } from '@heroicons/react/20/solid';
|
||||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||||
import { Button } from '@tih/ui';
|
import { Button, Dialog } from '@tih/ui';
|
||||||
|
|
||||||
import FormMonthYearPicker from './components/FormMonthYearPicker';
|
import FormMonthYearPicker from './components/FormMonthYearPicker';
|
||||||
import FormSelect from './components/FormSelect';
|
import FormSelect from './components/FormSelect';
|
||||||
@ -16,74 +13,110 @@ import FormTextArea from './components/FormTextArea';
|
|||||||
import FormTextInput from './components/FormTextInput';
|
import FormTextInput from './components/FormTextInput';
|
||||||
import {
|
import {
|
||||||
companyOptions,
|
companyOptions,
|
||||||
|
emptyOption,
|
||||||
|
FieldError,
|
||||||
internshipCycleOptions,
|
internshipCycleOptions,
|
||||||
locationOptions,
|
locationOptions,
|
||||||
titleOptions,
|
titleOptions,
|
||||||
yearOptions,
|
yearOptions,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import type { OfferDetailsFormData } from '../types';
|
import type {
|
||||||
|
FullTimeOfferDetailsFormData,
|
||||||
|
InternshipOfferDetailsFormData,
|
||||||
|
} from '../types';
|
||||||
|
import { JobTypeLabel } from '../types';
|
||||||
import { JobType } from '../types';
|
import { JobType } from '../types';
|
||||||
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
|
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
|
||||||
|
|
||||||
type FullTimeOfferDetailsFormProps = Readonly<{
|
type FullTimeOfferDetailsFormProps = Readonly<{
|
||||||
index: number;
|
index: number;
|
||||||
remove: UseFieldArrayRemove;
|
setDialogOpen: (isOpen: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function FullTimeOfferDetailsForm({
|
function FullTimeOfferDetailsForm({
|
||||||
index,
|
index,
|
||||||
remove,
|
setDialogOpen,
|
||||||
}: FullTimeOfferDetailsFormProps) {
|
}: FullTimeOfferDetailsFormProps) {
|
||||||
const { register } = useFormContext<{
|
const { register, formState, setValue } = useFormContext<{
|
||||||
offers: Array<OfferDetailsFormData>;
|
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 (
|
return (
|
||||||
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
|
<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">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.job?.title?.message}
|
||||||
label="Title"
|
label="Title"
|
||||||
options={titleOptions}
|
options={titleOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.title`, {
|
{...register(`offers.${index}.job.title`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<FormTextInput
|
<FormTextInput
|
||||||
|
errorMessage={offerFields?.job?.specialization?.message}
|
||||||
label="Focus / Specialization"
|
label="Focus / Specialization"
|
||||||
placeholder="e.g. Front End"
|
placeholder="e.g. Front End"
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.specialization`, {
|
{...register(`offers.${index}.job.specialization`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.companyId?.message}
|
||||||
label="Company"
|
label="Company"
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.companyId`, { required: true })}
|
{...register(`offers.${index}.companyId`, {
|
||||||
|
required: FieldError.Required,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<FormTextInput
|
<FormTextInput
|
||||||
|
errorMessage={offerFields?.job?.level?.message}
|
||||||
label="Level"
|
label="Level"
|
||||||
placeholder="e.g. L4, Junior"
|
placeholder="e.g. L4, Junior"
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.level`, { required: true })}
|
{...register(`offers.${index}.job.level`, {
|
||||||
|
required: FieldError.Required,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.location?.message}
|
||||||
label="Location"
|
label="Location"
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.location`, { required: true })}
|
{...register(`offers.${index}.location`, {
|
||||||
|
required: FieldError.Required,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<FormMonthYearPicker
|
<FormMonthYearPicker
|
||||||
{...register(`offers.${index}.monthYearReceived`, { required: true })}
|
monthLabel="Date Received"
|
||||||
|
monthRequired={true}
|
||||||
|
yearLabel=""
|
||||||
|
{...register(`offers.${index}.monthYearReceived`, {
|
||||||
|
required: FieldError.Required,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@ -95,19 +128,21 @@ function FullTimeOfferDetailsForm({
|
|||||||
label="Currency"
|
label="Currency"
|
||||||
options={CURRENCY_OPTIONS}
|
options={CURRENCY_OPTIONS}
|
||||||
{...register(`offers.${index}.job.totalCompensation.currency`, {
|
{...register(`offers.${index}.job.totalCompensation.currency`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endAddOnType="element"
|
endAddOnType="element"
|
||||||
|
errorMessage={offerFields?.job?.totalCompensation?.value?.message}
|
||||||
label="Total Compensation (Annual)"
|
label="Total Compensation (Annual)"
|
||||||
placeholder="0.00"
|
placeholder="0"
|
||||||
required={true}
|
required={true}
|
||||||
startAddOn="$"
|
startAddOn="$"
|
||||||
startAddOnType="label"
|
startAddOnType="label"
|
||||||
type="number"
|
type="number"
|
||||||
{...register(`offers.${index}.job.totalCompensation.value`, {
|
{...register(`offers.${index}.job.totalCompensation.value`, {
|
||||||
required: true,
|
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||||
|
required: FieldError.Required,
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@ -121,19 +156,21 @@ function FullTimeOfferDetailsForm({
|
|||||||
label="Currency"
|
label="Currency"
|
||||||
options={CURRENCY_OPTIONS}
|
options={CURRENCY_OPTIONS}
|
||||||
{...register(`offers.${index}.job.base.currency`, {
|
{...register(`offers.${index}.job.base.currency`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endAddOnType="element"
|
endAddOnType="element"
|
||||||
|
errorMessage={offerFields?.job?.base?.value?.message}
|
||||||
label="Base Salary (Annual)"
|
label="Base Salary (Annual)"
|
||||||
placeholder="0.00"
|
placeholder="0"
|
||||||
required={true}
|
required={true}
|
||||||
startAddOn="$"
|
startAddOn="$"
|
||||||
startAddOnType="label"
|
startAddOnType="label"
|
||||||
type="number"
|
type="number"
|
||||||
{...register(`offers.${index}.job.base.value`, {
|
{...register(`offers.${index}.job.base.value`, {
|
||||||
required: true,
|
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||||
|
required: FieldError.Required,
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@ -145,19 +182,21 @@ function FullTimeOfferDetailsForm({
|
|||||||
label="Currency"
|
label="Currency"
|
||||||
options={CURRENCY_OPTIONS}
|
options={CURRENCY_OPTIONS}
|
||||||
{...register(`offers.${index}.job.bonus.currency`, {
|
{...register(`offers.${index}.job.bonus.currency`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endAddOnType="element"
|
endAddOnType="element"
|
||||||
|
errorMessage={offerFields?.job?.bonus?.value?.message}
|
||||||
label="Bonus (Annual)"
|
label="Bonus (Annual)"
|
||||||
placeholder="0.00"
|
placeholder="0"
|
||||||
required={true}
|
required={true}
|
||||||
startAddOn="$"
|
startAddOn="$"
|
||||||
startAddOnType="label"
|
startAddOnType="label"
|
||||||
type="number"
|
type="number"
|
||||||
{...register(`offers.${index}.job.bonus.value`, {
|
{...register(`offers.${index}.job.bonus.value`, {
|
||||||
required: true,
|
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||||
|
required: FieldError.Required,
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@ -171,19 +210,21 @@ function FullTimeOfferDetailsForm({
|
|||||||
label="Currency"
|
label="Currency"
|
||||||
options={CURRENCY_OPTIONS}
|
options={CURRENCY_OPTIONS}
|
||||||
{...register(`offers.${index}.job.stocks.currency`, {
|
{...register(`offers.${index}.job.stocks.currency`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endAddOnType="element"
|
endAddOnType="element"
|
||||||
|
errorMessage={offerFields?.job?.stocks?.value?.message}
|
||||||
label="Stocks (Annual)"
|
label="Stocks (Annual)"
|
||||||
placeholder="0.00"
|
placeholder="0"
|
||||||
required={true}
|
required={true}
|
||||||
startAddOn="$"
|
startAddOn="$"
|
||||||
startAddOnType="label"
|
startAddOnType="label"
|
||||||
type="number"
|
type="number"
|
||||||
{...register(`offers.${index}.job.stocks.value`, {
|
{...register(`offers.${index}.job.stocks.value`, {
|
||||||
required: true,
|
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||||
|
required: FieldError.Required,
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@ -208,7 +249,7 @@ function FullTimeOfferDetailsForm({
|
|||||||
icon={TrashIcon}
|
icon={TrashIcon}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => remove(index)}
|
onClick={() => setDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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<{
|
type InternshipOfferDetailsFormProps = Readonly<{
|
||||||
index: number;
|
index: number;
|
||||||
remove: UseFieldArrayRemove;
|
setDialogOpen: (isOpen: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function InternshipOfferDetailsForm({
|
function InternshipOfferDetailsForm({
|
||||||
index,
|
index,
|
||||||
remove,
|
setDialogOpen,
|
||||||
}: InternshipOfferDetailsFormProps) {
|
}: InternshipOfferDetailsFormProps) {
|
||||||
const { register } = useFormContext<{
|
const { register, formState } = useFormContext<{
|
||||||
offers: Array<OfferDetailsFormData>;
|
offers: Array<InternshipOfferDetailsFormData>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const offerFields = formState.errors.offers?.[index];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
|
<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">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.job?.title?.message}
|
||||||
label="Title"
|
label="Title"
|
||||||
options={titleOptions}
|
options={titleOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.title`, {
|
{...register(`offers.${index}.job.title`, {
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<FormTextInput
|
<FormTextInput
|
||||||
|
errorMessage={offerFields?.job?.specialization?.message}
|
||||||
label="Focus / Specialization"
|
label="Focus / Specialization"
|
||||||
placeholder="e.g. Front End"
|
placeholder="e.g. Front End"
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.specialization`, {
|
{...register(`offers.${index}.job.specialization`, {
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.companyId?.message}
|
||||||
label="Company"
|
label="Company"
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.companyId`, {
|
{...register(`offers.${index}.companyId`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.location?.message}
|
||||||
label="Location"
|
label="Location"
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.location`, {
|
{...register(`offers.${index}.location`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 grid grid-cols-2 space-x-3">
|
<div className="mb-5 grid grid-cols-2 space-x-3">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.job?.internshipCycle?.message}
|
||||||
label="Internship Cycle"
|
label="Internship Cycle"
|
||||||
options={internshipCycleOptions}
|
options={internshipCycleOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.internshipCycle`, {
|
{...register(`offers.${index}.job.internshipCycle`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
display="block"
|
display="block"
|
||||||
|
errorMessage={offerFields?.job?.startYear?.message}
|
||||||
label="Internship Year"
|
label="Internship Year"
|
||||||
options={yearOptions}
|
options={yearOptions}
|
||||||
|
placeholder={emptyOption}
|
||||||
required={true}
|
required={true}
|
||||||
{...register(`offers.${index}.job.startYear`, {
|
{...register(`offers.${index}.job.startYear`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5 flex items-center space-x-9">
|
<div className="mb-5">
|
||||||
<p className="text-sm">Date received:</p>
|
|
||||||
<FormMonthYearPicker
|
<FormMonthYearPicker
|
||||||
{...register(`offers.${index}.monthYearReceived`, { required: true })}
|
monthLabel="Date Received"
|
||||||
|
monthRequired={true}
|
||||||
|
yearLabel=""
|
||||||
|
{...register(`offers.${index}.monthYearReceived`, {
|
||||||
|
required: FieldError.Required,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@ -346,19 +365,21 @@ function InternshipOfferDetailsForm({
|
|||||||
label="Currency"
|
label="Currency"
|
||||||
options={CURRENCY_OPTIONS}
|
options={CURRENCY_OPTIONS}
|
||||||
{...register(`offers.${index}.job.monthlySalary.currency`, {
|
{...register(`offers.${index}.job.monthlySalary.currency`, {
|
||||||
required: true,
|
required: FieldError.Required,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endAddOnType="element"
|
endAddOnType="element"
|
||||||
|
errorMessage={offerFields?.job?.monthlySalary?.value?.message}
|
||||||
label="Salary (Monthly)"
|
label="Salary (Monthly)"
|
||||||
placeholder="0.00"
|
placeholder="0"
|
||||||
required={true}
|
required={true}
|
||||||
startAddOn="$"
|
startAddOn="$"
|
||||||
startAddOnType="label"
|
startAddOnType="label"
|
||||||
type="number"
|
type="number"
|
||||||
{...register(`offers.${index}.job.monthlySalary.value`, {
|
{...register(`offers.${index}.job.monthlySalary.value`, {
|
||||||
required: true,
|
min: { message: FieldError.NonNegativeNumber, value: 0 },
|
||||||
|
required: FieldError.Required,
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@ -383,7 +404,9 @@ function InternshipOfferDetailsForm({
|
|||||||
icon={TrashIcon}
|
icon={TrashIcon}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => remove(index)}
|
onClick={() => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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() {
|
export default function OfferDetailsForm() {
|
||||||
const [jobType, setJobType] = useState(JobType.FullTime);
|
const [jobType, setJobType] = useState(JobType.FullTime);
|
||||||
|
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||||
const { control, register } = useFormContext();
|
const { control, register } = useFormContext();
|
||||||
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
|
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
|
||||||
|
|
||||||
const changeJobType = (jobTypeChosen: JobType) => () => {
|
const toggleJobType = () => {
|
||||||
if (jobType === jobTypeChosen) {
|
if (jobType === JobType.FullTime) {
|
||||||
return;
|
setJobType(JobType.Internship);
|
||||||
|
} else {
|
||||||
|
setJobType(JobType.FullTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
setJobType(jobTypeChosen);
|
|
||||||
fieldArrayValues.remove();
|
fieldArrayValues.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const switchJobTypeLabel = () =>
|
||||||
|
jobType === JobType.FullTime
|
||||||
|
? JobTypeLabel.INTERNSHIP
|
||||||
|
: JobTypeLabel.FULLTIME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h5 className="mb-8 text-center text-4xl font-bold text-gray-900">
|
<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">
|
<div className="mx-5 w-1/3">
|
||||||
<Button
|
<Button
|
||||||
display="block"
|
display="block"
|
||||||
label="Full-time"
|
label={JobTypeLabel.FULLTIME}
|
||||||
size="md"
|
size="md"
|
||||||
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
|
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
|
||||||
onClick={changeJobType(JobType.FullTime)}
|
onClick={() => {
|
||||||
|
if (jobType === JobType.FullTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
{...register(`offers.${0}.jobType`)}
|
{...register(`offers.${0}.jobType`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-5 w-1/3">
|
<div className="mx-5 w-1/3">
|
||||||
<Button
|
<Button
|
||||||
display="block"
|
display="block"
|
||||||
label="Internship"
|
label={JobTypeLabel.INTERNSHIP}
|
||||||
size="md"
|
size="md"
|
||||||
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
|
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
|
||||||
onClick={changeJobType(JobType.Internship)}
|
onClick={() => {
|
||||||
|
if (jobType === JobType.Internship) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
{...register(`offers.${0}.jobType`)}
|
{...register(`offers.${0}.jobType`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -436,6 +546,32 @@ export default function OfferDetailsForm() {
|
|||||||
fieldArrayValues={fieldArrayValues}
|
fieldArrayValues={fieldArrayValues}
|
||||||
jobType={jobType}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
import { useFormContext, useWatch } from 'react-hook-form';
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
import MonthYearPicker from '~/components/shared/MonthYearPicker';
|
import MonthYearPicker from '~/components/shared/MonthYearPicker';
|
||||||
@ -14,7 +15,7 @@ type FormMonthYearPickerProps = Omit<
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FormMonthYearPicker({
|
function FormMonthYearPickerWithRef({
|
||||||
name,
|
name,
|
||||||
...rest
|
...rest
|
||||||
}: FormMonthYearPickerProps) {
|
}: 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';
|
import type { MonthYear } from '~/components/shared/MonthYearPicker';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -10,6 +9,11 @@ export enum JobType {
|
|||||||
Internship = 'INTERNSHIP',
|
Internship = 'INTERNSHIP',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const JobTypeLabel = {
|
||||||
|
FULLTIME: 'Full-time',
|
||||||
|
INTERNSHIP: 'Internship',
|
||||||
|
};
|
||||||
|
|
||||||
export enum EducationBackgroundType {
|
export enum EducationBackgroundType {
|
||||||
Bachelor = 'Bachelor',
|
Bachelor = 'Bachelor',
|
||||||
Diploma = 'Diploma',
|
Diploma = 'Diploma',
|
||||||
@ -43,16 +47,27 @@ type InternshipJobData = {
|
|||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OfferDetailsFormData = {
|
type OfferDetailsGeneralData = {
|
||||||
comments: string;
|
comments: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
job: FullTimeJobData | InternshipJobData;
|
|
||||||
jobType: string;
|
jobType: string;
|
||||||
location: string;
|
location: string;
|
||||||
monthYearReceived: MonthYear;
|
monthYearReceived: MonthYear;
|
||||||
negotiationStrategy: string;
|
negotiationStrategy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
|
||||||
|
job: FullTimeJobData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
|
||||||
|
job: InternshipJobData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfferDetailsFormData =
|
||||||
|
| FullTimeOfferDetailsFormData
|
||||||
|
| InternshipOfferDetailsFormData;
|
||||||
|
|
||||||
export type OfferDetailsPostData = Omit<
|
export type OfferDetailsPostData = Omit<
|
||||||
OfferDetailsFormData,
|
OfferDetailsFormData,
|
||||||
'monthYearReceived'
|
'monthYearReceived'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { SubmitHandler } from 'react-hook-form';
|
import type { SubmitHandler } from 'react-hook-form';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
||||||
@ -51,6 +51,9 @@ type FormStep = {
|
|||||||
|
|
||||||
export default function OffersSubmissionPage() {
|
export default function OffersSubmissionPage() {
|
||||||
const [formStep, setFormStep] = useState(0);
|
const [formStep, setFormStep] = useState(0);
|
||||||
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollToTop = () =>
|
||||||
|
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
|
||||||
const formMethods = useForm<OfferProfileFormData>({
|
const formMethods = useForm<OfferProfileFormData>({
|
||||||
defaultValues: defaultOfferValues,
|
defaultValues: defaultOfferValues,
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
@ -94,9 +97,13 @@ export default function OffersSubmissionPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFormStep(formStep + 1);
|
setFormStep(formStep + 1);
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousStep = () => setFormStep(formStep - 1);
|
const previousStep = () => {
|
||||||
|
setFormStep(formStep - 1);
|
||||||
|
scrollToTop();
|
||||||
|
};
|
||||||
|
|
||||||
const createMutation = trpc.useMutation(['offers.profile.create'], {
|
const createMutation = trpc.useMutation(['offers.profile.create'], {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@ -105,6 +112,7 @@ export default function OffersSubmissionPage() {
|
|||||||
onSuccess() {
|
onSuccess() {
|
||||||
alert('offer profile submit success!');
|
alert('offer profile submit success!');
|
||||||
setFormStep(formStep + 1);
|
setFormStep(formStep + 1);
|
||||||
|
scrollToTop();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -135,7 +143,7 @@ export default function OffersSubmissionPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<div className="mb-4 flex justify-end">
|
||||||
|
@ -43,7 +43,7 @@ module.exports = {
|
|||||||
'no-else-return': [ERROR, { allowElseIf: false }],
|
'no-else-return': [ERROR, { allowElseIf: false }],
|
||||||
'no-extra-boolean-cast': ERROR,
|
'no-extra-boolean-cast': ERROR,
|
||||||
'no-lonely-if': ERROR,
|
'no-lonely-if': ERROR,
|
||||||
'no-shadow': ERROR,
|
'no-shadow': OFF,
|
||||||
'no-unused-vars': OFF, // Use @typescript-eslint/no-unused-vars instead.
|
'no-unused-vars': OFF, // Use @typescript-eslint/no-unused-vars instead.
|
||||||
'object-shorthand': ERROR,
|
'object-shorthand': ERROR,
|
||||||
'one-var': [ERROR, 'never'],
|
'one-var': [ERROR, 'never'],
|
||||||
@ -100,6 +100,7 @@ module.exports = {
|
|||||||
'@typescript-eslint/no-for-in-array': ERROR,
|
'@typescript-eslint/no-for-in-array': ERROR,
|
||||||
'@typescript-eslint/no-non-null-assertion': OFF,
|
'@typescript-eslint/no-non-null-assertion': OFF,
|
||||||
'@typescript-eslint/no-unused-vars': [ERROR, { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': [ERROR, { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-shadow': ERROR,
|
||||||
'@typescript-eslint/prefer-optional-chain': ERROR,
|
'@typescript-eslint/prefer-optional-chain': ERROR,
|
||||||
'@typescript-eslint/require-array-sort-compare': ERROR,
|
'@typescript-eslint/require-array-sort-compare': ERROR,
|
||||||
'@typescript-eslint/restrict-plus-operands': ERROR,
|
'@typescript-eslint/restrict-plus-operands': ERROR,
|
||||||
|
Reference in New Issue
Block a user