From bc1a07251522af0e747b1f8ed797b4767e32bf05 Mon Sep 17 00:00:00 2001 From: Davor <davor.ivankovic2@gmail.com> Date: Tue, 19 Jul 2022 00:21:06 +0200 Subject: [PATCH] implement progress steps component --- src/components/Form/Select/Select.tsx | 2 +- src/components/Modal/Modal/Modal.tsx | 6 +- src/components/Modal/Modal/types.ts | 1 + src/components/Steps/ProgressSteps.tsx | 107 ++++++++++ src/components/Steps/index.ts | 1 + src/components/Steps/types.ts | 20 ++ src/components/UserModal/consts.ts | 2 +- src/components/index.ts | 1 + .../MultipleUsersModal/MultipleUsersModal.tsx | 187 ++++++++++++++---- 9 files changed, 286 insertions(+), 41 deletions(-) create mode 100644 src/components/Steps/ProgressSteps.tsx create mode 100644 src/components/Steps/index.ts create mode 100644 src/components/Steps/types.ts diff --git a/src/components/Form/Select/Select.tsx b/src/components/Form/Select/Select.tsx index aad51fa9..06a5322f 100644 --- a/src/components/Form/Select/Select.tsx +++ b/src/components/Form/Select/Select.tsx @@ -26,7 +26,7 @@ export const Select = ({ control, name, label, options, disabled = false }: Sele value={field.value ? field.value : ''} // input value name={name} // send down the input name ref={field.ref} // send input ref, so we can focus on input when error appear - className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md" + className="block shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md" disabled={disabled} > {options?.map((option) => ( diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index 048b315d..e61aaee5 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -12,6 +12,7 @@ export const Modal: React.FC<ModalProps> = ({ useCancelButton = false, isLoading = false, leftActions = <></>, + saveButtonDisabled = false, }) => { const cancelButtonRef = useRef(null); const saveButtonRef = useRef(null); @@ -86,9 +87,12 @@ export const Modal: React.FC<ModalProps> = ({ <div className="ml-auto sm:flex sm:flex-row-reverse"> <button type="button" - className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm" + className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm ${ + saveButtonDisabled ? 'opacity-50' : '' + }`} onClick={onSave} ref={saveButtonRef} + disabled={saveButtonDisabled} > Save Changes </button> diff --git a/src/components/Modal/Modal/types.ts b/src/components/Modal/Modal/types.ts index afd720cb..e679e696 100644 --- a/src/components/Modal/Modal/types.ts +++ b/src/components/Modal/Modal/types.ts @@ -8,4 +8,5 @@ export type ModalProps = { useCancelButton?: boolean; isLoading?: boolean; leftActions?: React.ReactNode; + saveButtonDisabled?: boolean; }; diff --git a/src/components/Steps/ProgressSteps.tsx b/src/components/Steps/ProgressSteps.tsx new file mode 100644 index 00000000..2a24d675 --- /dev/null +++ b/src/components/Steps/ProgressSteps.tsx @@ -0,0 +1,107 @@ +import _ from 'lodash'; +import React from 'react'; +import { ProgressStepsProps, ProgressStepStatus } from './types'; + +export const ProgressSteps: React.FC<ProgressStepsProps> = ({ steps, onNext, onPrevious, onStepClick, children }) => { + const handleNext = () => { + if (onNext) { + onNext(); + } + }; + const handlePrevious = () => { + if (onPrevious) { + onPrevious(); + } + }; + + const showNextPage = () => { + if (onNext) { + return _.some(steps, { status: ProgressStepStatus.Upcoming }); + } + return false; + }; + + const showPreviousPage = () => { + if (onPrevious) { + return _.some(steps, { status: ProgressStepStatus.Complete }); + } + return false; + }; + + const handleStepClick = (stepId: string) => { + if (onStepClick) { + onStepClick(stepId); + } + }; + + return ( + <> + <nav aria-label="Progress"> + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} + <ol role="list" className="space-y-4 md:flex md:space-y-0 md:space-x-8 mb-4"> + {steps.map((step) => ( + <li key={step.name} className="md:flex-1" onClick={() => handleStepClick(step.id)}> + {step.status === ProgressStepStatus.Complete ? ( + <a + href={step.href} + className="group pl-4 py-2 flex flex-col border-l-4 border-primary-500 md:pl-0 md:pb-0 md:border-l-0 md:border-t-4" + > + <span className="text-xs text-primary-600 font-semibold tracking-wide uppercase group-hover:text-primary-800"> + {step.id} + </span> + <span className="text-sm font-medium">{step.name}</span> + </a> + ) : step.status === ProgressStepStatus.Current ? ( + <a + href={step.href} + className="pl-4 py-2 flex flex-col border-l-4 border-primary-500 md:pl-0 md:pb-0 md:border-l-0 md:border-t-4" + aria-current="step" + > + <span className="text-xs text-primary-600 font-semibold tracking-wide uppercase">{step.id}</span> + <span className="text-sm font-medium">{step.name}</span> + </a> + ) : ( + <a + href={step.href} + className="group pl-4 py-2 flex flex-col border-l-4 border-gray-200 hover:border-gray-300 md:pl-0 md:pb-0 md:border-l-0 md:border-t-4" + > + <span className="text-xs text-gray-500 font-semibold tracking-wide uppercase group-hover:text-gray-700"> + {step.id} + </span> + <span className="text-sm font-medium">{step.name}</span> + </a> + )} + </li> + ))} + </ol> + </nav> + + {children} + + {(showNextPage() || showPreviousPage()) && ( + <div className="pt-4 sm sm:flex"> + <div className="ml-auto sm:flex sm:flex-row-reverse"> + {showNextPage() && ( + <button + type="button" + className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm" + onClick={handleNext} + > + Next + </button> + )} + {showPreviousPage() && ( + <button + type="button" + className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm" + onClick={handlePrevious} + > + Previous + </button> + )} + </div> + </div> + )} + </> + ); +}; diff --git a/src/components/Steps/index.ts b/src/components/Steps/index.ts new file mode 100644 index 00000000..2bc8859d --- /dev/null +++ b/src/components/Steps/index.ts @@ -0,0 +1 @@ +export { ProgressSteps } from './ProgressSteps'; diff --git a/src/components/Steps/types.ts b/src/components/Steps/types.ts new file mode 100644 index 00000000..3eed8b3b --- /dev/null +++ b/src/components/Steps/types.ts @@ -0,0 +1,20 @@ +export type ProgressStepsProps = { + steps: ProgressStepInfo[]; + onNext?: () => void; + onPrevious?: () => void; + onStepClick?: (stepId: string) => void; +}; + +export interface ProgressStepInfo { + id: string; + name: string; + status: ProgressStepStatus; + component?: React.ReactNode; + href?: string; +} + +export enum ProgressStepStatus { + Complete = 0, + Current = 1, + Upcoming = 2, +} diff --git a/src/components/UserModal/consts.ts b/src/components/UserModal/consts.ts index 2229a230..f1566526 100644 --- a/src/components/UserModal/consts.ts +++ b/src/components/UserModal/consts.ts @@ -24,12 +24,12 @@ export const appAccessList = [ ]; export const allAppAccessList = [ - ...appAccessList, { name: 'dashboard', image: '/assets/logo-small.svg', label: 'Dashboard', }, + ...appAccessList, ]; export const initialAppRoles = [ diff --git a/src/components/index.ts b/src/components/index.ts index 565e17d3..aff49e2e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,4 @@ export { Banner } from './Banner'; export { Tabs } from './Tabs'; export { Modal, ConfirmationModal } from './Modal'; export { UserModal } from './UserModal'; +export { ProgressSteps } from './Steps'; diff --git a/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx b/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx index 63e3021b..02200e8f 100644 --- a/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx +++ b/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx @@ -1,50 +1,64 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import _ from 'lodash'; -import { useFieldArray, useForm } from 'react-hook-form'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; -import { Modal, Tabs } from 'src/components'; +import { Banner, Modal, ProgressSteps } from 'src/components'; import { Select, TextArea } from 'src/components/Form'; import { MultipleUsersData, UserRole, useUsers } from 'src/services/users'; import { allAppAccessList } from 'src/components/UserModal/consts'; +import { ProgressStepInfo, ProgressStepStatus } from 'src/components/Steps/types'; import { initialMultipleUsersForm, MultipleUsersModalProps } from './types'; export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) => { + const [steps, setSteps] = useState<ProgressStepInfo[]>([]); + const [isAdminRoleSelected, setAdminRoleSelected] = useState(false); const { createUsers, userModalLoading } = useUsers(); const { control, handleSubmit } = useForm<MultipleUsersData>({ defaultValues: initialMultipleUsersForm, }); - const { fields } = useFieldArray({ + const { fields, update } = useFieldArray({ control, name: 'appRoles', }); - const handleSave = async () => { - try { - await handleSubmit((data) => createUsers(data))(); - } catch (e: any) { - // Continue - } + const dashboardRole = useWatch({ + control, + name: 'appRoles.0.role', + }); - onClose(); - }; + const csvDataWatch = useWatch({ + control, + name: 'csvUserData', + }); - const handleClose = () => { - onClose(); - }; + useEffect(() => { + const isAdminDashboardRoleSelected = dashboardRole === UserRole.Admin; + setAdminRoleSelected(isAdminDashboardRoleSelected); + if (isAdminDashboardRoleSelected) { + fields.forEach((field, index) => update(index, { name: field.name, role: UserRole.Admin })); + } else { + fields.forEach((field, index) => update(index, { name: field.name, role: UserRole.User })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardRole]); const renderUsersCsvDataInput = () => { return ( <div> - <TextArea - control={control} - name="csvUserData" - label="CSV user data" - placeholder="Please paste users in CSV format: email,name" - rows={15} - required - /> + <div className="mt-8"> + <h3 className="text-lg leading-6 font-medium text-gray-900">CSV data</h3> + </div> + <div className="mt-6"> + <TextArea + control={control} + name="csvUserData" + placeholder={`Please paste users in CSV format: email, name\nuser1@example.com,User One\nuser2@example.com,User Two`} + rows={15} + required + /> + </div> </div> ); }; @@ -56,11 +70,18 @@ export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) = <h3 className="text-lg leading-6 font-medium text-gray-900">App Access</h3> </div> + {isAdminRoleSelected && ( + <div className="sm:col-span-6"> + <Banner title="Admin users automatically have admin-level access to all apps." titleSm="Admin user" /> + </div> + )} + <div> <div className="flow-root mt-6"> - <ul className="-my-5 divide-y divide-gray-200 "> - {fields.map((item, index) => { - return ( + <ul className="-my-5 divide-y divide-gray-200"> + {fields + .filter((field) => field.name === 'dashboard') + .map((item, index) => ( <li className="py-4" key={item.name}> <div className="flex items-center space-x-4"> <div className="flex-shrink-0 flex-1 flex items-center"> @@ -73,13 +94,12 @@ export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) = {_.find(allAppAccessList, ['name', item.name!])?.label} </h3> </div> - <div> + <div className="sm:col-span-2"> <Select key={item.id} control={control} name={`appRoles.${index}.role`} options={[ - { value: UserRole.NoAccess, name: 'No Access' }, { value: UserRole.User, name: 'User' }, { value: UserRole.Admin, name: 'Admin' }, ]} @@ -87,8 +107,43 @@ export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) = </div> </div> </li> - ); - })} + ))} + {!isAdminRoleSelected && + fields.map((item, index) => { + if (item.name === 'dashboard') { + return null; + } + + return ( + <li className="py-4" key={item.name}> + <div className="flex items-center space-x-4"> + <div className="flex-shrink-0 flex-1 flex items-center"> + <img + className="h-10 w-10 rounded-md overflow-hidden" + src={_.find(allAppAccessList, ['name', item.name!])?.image} + alt={item.name ?? 'Image'} + /> + <h3 className="ml-4 text-md leading-6 font-medium text-gray-900"> + {_.find(allAppAccessList, ['name', item.name!])?.label} + </h3> + </div> + <div className="sm:col-span-2"> + <Select + key={item.id} + control={control} + name={`appRoles.${index}.role`} + disabled={isAdminRoleSelected} + options={[ + { value: UserRole.NoAccess, name: 'No Access' }, + { value: UserRole.User, name: 'User' }, + { value: UserRole.Admin, name: 'Admin' }, + ]} + /> + </div> + </div> + </li> + ); + })} </ul> </div> </div> @@ -96,21 +151,77 @@ export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) = ); }; + useEffect(() => { + setSteps([ + { + id: 'Step 1', + name: 'Enter CSV user data', + status: ProgressStepStatus.Current, + }, + { + id: 'Step 2', + name: 'Define app access roles', + status: ProgressStepStatus.Upcoming, + }, + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const handleSave = async () => { + try { + await handleSubmit((data) => createUsers(data))(); + } catch (e: any) { + // Continue + } + + onClose(); + }; + + const handleClose = () => { + onClose(); + }; + + const updateStepsStatus = (nextIndex: number) => { + const updatedSteps = [...steps]; + _.forEach(updatedSteps, (step, index) => { + if (index < nextIndex) { + step.status = ProgressStepStatus.Complete; + } else if (index === nextIndex) { + step.status = ProgressStepStatus.Current; + } else { + step.status = ProgressStepStatus.Upcoming; + } + }); + setSteps(updatedSteps); + }; + + const handleStepClick = (stepId: string) => { + const activeStepIndex = _.findIndex(steps, { id: stepId }); + updateStepsStatus(activeStepIndex); + }; + + const getActiveStepIndex = _.findIndex(steps, { status: ProgressStepStatus.Current }); + const disableSave = _.isEmpty(csvDataWatch) || _.some(steps, { status: ProgressStepStatus.Upcoming }); + return ( - <Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={userModalLoading} useCancelButton> + <Modal + onClose={handleClose} + open={open} + onSave={handleSave} + isLoading={userModalLoading} + useCancelButton + saveButtonDisabled={disableSave} + > <div className="bg-white px-4"> <div className="space-y-10 divide-y divide-gray-200"> <div> <div> <h3 className="text-lg leading-6 font-medium text-gray-900">Add new users</h3> </div> - <div className="sm:p-6"> - <Tabs - tabs={[ - { name: 'Add users', component: renderUsersCsvDataInput() }, - { name: 'App access roles', component: renderAppAccess() }, - ]} - /> + <div className="sm:px-6 pt-6"> + <ProgressSteps steps={steps} onStepClick={handleStepClick}> + {getActiveStepIndex === 0 ? renderUsersCsvDataInput() : renderAppAccess()} + </ProgressSteps> </div> </div> </div> -- GitLab