Merge branch 'main' into feat/apps-development
This commit is contained in:
commit
1c153ac5ad
30 changed files with 738 additions and 19 deletions
|
@ -1,5 +1,13 @@
|
||||||
# Changelog
|
# 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]
|
## [1.1.0]
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
annotations:
|
annotations:
|
||||||
category: Dashboard
|
category: Dashboard
|
||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
appVersion: 0.2.6
|
appVersion: 0.2.8
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: common
|
- name: common
|
||||||
# https://artifacthub.io/packages/helm/bitnami/common
|
# https://artifacthub.io/packages/helm/bitnami/common
|
||||||
|
@ -23,4 +23,4 @@ name: stackspin-dashboard
|
||||||
sources:
|
sources:
|
||||||
- https://open.greenhost.net/stackspin/dashboard/
|
- https://open.greenhost.net/stackspin/dashboard/
|
||||||
- https://open.greenhost.net/stackspin/dashboard-backend/
|
- https://open.greenhost.net/stackspin/dashboard-backend/
|
||||||
version: 1.1.0
|
version: 1.2.0
|
||||||
|
|
|
@ -68,7 +68,7 @@ dashboard:
|
||||||
image:
|
image:
|
||||||
registry: open.greenhost.net:4567
|
registry: open.greenhost.net:4567
|
||||||
repository: stackspin/dashboard/dashboard
|
repository: stackspin/dashboard/dashboard
|
||||||
tag: 0-2-7
|
tag: 0-2-8
|
||||||
## Optionally specify an array of imagePullSecrets.
|
## Optionally specify an array of imagePullSecrets.
|
||||||
## Secrets must be manually created in the namespace.
|
## Secrets must be manually created in the namespace.
|
||||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
||||||
|
@ -235,7 +235,7 @@ backend:
|
||||||
image:
|
image:
|
||||||
registry: open.greenhost.net:4567
|
registry: open.greenhost.net:4567
|
||||||
repository: stackspin/dashboard-backend/dashboard-backend
|
repository: stackspin/dashboard-backend/dashboard-backend
|
||||||
tag: 0-2-8
|
tag: 0-2-9
|
||||||
## Optionally specify an array of imagePullSecrets.
|
## Optionally specify an array of imagePullSecrets.
|
||||||
## Secrets must be manually created in the namespace.
|
## Secrets must be manually created in the namespace.
|
||||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
||||||
|
|
|
@ -9,7 +9,7 @@ export enum ToastType {
|
||||||
Error = 'error',
|
Error = 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const showToast = (text: string, type?: ToastType) => {
|
export const showToast = (text: string, type?: ToastType, duration?: number) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ToastType.Error:
|
case ToastType.Error:
|
||||||
toast.custom(
|
toast.custom(
|
||||||
|
@ -47,7 +47,7 @@ export const showToast = (text: string, type?: ToastType) => {
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
),
|
),
|
||||||
{ position: 'top-right' },
|
{ position: 'top-right', duration },
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
@ -86,7 +86,7 @@ export const showToast = (text: string, type?: ToastType) => {
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
),
|
),
|
||||||
{ position: 'top-right' },
|
{ position: 'top-right', duration },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const Select = ({ control, name, label, options, disabled = false }: Sele
|
||||||
value={field.value ? field.value : ''} // input value
|
value={field.value ? field.value : ''} // input value
|
||||||
name={name} // send down the input name
|
name={name} // send down the input name
|
||||||
ref={field.ref} // send input ref, so we can focus on input when error appear
|
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}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{options?.map((option) => (
|
{options?.map((option) => (
|
||||||
|
|
1
src/components/Form/TextArea/index.ts
Normal file
1
src/components/Form/TextArea/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { TextArea } from './textarea';
|
43
src/components/Form/TextArea/textarea.tsx
Normal file
43
src/components/Form/TextArea/textarea.tsx
Normal file
|
@ -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>;
|
|
@ -2,3 +2,4 @@ export { Input } from './Input';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
export { Switch } from './Switch';
|
export { Switch } from './Switch';
|
||||||
export { CodeEditor } from './CodeEditor';
|
export { CodeEditor } from './CodeEditor';
|
||||||
|
export { TextArea } from './TextArea';
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||||
useCancelButton = false,
|
useCancelButton = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
leftActions = <></>,
|
leftActions = <></>,
|
||||||
|
saveButtonDisabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const cancelButtonRef = useRef(null);
|
const cancelButtonRef = useRef(null);
|
||||||
const saveButtonRef = 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">
|
<div className="ml-auto sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onSave}
|
||||||
ref={saveButtonRef}
|
ref={saveButtonRef}
|
||||||
|
disabled={saveButtonDisabled}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -8,4 +8,5 @@ export type ModalProps = {
|
||||||
useCancelButton?: boolean;
|
useCancelButton?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
leftActions?: React.ReactNode;
|
leftActions?: React.ReactNode;
|
||||||
|
saveButtonDisabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
143
src/components/Modal/StepsModal/StepsModal.tsx
Normal file
143
src/components/Modal/StepsModal/StepsModal.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
1
src/components/Modal/StepsModal/index.ts
Normal file
1
src/components/Modal/StepsModal/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { StepsModal } from './StepsModal';
|
16
src/components/Modal/StepsModal/types.ts
Normal file
16
src/components/Modal/StepsModal/types.ts
Normal file
|
@ -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;
|
||||||
|
};
|
|
@ -1,2 +1,3 @@
|
||||||
export { ConfirmationModal } from './ConfirmationModal';
|
export { ConfirmationModal } from './ConfirmationModal';
|
||||||
export { Modal } from './Modal';
|
export { Modal } from './Modal';
|
||||||
|
export { StepsModal } from './StepsModal';
|
||||||
|
|
107
src/components/ProgressSteps/ProgressSteps.tsx
Normal file
107
src/components/ProgressSteps/ProgressSteps.tsx
Normal file
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1
src/components/ProgressSteps/index.ts
Normal file
1
src/components/ProgressSteps/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { ProgressSteps } from './ProgressSteps';
|
20
src/components/ProgressSteps/types.ts
Normal file
20
src/components/ProgressSteps/types.ts
Normal file
|
@ -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,
|
||||||
|
}
|
|
@ -68,6 +68,8 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
|
||||||
setAdminRoleSelected(isAdminDashboardRoleSelected);
|
setAdminRoleSelected(isAdminDashboardRoleSelected);
|
||||||
if (isAdminDashboardRoleSelected) {
|
if (isAdminDashboardRoleSelected) {
|
||||||
fields.forEach((field, index) => update(index, { name: field.name, role: UserRole.Admin }));
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dashboardRole]);
|
}, [dashboardRole]);
|
||||||
|
|
|
@ -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',
|
name: 'dashboard',
|
||||||
role: UserRole.User,
|
role: UserRole.User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'wekan',
|
name: 'wekan',
|
||||||
role: UserRole.NoAccess,
|
role: UserRole.User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'wordpress',
|
name: 'wordpress',
|
||||||
role: UserRole.NoAccess,
|
role: UserRole.User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'nextcloud',
|
name: 'nextcloud',
|
||||||
role: UserRole.NoAccess,
|
role: UserRole.User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'zulip',
|
name: 'zulip',
|
||||||
role: UserRole.NoAccess,
|
role: UserRole.User,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -3,5 +3,6 @@ export { Header } from './Header';
|
||||||
export { Table } from './Table';
|
export { Table } from './Table';
|
||||||
export { Banner } from './Banner';
|
export { Banner } from './Banner';
|
||||||
export { Tabs } from './Tabs';
|
export { Tabs } from './Tabs';
|
||||||
export { Modal, ConfirmationModal } from './Modal';
|
export { Modal, ConfirmationModal, StepsModal } from './Modal';
|
||||||
export { UserModal } from './UserModal';
|
export { UserModal } from './UserModal';
|
||||||
|
export { ProgressSteps } from './ProgressSteps';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
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 { CogIcon, TrashIcon } from '@heroicons/react/outline';
|
||||||
import { useUsers } from 'src/services/users';
|
import { useUsers } from 'src/services/users';
|
||||||
import { Table } from 'src/components';
|
import { Table } from 'src/components';
|
||||||
|
@ -8,10 +8,12 @@ import { debounce } from 'lodash';
|
||||||
import { useAuth } from 'src/services/auth';
|
import { useAuth } from 'src/services/auth';
|
||||||
|
|
||||||
import { UserModal } from '../../components/UserModal';
|
import { UserModal } from '../../components/UserModal';
|
||||||
|
import { MultipleUsersModal } from './components';
|
||||||
|
|
||||||
export const Users: React.FC = () => {
|
export const Users: React.FC = () => {
|
||||||
const [selectedRowsIds, setSelectedRowsIds] = useState({});
|
const [selectedRowsIds, setSelectedRowsIds] = useState({});
|
||||||
const [configureModal, setConfigureModal] = useState(false);
|
const [configureModal, setConfigureModal] = useState(false);
|
||||||
|
const [multipleUsersModal, setMultipleUsersModal] = useState(false);
|
||||||
const [userId, setUserId] = useState(null);
|
const [userId, setUserId] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { users, loadUsers, userTableLoading } = useUsers();
|
const { users, loadUsers, userTableLoading } = useUsers();
|
||||||
|
@ -39,8 +41,11 @@ export const Users: React.FC = () => {
|
||||||
setUserId(id);
|
setUserId(id);
|
||||||
setConfigureModal(true);
|
setConfigureModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const configureModalClose = () => setConfigureModal(false);
|
const configureModalClose = () => setConfigureModal(false);
|
||||||
|
|
||||||
|
const multipleUsersModalClose = () => setMultipleUsersModal(false);
|
||||||
|
|
||||||
const columns: any = React.useMemo(
|
const columns: any = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
@ -101,11 +106,19 @@ export const Users: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => configureModalOpen(null)}
|
onClick={() => configureModalOpen(null)}
|
||||||
type="button"
|
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" />
|
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Add new user
|
Add new user
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -166,6 +179,7 @@ export const Users: React.FC = () => {
|
||||||
{configureModal && (
|
{configureModal && (
|
||||||
<UserModal open={configureModal} onClose={configureModalClose} userId={userId} setUserId={setUserId} />
|
<UserModal open={configureModal} onClose={configureModalClose} userId={userId} setUserId={setUserId} />
|
||||||
)}
|
)}
|
||||||
|
{multipleUsersModal && <MultipleUsersModal open={multipleUsersModal} onClose={multipleUsersModalClose} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
1
src/modules/users/components/MultipleUsersModal/index.ts
Normal file
1
src/modules/users/components/MultipleUsersModal/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { MultipleUsersModal } from './MultipleUsersModal';
|
10
src/modules/users/components/MultipleUsersModal/types.ts
Normal file
10
src/modules/users/components/MultipleUsersModal/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { initialAppRoles } from 'src/components/UserModal/consts';
|
||||||
|
|
||||||
|
export type MultipleUsersModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialMultipleUsersForm = {
|
||||||
|
appRoles: initialAppRoles,
|
||||||
|
};
|
1
src/modules/users/components/index.ts
Normal file
1
src/modules/users/components/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { MultipleUsersModal } from './MultipleUsersModal';
|
|
@ -9,6 +9,7 @@ import {
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
clearCurrentUser,
|
clearCurrentUser,
|
||||||
|
createBatchUsers,
|
||||||
} from '../redux';
|
} from '../redux';
|
||||||
import { getUserById, getUserModalLoading, getUserslLoading } from '../redux/selectors';
|
import { getUserById, getUserModalLoading, getUserslLoading } from '../redux/selectors';
|
||||||
|
|
||||||
|
@ -47,6 +48,10 @@ export function useUsers() {
|
||||||
return dispatch(createUser(data));
|
return dispatch(createUser(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createUsers(data: any) {
|
||||||
|
return dispatch(createBatchUsers(data));
|
||||||
|
}
|
||||||
|
|
||||||
function deleteUserById(id: string) {
|
function deleteUserById(id: string) {
|
||||||
return dispatch(deleteUser(id));
|
return dispatch(deleteUser(id));
|
||||||
}
|
}
|
||||||
|
@ -64,5 +69,6 @@ export function useUsers() {
|
||||||
createNewUser,
|
createNewUser,
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
clearSelectedUser,
|
clearSelectedUser,
|
||||||
|
createUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
|
import _ from 'lodash';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { showToast, ToastType } from 'src/common/util/show-toast';
|
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||||
import { State } from 'src/redux/types';
|
import { State } from 'src/redux/types';
|
||||||
import { performApiCall } from 'src/services/api';
|
import { performApiCall } from 'src/services/api';
|
||||||
import { AuthActionTypes } from 'src/services/auth';
|
import { AuthActionTypes } from 'src/services/auth';
|
||||||
import { transformRequestUser, transformUser } from '../transformations';
|
import {
|
||||||
|
transformBatchResponse,
|
||||||
|
transformRequestMultipleUsers,
|
||||||
|
transformRequestUser,
|
||||||
|
transformUser,
|
||||||
|
} from '../transformations';
|
||||||
|
|
||||||
export enum UserActionTypes {
|
export enum UserActionTypes {
|
||||||
FETCH_USERS = 'users/fetch_users',
|
FETCH_USERS = 'users/fetch_users',
|
||||||
|
@ -13,6 +19,7 @@ export enum UserActionTypes {
|
||||||
DELETE_USER = 'users/delete_user',
|
DELETE_USER = 'users/delete_user',
|
||||||
SET_USER_MODAL_LOADING = 'users/user_modal_loading',
|
SET_USER_MODAL_LOADING = 'users/user_modal_loading',
|
||||||
SET_USERS_LOADING = 'users/users_loading',
|
SET_USERS_LOADING = 'users/users_loading',
|
||||||
|
CREATE_BATCH_USERS = 'users/create_batch_users',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||||
|
@ -202,6 +209,44 @@ export const deleteUser = (id: string) => async (dispatch: Dispatch<any>) => {
|
||||||
dispatch(setUserModalLoading(false));
|
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>) => {
|
export const clearCurrentUser = () => (dispatch: Dispatch<any>) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: UserActionTypes.DELETE_USER,
|
type: UserActionTypes.DELETE_USER,
|
||||||
|
|
|
@ -27,6 +27,7 @@ const usersReducer = (state: any = initialUsersState, action: any) => {
|
||||||
case UserActionTypes.FETCH_USER:
|
case UserActionTypes.FETCH_USER:
|
||||||
case UserActionTypes.UPDATE_USER:
|
case UserActionTypes.UPDATE_USER:
|
||||||
case UserActionTypes.CREATE_USER:
|
case UserActionTypes.CREATE_USER:
|
||||||
|
case UserActionTypes.CREATE_BATCH_USERS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
|
|
|
@ -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 => {
|
const transformRoleById = (roleId: any): UserRole => {
|
||||||
switch (roleId) {
|
switch (roleId) {
|
||||||
|
@ -62,3 +63,31 @@ export const transformRequestUser = (data: Pick<User, 'app_roles' | 'name' | 'em
|
||||||
name: data.name ?? '',
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -29,3 +29,8 @@ export interface UserApiRequest {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MultipleUsersData {
|
||||||
|
csvUserData: string;
|
||||||
|
appRoles: AppRoles[];
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue