diff --git a/deployment/helmchart/CHANGELOG.md b/deployment/helmchart/CHANGELOG.md index 72b6183c333a7fa50fbac5a798ee8b47d81f3a48..5a24ce3d4b53eeb40853634c17b9157dfdb41757 100644 --- a/deployment/helmchart/CHANGELOG.md +++ b/deployment/helmchart/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.2.0] + +### Features + +* Batch user creation by pasting CSV in the dashboard +* When an admin's dashboard access is changed to "User", their app access now + defaults to "user" + ## [1.1.0] ### Bug fixes diff --git a/deployment/helmchart/Chart.yaml b/deployment/helmchart/Chart.yaml index a0ba170b15a3eec697155252a508cc75d872dbfc..393b079fd0540552e9b4f30ccc9f2d059c1c1450 100644 --- a/deployment/helmchart/Chart.yaml +++ b/deployment/helmchart/Chart.yaml @@ -1,7 +1,7 @@ annotations: category: Dashboard apiVersion: v2 -appVersion: 0.2.6 +appVersion: 0.2.8 dependencies: - name: common # https://artifacthub.io/packages/helm/bitnami/common @@ -23,4 +23,4 @@ name: stackspin-dashboard sources: - https://open.greenhost.net/stackspin/dashboard/ - https://open.greenhost.net/stackspin/dashboard-backend/ -version: 1.1.0 +version: 1.2.0 diff --git a/deployment/helmchart/values.yaml b/deployment/helmchart/values.yaml index 01c48fb9eade293417c5fa024368d143620a7ed8..35afd3be476b2b4428e846a5427765bc58a9c645 100644 --- a/deployment/helmchart/values.yaml +++ b/deployment/helmchart/values.yaml @@ -68,7 +68,7 @@ dashboard: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard - tag: 0-2-7 + tag: 0-2-8 ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ @@ -235,7 +235,7 @@ backend: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard-backend/dashboard-backend - tag: 0-2-8 + tag: 0-2-9 ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ diff --git a/src/common/util/show-toast.tsx b/src/common/util/show-toast.tsx index 351a98558eb032a9bf179686eed10c6b6bfee46e..0ab0f3c3c700c0538d76733c0dc7690c877fae9d 100644 --- a/src/common/util/show-toast.tsx +++ b/src/common/util/show-toast.tsx @@ -9,7 +9,7 @@ export enum ToastType { Error = 'error', } -export const showToast = (text: string, type?: ToastType) => { +export const showToast = (text: string, type?: ToastType, duration?: number) => { switch (type) { case ToastType.Error: toast.custom( @@ -47,7 +47,7 @@ export const showToast = (text: string, type?: ToastType) => { </div> </Transition> ), - { position: 'top-right' }, + { position: 'top-right', duration }, ); break; default: @@ -86,7 +86,7 @@ export const showToast = (text: string, type?: ToastType) => { </div> </Transition> ), - { position: 'top-right' }, + { position: 'top-right', duration }, ); } }; diff --git a/src/components/Form/Select/Select.tsx b/src/components/Form/Select/Select.tsx index aad51fa9d97789c95635086d21ff6655aabccda1..06a5322f710e625ab06c5cbe4c2b13429fe1e8b9 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/Form/TextArea/index.ts b/src/components/Form/TextArea/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f93c0de6dba7400e560e165cef83cdf9ea6c5cd --- /dev/null +++ b/src/components/Form/TextArea/index.ts @@ -0,0 +1 @@ +export { TextArea } from './textarea'; diff --git a/src/components/Form/TextArea/textarea.tsx b/src/components/Form/TextArea/textarea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dfa1882202ee1ab7617f8b7775729b5d6a20e31 --- /dev/null +++ b/src/components/Form/TextArea/textarea.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useController } from 'react-hook-form'; + +/* eslint-disable react/react-in-jsx-scope */ +export const TextArea = ({ control, name, label, required, ...props }: TextAreaProps) => { + const { + field, + // fieldState: { invalid, isTouched, isDirty }, + // formState: { touchedFields, dirtyFields }, + } = useController({ + name, + control, + rules: { required }, + defaultValue: '', + }); + + return ( + <> + {label && ( + <label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1"> + {label} + </label> + )} + <textarea + id={name} + onChange={field.onChange} // send value to hook form + onBlur={field.onBlur} // notify when input is touched/blur + value={field.value ? field.value.toString() : ''} // input value + name={name} // send down the input name + ref={field.ref} // send input ref, so we can focus on input when error appear + autoComplete="given-name" + className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md" + {...props} + /> + </> + ); +}; + +type TextAreaProps = { + control: any; + name: string; + label?: string; +} & React.HTMLProps<HTMLTextAreaElement>; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 5276586281294c676e305d9e61c3f560a0574392..19728f23f4aafb767d10d04769a5f4e5147613d2 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -2,3 +2,4 @@ export { Input } from './Input'; export { Select } from './Select'; export { Switch } from './Switch'; export { CodeEditor } from './CodeEditor'; +export { TextArea } from './TextArea'; diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index 048b315d43da0bc0c525c1afd55cd363fa3c0dd7..e61aaee56622b8773885a9fb40af927c4e47c471 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 afd720cbb24229bf015d625b2c1ac9621bd3e5eb..e679e696d4687ed1d1d2898721fcc82de22dedd3 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/Modal/StepsModal/StepsModal.tsx b/src/components/Modal/StepsModal/StepsModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e2135b4beb930e27233d919b7911a1a00586095 --- /dev/null +++ b/src/components/Modal/StepsModal/StepsModal.tsx @@ -0,0 +1,143 @@ +import React, { Fragment, useRef } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { XIcon } from '@heroicons/react/solid'; +import { StepsModalProps } from './types'; + +export const StepsModal: React.FC<StepsModalProps> = ({ + open, + onClose, + onSave, + onNext, + onPrevious, + children, + title = '', + useCancelButton = false, + isLoading = false, + leftActions = <></>, + showSaveButton = false, + showPreviousButton = false, + saveButtonDisabled = false, +}) => { + const cancelButtonRef = useRef(null); + const saveButtonRef = useRef(null); + const nextButtonRef = useRef(null); + const previousButtonRef = useRef(null); + + return ( + <Transition.Root show={open} as={Fragment}> + <Dialog as="div" className="fixed z-10 inset-0 overflow-y-auto" initialFocus={cancelButtonRef} onClose={() => {}}> + <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> + </Transition.Child> + + {/* This element is to trick the browser into centering the modal contents. */} + <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> + ​ + </span> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + enterTo="opacity-100 translate-y-0 sm:scale-100" + leave="ease-in duration-200" + leaveFrom="opacity-100 translate-y-0 sm:scale-100" + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + > + <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full relative"> + {isLoading && ( + <Dialog.Overlay className="inset-0 bg-gray-400 bg-opacity-75 transition-opacity absolute flex justify-center items-center"> + <svg + className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + > + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + /> + </svg> + </Dialog.Overlay> + )} + + {!useCancelButton && ( + <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:items-center sm:justify-between"> + <div>{title}</div> + <button + type="button" + className="w-full inline-flex justify-center rounded-md border border-gray-200 p-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" + onClick={onClose} + ref={cancelButtonRef} + > + <XIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> + </button> + </div> + )} + + <div className="bg-white px-4 p-6">{children}</div> + <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex"> + {leftActions} + <div className="ml-auto sm:flex sm:flex-row-reverse"> + {showSaveButton && onSave && ( + <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 ${ + saveButtonDisabled ? 'opacity-50' : '' + }`} + onClick={onSave} + ref={saveButtonRef} + disabled={saveButtonDisabled} + > + Save Changes + </button> + )} + {!showSaveButton && ( + <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={onNext} + ref={nextButtonRef} + > + Next + </button> + )} + {showPreviousButton && ( + <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={onPrevious} + ref={previousButtonRef} + > + Previous + </button> + )} + {useCancelButton && ( + <button + type="button" + className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" + onClick={onClose} + ref={cancelButtonRef} + > + Cancel + </button> + )} + </div> + </div> + </div> + </Transition.Child> + </div> + </Dialog> + </Transition.Root> + ); +}; diff --git a/src/components/Modal/StepsModal/index.ts b/src/components/Modal/StepsModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c9bae5e58db0b8b6e910dd541b1b2942a8a63a4 --- /dev/null +++ b/src/components/Modal/StepsModal/index.ts @@ -0,0 +1 @@ +export { StepsModal } from './StepsModal'; diff --git a/src/components/Modal/StepsModal/types.ts b/src/components/Modal/StepsModal/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0b5bbd1f5d9c952e1d1e5d73822f719b26900cb --- /dev/null +++ b/src/components/Modal/StepsModal/types.ts @@ -0,0 +1,16 @@ +import React from 'react'; + +export type StepsModalProps = { + open: boolean; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; + title?: string; + onSave?: () => void; + useCancelButton?: boolean; + isLoading?: boolean; + leftActions?: React.ReactNode; + showSaveButton?: boolean; + showPreviousButton?: boolean; + saveButtonDisabled?: boolean; +}; diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts index 4ef3e384fe3273b562aa57b7a3eaa58eead8fa2d..e5800df0cd61eb051c8456da27e6abece8183ee3 100644 --- a/src/components/Modal/index.ts +++ b/src/components/Modal/index.ts @@ -1,2 +1,3 @@ export { ConfirmationModal } from './ConfirmationModal'; export { Modal } from './Modal'; +export { StepsModal } from './StepsModal'; diff --git a/src/components/ProgressSteps/ProgressSteps.tsx b/src/components/ProgressSteps/ProgressSteps.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a24d67506ec09a94aab0a82112509174071f1c9 --- /dev/null +++ b/src/components/ProgressSteps/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/ProgressSteps/index.ts b/src/components/ProgressSteps/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bc8859dd118f5ff103c3a7f53093e5cc27697c2 --- /dev/null +++ b/src/components/ProgressSteps/index.ts @@ -0,0 +1 @@ +export { ProgressSteps } from './ProgressSteps'; diff --git a/src/components/ProgressSteps/types.ts b/src/components/ProgressSteps/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3eed8b3bc6572077b711366efc879964ae90f4c3 --- /dev/null +++ b/src/components/ProgressSteps/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/UserModal.tsx b/src/components/UserModal/UserModal.tsx index 0993b94fbeb3473526f26b9a83e05ebdf635c6fe..404db4b13b73463405966b934365789223787c92 100644 --- a/src/components/UserModal/UserModal.tsx +++ b/src/components/UserModal/UserModal.tsx @@ -68,6 +68,8 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps) 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]); diff --git a/src/components/UserModal/consts.ts b/src/components/UserModal/consts.ts index 38827fbfa94a07913543ac40759e7c626fd6155c..b0b0deaeaa38070dcdb50f6d53b7ee831732b827 100644 --- a/src/components/UserModal/consts.ts +++ b/src/components/UserModal/consts.ts @@ -31,26 +31,35 @@ export const appAccessList = [ }, ]; -const initialAppRoles = [ +export const allAppAccessList = [ + { + name: 'dashboard', + image: '/assets/logo-small.svg', + label: 'Dashboard', + }, + ...appAccessList, +]; + +export const initialAppRoles = [ { name: 'dashboard', role: UserRole.User, }, { name: 'wekan', - role: UserRole.NoAccess, + role: UserRole.User, }, { name: 'wordpress', - role: UserRole.NoAccess, + role: UserRole.User, }, { name: 'nextcloud', - role: UserRole.NoAccess, + role: UserRole.User, }, { name: 'zulip', - role: UserRole.NoAccess, + role: UserRole.User, }, ]; diff --git a/src/components/index.ts b/src/components/index.ts index 565e17d3790a54e2f7c8a2757fa444d443539cfd..9a2f607cfeb960268b04186d98cfc92dcd0c9abc 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,5 +3,6 @@ export { Header } from './Header'; export { Table } from './Table'; export { Banner } from './Banner'; export { Tabs } from './Tabs'; -export { Modal, ConfirmationModal } from './Modal'; +export { Modal, ConfirmationModal, StepsModal } from './Modal'; export { UserModal } from './UserModal'; +export { ProgressSteps } from './ProgressSteps'; diff --git a/src/modules/users/Users.tsx b/src/modules/users/Users.tsx index c40cc794947c6b87834fb1e42cf2bae6b7993b96..41cfafbf3444a685267465d88df1dd9938bee7dc 100644 --- a/src/modules/users/Users.tsx +++ b/src/modules/users/Users.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { SearchIcon, PlusIcon } from '@heroicons/react/solid'; +import { SearchIcon, PlusIcon, ViewGridAddIcon } from '@heroicons/react/solid'; import { CogIcon, TrashIcon } from '@heroicons/react/outline'; import { useUsers } from 'src/services/users'; import { Table } from 'src/components'; @@ -8,10 +8,12 @@ import { debounce } from 'lodash'; import { useAuth } from 'src/services/auth'; import { UserModal } from '../../components/UserModal'; +import { MultipleUsersModal } from './components'; export const Users: React.FC = () => { const [selectedRowsIds, setSelectedRowsIds] = useState({}); const [configureModal, setConfigureModal] = useState(false); + const [multipleUsersModal, setMultipleUsersModal] = useState(false); const [userId, setUserId] = useState(null); const [search, setSearch] = useState(''); const { users, loadUsers, userTableLoading } = useUsers(); @@ -39,8 +41,11 @@ export const Users: React.FC = () => { setUserId(id); setConfigureModal(true); }; + const configureModalClose = () => setConfigureModal(false); + const multipleUsersModalClose = () => setMultipleUsersModal(false); + const columns: any = React.useMemo( () => [ { @@ -101,11 +106,19 @@ export const Users: React.FC = () => { <button onClick={() => configureModalOpen(null)} type="button" - className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800" + className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800 mx-5 " > <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> Add new user </button> + <button + onClick={() => setMultipleUsersModal(true)} + type="button" + className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800" + > + <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> + Add new users + </button> </div> )} </div> @@ -166,6 +179,7 @@ export const Users: React.FC = () => { {configureModal && ( <UserModal open={configureModal} onClose={configureModalClose} userId={userId} setUserId={setUserId} /> )} + {multipleUsersModal && <MultipleUsersModal open={multipleUsersModal} onClose={multipleUsersModalClose} />} </div> </div> ); diff --git a/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx b/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74f60c692ec53fc6347ea35a1ced1db40e22b59d --- /dev/null +++ b/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; + +import { Banner, StepsModal, 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/ProgressSteps/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, update } = useFieldArray({ + control, + name: 'appRoles', + }); + + const dashboardRole = useWatch({ + control, + name: 'appRoles.0.role', + }); + + const csvDataWatch = useWatch({ + control, + name: 'csvUserData', + }); + + 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> + <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> + ); + }; + + const renderAppAccess = () => { + return ( + <div> + <div className="mt-8"> + <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 + .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"> + <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`} + options={[ + { value: UserRole.User, name: 'User' }, + { value: UserRole.Admin, name: 'Admin' }, + ]} + /> + </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> + </div> + ); + }; + + 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 getActiveStepIndex = () => _.findIndex(steps, { status: ProgressStepStatus.Current }); + + 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 handleNext = () => { + const nextIndex = getActiveStepIndex() + 1; + updateStepsStatus(nextIndex); + }; + + const handlePrevious = () => { + const nextIndex = getActiveStepIndex() - 1; + updateStepsStatus(nextIndex); + }; + + const activeStepIndex = getActiveStepIndex(); + const showSave = !_.some(steps, { status: ProgressStepStatus.Upcoming }); + const showPrevious = _.some(steps, { status: ProgressStepStatus.Complete }); + + return ( + <StepsModal + onClose={handleClose} + open={open} + onSave={handleSave} + onNext={handleNext} + onPrevious={handlePrevious} + showPreviousButton={showPrevious} + isLoading={userModalLoading} + useCancelButton + showSaveButton={showSave} + saveButtonDisabled={_.isEmpty(csvDataWatch)} + > + <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:px-6 pt-6"> + <ProgressSteps steps={steps} onStepClick={handleStepClick}> + {activeStepIndex === 0 ? renderUsersCsvDataInput() : renderAppAccess()} + </ProgressSteps> + </div> + </div> + </div> + </div> + </StepsModal> + ); +}; diff --git a/src/modules/users/components/MultipleUsersModal/index.ts b/src/modules/users/components/MultipleUsersModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8c3b1abdcb63e5dffe1ed477b643f9e595adfe1 --- /dev/null +++ b/src/modules/users/components/MultipleUsersModal/index.ts @@ -0,0 +1 @@ +export { MultipleUsersModal } from './MultipleUsersModal'; diff --git a/src/modules/users/components/MultipleUsersModal/types.ts b/src/modules/users/components/MultipleUsersModal/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..643f10cc604b86b9e8a5222db4863299f61fa042 --- /dev/null +++ b/src/modules/users/components/MultipleUsersModal/types.ts @@ -0,0 +1,10 @@ +import { initialAppRoles } from 'src/components/UserModal/consts'; + +export type MultipleUsersModalProps = { + open: boolean; + onClose: () => void; +}; + +export const initialMultipleUsersForm = { + appRoles: initialAppRoles, +}; diff --git a/src/modules/users/components/index.ts b/src/modules/users/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8c3b1abdcb63e5dffe1ed477b643f9e595adfe1 --- /dev/null +++ b/src/modules/users/components/index.ts @@ -0,0 +1 @@ +export { MultipleUsersModal } from './MultipleUsersModal'; diff --git a/src/services/users/hooks/use-users.ts b/src/services/users/hooks/use-users.ts index 75febdcc9d2efbdda99521e89a25142eaf927b18..2d0381de36faf88771aab5af1e9e033f455a68fc 100644 --- a/src/services/users/hooks/use-users.ts +++ b/src/services/users/hooks/use-users.ts @@ -9,6 +9,7 @@ import { createUser, deleteUser, clearCurrentUser, + createBatchUsers, } from '../redux'; import { getUserById, getUserModalLoading, getUserslLoading } from '../redux/selectors'; @@ -47,6 +48,10 @@ export function useUsers() { return dispatch(createUser(data)); } + function createUsers(data: any) { + return dispatch(createBatchUsers(data)); + } + function deleteUserById(id: string) { return dispatch(deleteUser(id)); } @@ -64,5 +69,6 @@ export function useUsers() { createNewUser, deleteUserById, clearSelectedUser, + createUsers, }; } diff --git a/src/services/users/redux/actions.ts b/src/services/users/redux/actions.ts index 1b59cc247c1189732576d92f99006288392f8f96..7643b41e83586929faf4a108ec8fafae56b28331 100644 --- a/src/services/users/redux/actions.ts +++ b/src/services/users/redux/actions.ts @@ -1,9 +1,15 @@ +import _ from 'lodash'; import { Dispatch } from 'redux'; import { showToast, ToastType } from 'src/common/util/show-toast'; import { State } from 'src/redux/types'; import { performApiCall } from 'src/services/api'; import { AuthActionTypes } from 'src/services/auth'; -import { transformRequestUser, transformUser } from '../transformations'; +import { + transformBatchResponse, + transformRequestMultipleUsers, + transformRequestUser, + transformUser, +} from '../transformations'; export enum UserActionTypes { FETCH_USERS = 'users/fetch_users', @@ -13,6 +19,7 @@ export enum UserActionTypes { DELETE_USER = 'users/delete_user', SET_USER_MODAL_LOADING = 'users/user_modal_loading', SET_USERS_LOADING = 'users/users_loading', + CREATE_BATCH_USERS = 'users/create_batch_users', } export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => { @@ -202,6 +209,44 @@ export const deleteUser = (id: string) => async (dispatch: Dispatch<any>) => { dispatch(setUserModalLoading(false)); }; +export const createBatchUsers = (users: any) => async (dispatch: Dispatch<any>) => { + dispatch(setUserModalLoading(true)); + + try { + const { data } = await performApiCall({ + path: '/users-batch', + method: 'POST', + body: transformRequestMultipleUsers(users), + }); + + const responseData = transformBatchResponse(data); + + dispatch({ + type: UserActionTypes.CREATE_BATCH_USERS, + payload: responseData, + }); + + // show information about created users + if (!_.isEmpty(responseData.success)) { + showToast(responseData.success.message, ToastType.Success, Infinity); + } + if (!_.isEmpty(responseData.existing)) { + showToast(responseData.existing.message, ToastType.Error, Infinity); + } + if (!_.isEmpty(responseData.failed)) { + showToast(responseData.failed.message, ToastType.Error, Infinity); + } + + dispatch(fetchUsers()); + } catch (err: any) { + dispatch(setUserModalLoading(false)); + showToast(`${err}`, ToastType.Error); + throw err; + } + + dispatch(setUserModalLoading(false)); +}; + export const clearCurrentUser = () => (dispatch: Dispatch<any>) => { dispatch({ type: UserActionTypes.DELETE_USER, diff --git a/src/services/users/redux/reducers.ts b/src/services/users/redux/reducers.ts index 71ff2fa07b42a7b9c5252298b995dd31e294e0fa..2d02771db98e3c3419b651a7d9e721ce4f26c59e 100644 --- a/src/services/users/redux/reducers.ts +++ b/src/services/users/redux/reducers.ts @@ -27,6 +27,7 @@ const usersReducer = (state: any = initialUsersState, action: any) => { case UserActionTypes.FETCH_USER: case UserActionTypes.UPDATE_USER: case UserActionTypes.CREATE_USER: + case UserActionTypes.CREATE_BATCH_USERS: return { ...state, isModalVisible: false, diff --git a/src/services/users/transformations.ts b/src/services/users/transformations.ts index bc2bd05501259f598e289e0b05c2afb43de071d8..21e7db46413d1b240b861315de78fa09fb37c2a5 100644 --- a/src/services/users/transformations.ts +++ b/src/services/users/transformations.ts @@ -1,4 +1,5 @@ -import { AppRoles, User, UserRole } from './types'; +import _ from 'lodash'; +import { AppRoles, MultipleUsersData, User, UserRole } from './types'; const transformRoleById = (roleId: any): UserRole => { switch (roleId) { @@ -62,3 +63,31 @@ export const transformRequestUser = (data: Pick<User, 'app_roles' | 'name' | 'em name: data.name ?? '', }; }; + +const extractUsersFromCsv = (csvData: string) => { + const csvRows = csvData.split('\n'); + + return _.map(csvRows, (row) => { + const values = row.split(','); + const email = values[0].trim(); + const name = !_.isNil(values[1]) ? values[1].trim() : ''; + return { email, name, app_roles: [] }; + }); +}; + +export const transformRequestMultipleUsers = (data: MultipleUsersData) => { + const batchUsers = extractUsersFromCsv(data.csvUserData); + return { + users: _.map(batchUsers, (user) => + transformRequestUser({ app_roles: data.appRoles, name: user.name, email: user.email } as User), + ), + }; +}; + +export const transformBatchResponse = (response: any): any => { + return { + success: response.success, + existing: response.existing, + failed: response.failed, + }; +}; diff --git a/src/services/users/types.ts b/src/services/users/types.ts index f97f43543661c1f0044d81cb0c95c81c8a289384..d22811cba75d1cd8aa84c983de14f632df089dda 100644 --- a/src/services/users/types.ts +++ b/src/services/users/types.ts @@ -29,3 +29,8 @@ export interface UserApiRequest { name: string; status: string; } + +export interface MultipleUsersData { + csvUserData: string; + appRoles: AppRoles[]; +}