Skip to content

Commit

Permalink
[FE][CPF-98] Add people - ladder step (#99)
Browse files Browse the repository at this point in the history
* feat: main ladder basic

* feat: ladder step

* feat: refactor and id support
  • Loading branch information
r1skz3ro authored Jul 16, 2024
1 parent baff627 commit 489a847
Show file tree
Hide file tree
Showing 28 changed files with 393 additions and 62 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EmployeeSideStepper } from '@app/components/modules/EmployeeSideStepper';
import { WorkflowTopbar } from '@app/components/modules/WorkflowTopbar';
import { AddEmployeeFormProvider } from '@app/components/pages/addEmployee/AddEmployeeFormProvider';

export default function PeopleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
Expand All @@ -9,7 +10,9 @@ export default function PeopleLayout({ children }: Readonly<{ children: React.Re
<main className="p-8">
<div className="grid grid-cols-workflow">
<EmployeeSideStepper />
<div className="col-span-1 px-8">{children}</div>
<div className="col-span-1 px-8">
<AddEmployeeFormProvider>{children}</AddEmployeeFormProvider>
</div>
</div>
</main>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { MainLadder } from '@app/components/pages/addEmployee/MainLadder';

export default MainLadder;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { PersonalDetails } from '@app/components/pages/addEmployee/PersonalDetails';

export default PersonalDetails;

This file was deleted.

1 change: 1 addition & 0 deletions frontend/src/components/common/Button/Button.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variants;
disabled?: boolean;
className?: string;
type?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
}

export type StyleTypes = 'primary' | 'natural' | 'warning';
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@ const types: {
border:
'bg-transparent text-blue-800 border border-blue-800 hover:bg-navy-100 active:text-blue-900 active:border-blue-900',
borderless: 'px-2 bg-transparent text-blue-800 hover:bg-navy-100 active:text-blue-900',
link: 'px-0 bg-transparent text-blue-800 hover:underline hover:text-blue-900 active:text-blue-900 active:no-underline',
link: 'px-0 h-auto bg-transparent text-blue-800 hover:underline hover:text-blue-900 active:text-blue-900 active:no-underline',
},
natural: {
solid: 'bg-navy-600 text-white hover:bg-navy-700',
border: 'bg-transparent text-navy-600 border border-navy-300 hover:bg-navy-100',
borderless: 'px-2 bg-transparent text-navy-600 hover:bg-navy-50 active:bg-navy-100',
link: 'px-0 bg-transparent text-navy-600 hover:underline hover:text-navy-700 active:no-underline',
link: 'px-0 h-auto bg-transparent text-navy-600 hover:underline hover:text-navy-700 active:no-underline',
},
warning: {
solid: 'bg-red-600 text-white hover:bg-red-700',
border: 'bg-transparent text-red-600 border border-red-600 hover:bg-navy-100 hover:text-red-700',
borderless:
'px-2 bg-transparent text-red-600 hover:bg-navy-50 hover:border-[1.5px] hover:border-red-700 hover:text-red-700',
link: 'px-0 bg-transparent text-red-600 hover:underline hover:text-red-700 active:no-underline',
link: 'px-0 h-auto bg-transparent text-red-600 hover:underline hover:text-red-700 active:no-underline',
},
};

Expand All @@ -39,6 +39,7 @@ const disabledStyles = {
export const Button: FC<ButtonProps> = ({
styleType = 'primary',
variant = 'solid',
type = 'button',
disabled = false,
className,
children,
Expand All @@ -54,7 +55,7 @@ export const Button: FC<ButtonProps> = ({
);

return (
<button className={buttonClass} disabled={disabled} {...props}>
<button className={buttonClass} type={type} disabled={disabled} {...props}>
{children}
</button>
);
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/common/Combobox/Combobox.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ReactNode } from 'react';

export interface Option {
id: string;
name: string;
}

export interface ComboboxProps {
label?: string;
options: Option[];
name: string;
renderRightContent?: () => ReactNode;
}
76 changes: 76 additions & 0 deletions frontend/src/components/common/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import { ChevronRightIcon } from '@app/static/icons/ChevronRightIcon';
import {
Combobox as HeadlessCombobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Label,
} from '@headlessui/react';
import { useMemo, useState } from 'react';
import { ComboboxProps, Option } from './Combobox.interface';
import { Controller, useFormContext } from 'react-hook-form';

export const Combobox: React.FC<ComboboxProps> = ({ label, options, name, renderRightContent }) => {
const [query, setQuery] = useState('');
const { control } = useFormContext();

const filteredOptions = useMemo(
() =>
options
.sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()))
.filter((option) => option.name.toLowerCase().includes(query.toLowerCase())),
[options, query],
);

return (
<Controller
control={control}
name={name}
render={({ field: { onChange, value } }) => (
<HeadlessCombobox
as="div"
immediate
value={value}
onChange={(person) => {
setQuery('');
onChange(person);
}}
className="flex flex-1 flex-col gap-y-2"
>
{label && <Label className="font-semibold text-navy-900">{label}</Label>}
<div className="flex flex-1 items-center">
<div className="relative w-full">
<ComboboxInput
className="w-full rounded-xl border-0 bg-white px-4 py-3 text-navy-600 shadow-sm outline-none ring-1 ring-inset ring-navy-200 focus:ring-1 focus:ring-inset focus:ring-navy-700"
onChange={(event) => setQuery(event.target.value)}
onBlur={() => setQuery('')}
displayValue={(item: Option) => item?.name}
/>
<ComboboxButton className="absolute inset-y-0 right-0 px-4">
<ChevronRightIcon className="text-gray-400 h-4 w-4 rotate-90 text-navy-600" aria-hidden="true" />
</ComboboxButton>

{filteredOptions?.length > 0 && (
<ComboboxOptions className="ring-black absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-opacity-5 focus:outline-none sm:text-sm">
{filteredOptions.map((option) => (
<ComboboxOption
key={option.id}
value={option}
className="relative cursor-default select-none py-2 pl-3 pr-9 text-navy-900 data-[focus]:cursor-pointer data-[focus]:bg-navy-200 data-[focus]:font-medium"
>
<span className="block truncate">{option.name}</span>
</ComboboxOption>
))}
</ComboboxOptions>
)}
</div>
{renderRightContent?.()}
</div>
</HeadlessCombobox>
)}
/>
);
};
3 changes: 3 additions & 0 deletions frontend/src/components/common/Combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Combobox } from './Combobox';

export type { Option } from './Combobox.interface';
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { Step } from '../SideStepper';
import { AddNewPersonRouteKeys } from './EmployeeSideStepper.interface';

export const addEmployeeInitialSteps: Step<AddNewPersonRouteKeys>[] = [
{ label: '1. Personal details', state: 'notStarted', current: true, href: routes.people.addNew.personalDetails },
{ label: '2. Main ladder', state: 'notStarted', current: false, href: routes.people.addNew.mainLadder },
{ label: '1. Personal details', state: 'notStarted', active: true, href: routes.people.addNew.personalDetails },
{ label: '2. Main ladder', state: 'notStarted', active: false, href: routes.people.addNew.mainLadder },
];
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ export const EmployeeSideStepper = () => {
const pathname = usePathname();
const [addEmployeeSteps, setAddEmployeeSteps] = useState(addEmployeeInitialSteps);

// INFO: If current step is not 'completed', then set 'inProgress' state to the current path
useEffect(() => {
setAddEmployeeSteps((prevState) =>
prevState.map((step) => {
const active = pathname.endsWith(step.href);
// INFO: If current step is not 'completed', then set 'inProgress' state to the current path
const state: StepStates =
progress[step.href] !== 'completed' && pathname.endsWith(step.href) ? 'inProgress' : progress[step.href];

return {
...step,
state: state,
state,
active,
};
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type StepStates = 'completed' | 'inProgress' | 'notStarted';
export interface Step<T> {
label: string;
state: StepStates;
current: boolean;
active: boolean;
href: T;
}

Expand Down
28 changes: 15 additions & 13 deletions frontend/src/components/modules/SideStepper/SideStepper.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,34 @@
import { generateClassNames } from '@app/utils';
import { SideStepperProps, StepStates } from './SideStepper.interface';
import { SideStepperProps } from './SideStepper.interface';
import { Fragment } from 'react';
import { Typography } from '@app/components/common/Typography';
import { Completed, InProgress, NotStarted } from './SideStepper.utils';

const stepsMap: { [key in StepStates]: JSX.Element } = {
completed: <Completed />,
inProgress: <InProgress />,
notStarted: <NotStarted />,
};
import { stepComponentsMap } from './SideStepper.utils';
import { Button } from '@app/components/common/Button';
import { useRouter } from 'next/navigation';

export const SideStepper = <T extends string>({ steps }: SideStepperProps<T>) => {
const router = useRouter();
return (
<div className="flex">
<div className="flex flex-col">
{steps.map(({ label, state, current }, i) => {
{steps.map(({ label, state, active, href }, i) => {
const last = i === steps.length - 1;
return (
<Fragment key={label}>
<div className="flex items-center gap-x-3">
{stepsMap[state]}
<Button
className="flex items-center justify-start gap-x-3"
variant="link"
disabled={state === 'notStarted'}
onClick={() => router.push(href)}
>
{stepComponentsMap[state]}
<Typography
variant="body-m/semibold"
className={generateClassNames(current ? 'text-navy-900' : 'text-navy-600')}
className={generateClassNames(active ? 'text-navy-900' : 'text-navy-600')}
>
{label}
</Typography>
</div>
</Button>
{!last && (
<div className="flex w-7 justify-center py-2">
<div className="flex h-[16px] w-[1.5px] border border-navy-300" />
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/modules/SideStepper/SideStepper.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { CheckMarkIcon } from '@app/static/icons/CheckMarkIcon';
import { StepStates } from './SideStepper.interface';

export const Completed = () => {
const Completed = () => {
return (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-800">
<CheckMarkIcon />
</div>
);
};
export const InProgress = () => {
const InProgress = () => {
return (
<div className="flex h-7 w-7 items-center justify-center rounded-full border border-blue-800 bg-white">
<div className="h-[10px] w-[10px] rounded-full bg-blue-800" />
</div>
);
};
export const NotStarted = () => {
const NotStarted = () => {
return <div className="flex h-7 w-7 items-center justify-center rounded-full border border-navy-300 bg-white" />;
};

export const stepComponentsMap: { [key in StepStates]: JSX.Element } = {
completed: <Completed />,
inProgress: <InProgress />,
notStarted: <NotStarted />,
};
1 change: 1 addition & 0 deletions frontend/src/components/modules/SideStepper/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { SideStepper } from './SideStepper';
export { stepComponentsMap } from './SideStepper.utils';
export type { SideStepperProps, Step, StepStates } from './SideStepper.interface';

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Option } from '@app/components/common/Combobox';

export const addEmployeeFormNames = {
firstName: 'firstName',
lastName: 'lastName',
email: 'email',
ladder: 'ladder',
technology: 'technology',
} as const;

export interface AddEmployeeForm {
[addEmployeeFormNames.firstName]: string;
[addEmployeeFormNames.lastName]: string;
[addEmployeeFormNames.email]: string;
[addEmployeeFormNames.ladder]: Option;
[addEmployeeFormNames.technology]: Option[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FC, PropsWithChildren } from 'react';
import { useForm } from 'react-hook-form';

import { FormProvider } from '@app/components/common/FormProvider';
import { AddEmployeeForm, addEmployeeFormNames } from './AddEmployeeForm.interface';

export const AddEmployeeFormProvider: FC<PropsWithChildren> = ({ children }) => {
const form = useForm<AddEmployeeForm>({
mode: 'onChange',
defaultValues: {
[addEmployeeFormNames.firstName]: '',
[addEmployeeFormNames.lastName]: '',
[addEmployeeFormNames.email]: '',
[addEmployeeFormNames.ladder]: {},
[addEmployeeFormNames.technology]: [],
},
});
return <FormProvider<AddEmployeeForm> form={form}>{children}</FormProvider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AddEmployeeFormProvider } from './AddEmployeeForm';
export type { AddEmployeeForm } from './AddEmployeeForm.interface';
export { addEmployeeFormNames } from './AddEmployeeForm.interface';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useEffect, useState } from 'react';
import { AddEmployeeForm, addEmployeeFormNames } from '../AddEmployeeFormProvider';
import { usePeopleStore } from '@app/store/people';
import { routes } from '@app/constants';

export const useMainLadder = () => {
const form = useFormContext<AddEmployeeForm>();
const updateProgress = usePeopleStore((state) => state.updateProgress);

const technologyFields = useFieldArray({
name: addEmployeeFormNames.technology,
control: form.control,
});

const [open, setOpen] = useState(true);
const [formValid, setFormValid] = useState(false);

const handleSubmit = form.handleSubmit((data) => console.log('data', data));
const values = form.watch();
const ladderSelected = values?.[addEmployeeFormNames.ladder]?.name?.length > 0;
const firstTechnology = values?.[addEmployeeFormNames.technology]?.[0];

useEffect(() => {
const technologySelected = firstTechnology && firstTechnology.name?.length > 0;

setFormValid(ladderSelected && technologySelected);
}, [values, ladderSelected, firstTechnology]);

// INFO: update progress in sidebar stepper
useEffect(() => {
if (formValid) updateProgress({ [routes.people.addNew.mainLadder]: 'completed' });
else updateProgress({ [routes.people.addNew.mainLadder]: 'inProgress' });
}, [formValid, updateProgress]);

return { firstTechnology, form, handleSubmit, technologyFields, open, setOpen, ladderSelected, formValid };
};
Loading

0 comments on commit 489a847

Please sign in to comment.