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 ad493dde0f8ec285bdb7980cc226b0b5067e2593..2cc92984681dc27fabc68665ce8ab44a97834e7e 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,2 +1,3 @@ export { Input } from './Input'; export { Select } from './Select'; +export { TextArea } from './TextArea'; diff --git a/src/components/UserModal/consts.ts b/src/components/UserModal/consts.ts index 7304a419c1f98dd2c6ebced01760f86fda475b81..2229a2309ff472e76a31f80e9f4e7381ff826f36 100644 --- a/src/components/UserModal/consts.ts +++ b/src/components/UserModal/consts.ts @@ -23,26 +23,35 @@ export const appAccessList = [ }, ]; -const initialAppRoles = [ +export const allAppAccessList = [ + ...appAccessList, + { + name: 'dashboard', + image: '/assets/logo-small.svg', + label: 'Dashboard', + }, +]; + +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/modules/users/Users.tsx b/src/modules/users/Users.tsx index 814c4c9719df98498be6bed21a80803929ca653d..41cfafbf3444a685267465d88df1dd9938bee7dc 100644 --- a/src/modules/users/Users.tsx +++ b/src/modules/users/Users.tsx @@ -1,10 +1,10 @@ /* 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'; -import _, { debounce } from 'lodash'; +import { debounce } from 'lodash'; import { useAuth } from 'src/services/auth'; import { UserModal } from '../../components/UserModal'; @@ -41,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( () => [ { @@ -103,7 +106,7 @@ 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 @@ -113,7 +116,7 @@ export const Users: React.FC = () => { 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" > - <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> + <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> Add new users </button> </div> @@ -176,13 +179,7 @@ export const Users: React.FC = () => { {configureModal && ( <UserModal open={configureModal} onClose={configureModalClose} userId={userId} setUserId={setUserId} /> )} - {multipleUsersModal && ( - <MultipleUsersModal - open={multipleUsersModal} - onClose={() => setMultipleUsersModal(false)} - onUpload={_.noop} - /> - )} + {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 index f57e28d938c66a78ce992c787023b75c25d47545..63e3021b47a356f64370abe1cf4634558d52162c 100644 --- a/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx +++ b/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx @@ -1,124 +1,120 @@ -import React, { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react'; +import React from 'react'; import _ from 'lodash'; -// import { TrashIcon } from '@heroicons/react/outline'; -// import { useFieldArray, useForm, useWatch } from 'react-hook-form'; -import { Modal, Tabs } from 'src/components'; -// import { Input, Select } from 'src/components/Form'; -import { useUsers } from 'src/services/users'; -import { useAuth } from 'src/services/auth'; -import { MultipleUsersModalProps } from './types'; -import { csvFileToArray } from './utils'; +import { useFieldArray, useForm } from 'react-hook-form'; -export const MultipleUsersModal = ({ open, onClose, onUpload }: MultipleUsersModalProps) => { - const [file, setFile] = useState<File>(); - const { userModalLoading } = useUsers(); - const { currentUser, isAdmin } = useAuth(); +import { Modal, Tabs } 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 { initialMultipleUsersForm, MultipleUsersModalProps } from './types'; - const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => { - setFile(_.get(e.target, 'files[0]')); - console.log(_.get(e.target, 'files[0]')); - }; +export const MultipleUsersModal = ({ open, onClose }: MultipleUsersModalProps) => { + const { createUsers, userModalLoading } = useUsers(); - const handleSubmit = () => { - // e.preventDefault(); + const { control, handleSubmit } = useForm<MultipleUsersData>({ + defaultValues: initialMultipleUsersForm, + }); - if (file) { - const formData = new FormData(); - formData.append('image', file, file.name); + const { fields } = useFieldArray({ + control, + name: 'appRoles', + }); - // console.log('csv to array', csvFileToArray(file)); - console.log('submit', formData); - } else { - console.log('nothing in file?'); + const handleSave = async () => { + try { + await handleSubmit((data) => createUsers(data))(); + } catch (e: any) { + // Continue } + + onClose(); }; - const renderUpload = () => { + const handleClose = () => { + onClose(); + }; + + const renderUsersCsvDataInput = () => { return ( <div> - <div>Please upload CSV file using , as a delimiter</div> - <div> - <input type="file" id="csvFileInput" accept=".csv" onChange={handleOnChange} /> - <button onClick={handleSubmit}>IMPORT CSV</button> - </div> + <TextArea + control={control} + name="csvUserData" + label="CSV user data" + placeholder="Please paste users in CSV format: email,name" + rows={15} + required + /> </div> ); }; - const renderMultilineInput = () => { + const renderAppAccess = () => { return ( <div> - <textarea - rows={5} - name="description" - className="w-full rounded-md border-gray-700 focus:border-gray-700 shadow-none focus:shadow-slate-800" - > - Enter details here... - </textarea> + <div className="mt-8"> + <h3 className="text-lg leading-6 font-medium text-gray-900">App Access</h3> + </div> + + <div> + <div className="flow-root mt-6"> + <ul className="-my-5 divide-y divide-gray-200 "> + {fields.map((item, index) => { + 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> + <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' }, + ]} + /> + </div> + </div> + </li> + ); + })} + </ul> + </div> + </div> </div> ); }; - const handleSave = async () => { - // try { - // if (userId) { - // await handleSubmit((data) => editUserById(data))(); - // } else { - // await handleSubmit((data) => createNewUser(data))(); - // } - // } catch (e: any) { - // // Continue - // } - - onUpload(); - - onClose(); - }; - - const handleClose = () => { - onClose(); - }; - return ( - <> - <Modal - onClose={handleClose} - open={open} - onSave={handleSave} - isLoading={userModalLoading} - // leftActions={ - // userId && - // user.email !== currentUser?.email && ( - // <button - // onClick={deleteModalOpen} - // type="button" - // className="mb-4 sm:mb-0 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" - // > - // <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - // Delete - // </button> - // ) - // } - useCancelButton - > - <div className="bg-white px-4"> - <div className="space-y-10 divide-y divide-gray-200"> + <Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={userModalLoading} useCancelButton> + <div className="bg-white px-4"> + <div className="space-y-10 divide-y divide-gray-200"> + <div> <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: renderMultilineInput() }, - { name: 'Import from file', component: renderUpload() }, - ]} - /> - </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> </div> </div> - </Modal> - </> + </div> + </Modal> ); }; diff --git a/src/modules/users/components/MultipleUsersModal/types.ts b/src/modules/users/components/MultipleUsersModal/types.ts index 2a3a28076cb437cf7c729f665841c89de6343bd5..643f10cc604b86b9e8a5222db4863299f61fa042 100644 --- a/src/modules/users/components/MultipleUsersModal/types.ts +++ b/src/modules/users/components/MultipleUsersModal/types.ts @@ -1,5 +1,10 @@ +import { initialAppRoles } from 'src/components/UserModal/consts'; + export type MultipleUsersModalProps = { open: boolean; onClose: () => void; - onUpload: () => void; +}; + +export const initialMultipleUsersForm = { + appRoles: initialAppRoles, }; diff --git a/src/modules/users/components/MultipleUsersModal/utils.ts b/src/modules/users/components/MultipleUsersModal/utils.ts deleted file mode 100644 index 7934b5d770dfda94bd12d19a376e90e4c3f342af..0000000000000000000000000000000000000000 --- a/src/modules/users/components/MultipleUsersModal/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { User } from 'src/services/users'; - -export const csvFileToArray = (csvData: string) => { - const csvRows = csvData.slice(csvData.indexOf('\n') + 1).split('\n'); - - const array = csvRows.map((i) => { - const values = i.split(','); - const email = values[0]; - const name = values[1]; - return { email, name, app_roles: [], preferredUsername: '', status: '', id: '' } as User; - }); - - return array; -}; diff --git a/src/services/users/hooks/use-users.ts b/src/services/users/hooks/use-users.ts index 0e5dd35c1f1233fa06e9702f08da866a83109c29..f5079b831e7877f9242e1602f52730b975b99a28 100644 --- a/src/services/users/hooks/use-users.ts +++ b/src/services/users/hooks/use-users.ts @@ -7,6 +7,7 @@ import { createUser, deleteUser, clearCurrentUser, + createBatchUsers, } from '../redux'; import { getUserById, getUserModalLoading, getUserslLoading } from '../redux/selectors'; @@ -37,6 +38,10 @@ export function useUsers() { return dispatch(createUser(data)); } + function createUsers(data: any) { + return dispatch(createBatchUsers(data)); + } + function deleteUserById(id: string) { return dispatch(deleteUser(id)); } @@ -52,5 +57,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 7c350342383da1f03645b464b97da178a9ef5998..4ac3932f115353c04ee84271190760dab228d0f9 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>) => { @@ -154,6 +161,51 @@ 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 + const createdUsersNumber = _.size(responseData.createdUsers); + if (createdUsersNumber > 0) { + showToast( + `${_.size(responseData.createdUsers)} ${createdUsersNumber > 1 ? 'users' : 'user'} created successfully.`, + ToastType.Success, + ); + } + const notCreatedUsersNumber = _.size(responseData.notCreatedUsers); + if (notCreatedUsersNumber > 0) { + showToast( + `${_.size(responseData.notCreatedUsers)} + ${notCreatedUsersNumber > 1 ? 'users' : 'user'} not created with + ${notCreatedUsersNumber > 1 ? 'emails' : 'email'}: \n${responseData.notCreatedUsers.join(',\n')}`, + ToastType.Error, + ); + } + + 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..65b869c34b5ea3c2c691c808bfb01a14115979c4 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,30 @@ 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 { + createdUsers: response.created_users, + notCreatedUsers: response.not_created_users, + }; +}; 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[]; +}