diff --git a/src/apps/admin/src/AdminApp.tsx b/src/apps/admin/src/AdminApp.tsx index dcd12601e..564392814 100644 --- a/src/apps/admin/src/AdminApp.tsx +++ b/src/apps/admin/src/AdminApp.tsx @@ -3,7 +3,7 @@ import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' -import { Layout, SWRConfigProvider } from './lib' +import { AdminAppContextProvider, Layout, SWRConfigProvider } from './lib' import { toolTitle } from './admin-app.routes' import './lib/styles/index.scss' @@ -24,12 +24,14 @@ const AdminApp: FC = () => { return (
- - - - {childRoutes} - - + + + + + {childRoutes} + + +
) } diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 15e41fb27..4bd9d9b24 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -10,6 +10,7 @@ import { import { manageChallengeRouteId, manageReviewRouteId, + permissionManagementRouteId, rootRoute, userManagementRouteId, } from './config/routes.config' @@ -42,6 +43,33 @@ const ManageReviewerPage: LazyLoadedComponent = lazyLoad( () => import('./review-management/ManageReviewerPage'), 'ManageReviewerPage', ) +const PermissionManagement: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionManagement'), +) +const PermissionRolesPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionRolesPage'), + 'PermissionRolesPage', +) +const PermissionRoleMembersPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionRoleMembersPage'), + 'PermissionRoleMembersPage', +) +const PermissionAddRoleMembersPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionAddRoleMembersPage'), + 'PermissionAddRoleMembersPage', +) +const PermissionGroupsPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionGroupsPage'), + 'PermissionGroupsPage', +) +const PermissionGroupMembersPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionGroupMembersPage'), + 'PermissionGroupMembersPage', +) +const PermissionAddGroupMembersPage: LazyLoadedComponent = lazyLoad( + () => import('./permission-management/PermissionAddGroupMembersPage'), + 'PermissionAddGroupMembersPage', +) export const toolTitle: string = ToolTitle.admin @@ -96,6 +124,44 @@ export const adminRoutes: ReadonlyArray = [ id: manageReviewRouteId, route: manageReviewRouteId, }, + // Permission Management Module + { + children: [ + { + element: , + id: 'permission-roles-page', + route: 'roles', + }, + { + element: , + id: 'permission-role-members-page', + route: 'roles/:roleId/role-members', + }, + { + element: , + id: 'permission-add-role-members-page', + route: 'roles/:roleId/role-members/add', + }, + { + element: , + id: 'permission-groups-page', + route: 'groups', + }, + { + element: , + id: 'permission-group-members-page', + route: 'groups/:groupId/group-members', + }, + { + element: , + id: 'permission-add-group-members-page', + route: 'groups/:groupId/group-members/add', + }, + ], + element: , + id: permissionManagementRouteId, + route: permissionManagementRouteId, + }, ], domain: AppSubdomain.admin, element: , diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index 7006e66ed..7c2792843 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -11,3 +11,4 @@ export const rootRoute: string export const manageChallengeRouteId = 'challenge-management' export const manageReviewRouteId = 'review-management' export const userManagementRouteId = 'user-management' +export const permissionManagementRouteId = 'permission-management' diff --git a/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.module.scss b/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.module.scss new file mode 100644 index 000000000..8923fcf3a --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.module.scss @@ -0,0 +1,33 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.blockRadios { + display: flex; + flex-direction: column; + gap: 18px; +} + +.dialogLoadingSpinnerContainer { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: 0; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.tsx b/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.tsx new file mode 100644 index 000000000..0eb7fc623 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddGroup/DialogAddGroup.tsx @@ -0,0 +1,179 @@ +/** + * Dialog add group. + */ +import { FC, useCallback } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { + BaseModal, + Button, + InputCheckbox, + InputText, + InputTextarea, + LoadingSpinner, +} from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { FormAddGroup } from '../../models' +import { formAddGroupSchema } from '../../utils' + +import styles from './DialogAddGroup.module.scss' + +interface Props { + className?: string + open: boolean + setOpen: (isOpen: boolean) => void + onSubmitForm?: (filter: FormAddGroup) => void + isLoading?: boolean +} + +export const DialogAddGroup: FC = (props: Props) => { + const handleClose = useCallback(() => { + if (!props.isLoading) { + props.setOpen(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.isLoading]) + const { + register, + handleSubmit, + control, + formState: { errors, isValid }, + }: UseFormReturn = useForm({ + defaultValues: { + description: '', + name: '', + privateGroup: false, + selfRegister: false, + }, + mode: 'all', + resolver: yupResolver(formAddGroupSchema), + }) + const onSubmit = useCallback( + (data: FormAddGroup) => { + props.onSubmitForm?.(data) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onSubmitForm], + ) + + return ( + +
+
+ + +
+ + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> +
+
+
+ + +
+ + {props.isLoading && ( +
+ +
+ )} +
+
+ ) +} + +export default DialogAddGroup diff --git a/src/apps/admin/src/lib/components/DialogAddGroup/index.ts b/src/apps/admin/src/lib/components/DialogAddGroup/index.ts new file mode 100644 index 000000000..d12c6082e --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddGroup/index.ts @@ -0,0 +1 @@ +export { default as DialogAddGroup } from './DialogAddGroup' diff --git a/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.module.scss b/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.module.scss new file mode 100644 index 000000000..9dcb088b9 --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.module.scss @@ -0,0 +1,34 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px 30px; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.blockBottom { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 30px; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + align-items: flex-end; + } +} diff --git a/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.tsx b/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.tsx new file mode 100644 index 000000000..ae74bf66a --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersFilters/GroupMembersFilters.tsx @@ -0,0 +1,203 @@ +/** + * Group members filters ui. + */ +import { FC, useCallback } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, InputDatePicker, InputText } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { FormGroupMembersFilters } from '../../models' +import { formGroupMembersFiltersSchema } from '../../utils' + +import styles from './GroupMembersFilters.module.scss' + +interface Props { + className?: string + onSubmitForm?: (filter: FormGroupMembersFilters) => void + isLoading?: boolean + memberType: string +} + +export const GroupMembersFilters: FC = props => { + const { + register, + handleSubmit, + control, + formState: { isValid }, + }: UseFormReturn = useForm({ + defaultValues: {}, + mode: 'all', + resolver: yupResolver(formGroupMembersFiltersSchema), + }) + const onSubmit = useCallback( + (data: FormGroupMembersFilters) => { + props.onSubmitForm?.(data) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onSubmitForm], + ) + + return ( +
+
+ + + + + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> +
+ +
+ +
+
+ ) +} + +export default GroupMembersFilters diff --git a/src/apps/admin/src/lib/components/GroupMembersFilters/index.ts b/src/apps/admin/src/lib/components/GroupMembersFilters/index.ts new file mode 100644 index 000000000..93d477d58 --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersFilters/index.ts @@ -0,0 +1 @@ +export { default as GroupMembersFilters } from './GroupMembersFilters' diff --git a/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.module.scss b/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.module.scss new file mode 100644 index 000000000..7f454d76e --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.module.scss @@ -0,0 +1,34 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-4 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } + + th:first-child { + padding-left: 16px !important; + + @include ltemd { + padding-left: 5px !important; + } + } +} + +.blockRightColumn { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} + +.blockCellCheckBox { + width: 52px; +} + +.blockCellWrap { + white-space: break-spaces !important; +} diff --git a/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.tsx b/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.tsx new file mode 100644 index 000000000..f163f4c30 --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersTable/GroupMembersTable.tsx @@ -0,0 +1,383 @@ +/** + * Group members table. + */ +import { FC, useCallback, useEffect, useMemo } from 'react' +import _ from 'lodash' +import classNames from 'classnames' + +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Button, InputCheckbox, Table, TableColumn } from '~/libs/ui' + +import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { UserGroupMember, UserMappingType } from '../../models' +import { MobileTableColumn } from '../../models/MobileTableColumn.model' +import { Pagination } from '../common/Pagination' +import { TableMobile } from '../common/TableMobile' + +import styles from './GroupMembersTable.module.scss' + +interface Props { + className?: string + memberType: string + usersMapping: UserMappingType + groupsMapping: UserMappingType + datas: UserGroupMember[] + onChangeDatas?: (datas: UserGroupMember[]) => void + selectedDatas: { + [id: number]: boolean + } + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + toggleSelect: (key: number) => void + forceSelect: (key: number) => void + forceUnSelect: (key: number) => void + doRemoveGroupMember: (memberId: number) => void +} + +export const GroupMembersTable: FC = (props: Props) => { + const { + page, + setPage, + totalPages, + results, + setSort, + sort, + }: useTableFilterLocalProps = useTableFilterLocal( + props.datas ?? [], + undefined, + { + createdAtString: 'createdAt', + updatedAtString: 'updatedAt', + }, + ) + + const isSelectAll = useMemo( + () => _.every(results, item => props.selectedDatas[item.memberId]), + [results, props.selectedDatas], + ) + + const toggleSelectAll = useCallback(() => { + if (isSelectAll) { + _.forEach(results, item => { + props.forceUnSelect(item.memberId) + }) + } else { + _.forEach(results, item => { + props.forceSelect(item.memberId) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSelectAll, results]) + + useEffect(() => { + props.onChangeDatas?.(results) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [results]) + + const columns = useMemo[]>( + () => [ + { + className: styles.blockCellCheckBox, + label: () => ( // eslint-disable-line react/no-unstable-nested-components +
+ +
+ ), + renderer: (data: UserGroupMember) => ( + + ), + type: 'element', + }, + { + className: styles.blockCellWrap, + label: `${props.memberType} ID`, + propertyName: 'memberId', + type: 'text', + }, + { + className: styles.blockCellWrap, + label: props.memberType === 'user' ? 'Handle' : 'Group Name', + propertyName: 'name', + renderer: (data: UserGroupMember) => { + let name = '' + if (!data.memberId) { + name = '' + } else if (props.memberType === 'group') { + name = !props.groupsMapping[data.memberId] + ? 'loading...' + : props.groupsMapping[data.memberId] + } else if (props.memberType === 'user') { + name = !props.usersMapping[data.memberId] + ? 'loading...' + : props.usersMapping[data.memberId] + } + + return <>{name} + }, + type: 'element', + }, + { + className: styles.blockCellWrap, + label: 'Created By', + propertyName: 'createdByHandle', + renderer: (data: UserGroupMember) => { + if (!data.createdBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.createdBy] + ? 'loading...' + : props.usersMapping[data.createdBy]} + + ) + }, + type: 'element', + }, + { + label: 'Created at', + propertyName: 'createdAtString', + type: 'text', + }, + { + className: styles.blockCellWrap, + label: 'Modified By', + propertyName: 'updatedByHandle', + renderer: (data: UserGroupMember) => { + if (!data.updatedBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.updatedBy] + ? 'loading...' + : props.usersMapping[data.updatedBy]} + + ) + }, + type: 'element', + }, + { + label: 'Modified at', + propertyName: 'updatedAtString', + type: 'text', + }, + { + label: '', + renderer: (data: UserGroupMember) => ( + + ), + type: 'action', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + props.memberType, + props.groupsMapping, + props.usersMapping, + props.selectedDatas, + props.isRemovingBool, + props.isRemoving, + isSelectAll, + props.doRemoveGroupMember, + toggleSelectAll, + ], + ) + + const columnsMobile = useMemo< + MobileTableColumn[][] + >(() => [ + [ + columns[0], + { + colSpan: 2, + label: `${props.memberType} ID`, + propertyName: 'id', + renderer: (data: UserGroupMember) => { + let name = '' + if (!data.memberId) { + name = '' + } else if (props.memberType === 'group') { + name = !props.groupsMapping[data.memberId] + ? 'loading...' + : props.groupsMapping[data.memberId] + } else if (props.memberType === 'user') { + name = !props.usersMapping[data.memberId] + ? 'loading...' + : props.usersMapping[data.memberId] + } + + return ( + <> + {data.memberId} + {' '} + | + {' '} + {name} + + ) + }, + type: 'element', + }, + ], + [ + { + label: 'empty cell', + propertyName: 'id', + renderer: () => <>, + type: 'element', + }, + { + label: 'Created By label', + mobileType: 'label', + propertyName: 'createdBy', + renderer: () =>
Created by:
, + type: 'element', + }, + { + ...columns[3], + className: classNames( + columns[3].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'empty cell', + propertyName: 'id', + renderer: () => <>, + type: 'element', + }, + { + label: 'Created At label', + mobileType: 'label', + propertyName: 'createdAt', + renderer: () =>
Created at:
, + type: 'element', + }, + { + ...columns[4], + className: classNames( + columns[4].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'empty cell', + propertyName: 'id', + renderer: () => <>, + type: 'element', + }, + { + label: 'Modified By label', + mobileType: 'label', + propertyName: 'updatedBy', + renderer: () => ( +
Modified By:
+ ), + type: 'element', + }, + { + ...columns[5], + className: classNames( + columns[5].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'empty cell', + propertyName: 'id', + renderer: () => <>, + type: 'element', + }, + { + label: 'Modified at label', + mobileType: 'label', + propertyName: 'updatedAd', + renderer: () => ( +
Modified at:
+ ), + type: 'element', + }, + { + ...columns[6], + className: classNames( + columns[6].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + ...columns[7], + className: classNames( + columns[7].className, + styles.blockRightColumn, + ), + colSpan: 3, + }, + ], + ], [ + columns, + props.groupsMapping, + props.memberType, + props.usersMapping, + ]) + + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 1120, [screenWidth]) + + return ( +
+ {isTablet ? ( + + ) : ( + + )} + + + + ) +} + +export default GroupMembersTable diff --git a/src/apps/admin/src/lib/components/GroupMembersTable/index.ts b/src/apps/admin/src/lib/components/GroupMembersTable/index.ts new file mode 100644 index 000000000..8dcd50315 --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupMembersTable/index.ts @@ -0,0 +1 @@ +export { default as GroupMembersTable } from './GroupMembersTable' diff --git a/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.module.scss b/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.module.scss new file mode 100644 index 000000000..e5c11762f --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.module.scss @@ -0,0 +1,38 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: 0 $sp-8; + + @include ltelg { + padding: 0 $sp-4; + } + + a { + color: $blue-110; + + &:hover { + color: $blue-110; + } + } + + th:first-child { + padding-left: 16px !important; + + @include ltemd { + padding-left: 5px !important; + } + } +} + +.blockRightColumn { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} + +.blockCellWrap { + white-space: break-spaces !important; +} diff --git a/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.tsx b/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.tsx new file mode 100644 index 000000000..ba313e200 --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupsTable/GroupsTable.tsx @@ -0,0 +1,249 @@ +/** + * Groups table. + */ +import { FC, useMemo } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' + +import { Table, TableColumn } from '~/libs/ui' +import { useWindowSize, WindowSize } from '~/libs/shared' + +import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { MobileTableColumn } from '../../models/MobileTableColumn.model' +import { UserGroup, UserMappingType } from '../../models' +import { Pagination } from '../common/Pagination' +import { TableMobile } from '../common/TableMobile' + +import styles from './GroupsTable.module.scss' + +interface Props { + className?: string + datas: UserGroup[] + usersMapping: UserMappingType +} + +export const GroupsTable: FC = (props: Props) => { + const columns = useMemo[]>( + () => [ + { + label: 'Group ID', + propertyName: 'id', + renderer: (data: UserGroup) => ( +
+ {data.id} +
+ ), + type: 'element', + }, + { + label: 'Name', + propertyName: 'name', + renderer: (data: UserGroup) => ( +
+ {data.name} +
+ ), + type: 'element', + }, + { + className: styles.blockCellWrap, + label: 'Description', + propertyName: 'description', + type: 'text', + }, + { + label: 'Created By', + propertyName: 'createdByHandle', + renderer: (data: UserGroup) => { + if (!data.createdBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.createdBy] + ? 'loading...' + : props.usersMapping[data.createdBy]} + + ) + }, + type: 'element', + }, + { + label: 'Created at', + propertyName: 'createdAtString', + type: 'text', + }, + { + label: 'Modified By', + propertyName: 'updatedByHandle', + renderer: (data: UserGroup) => { + if (!data.updatedBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.updatedBy] + ? 'loading...' + : props.usersMapping[data.updatedBy]} + + ) + }, + type: 'element', + }, + { + label: 'Modified at', + propertyName: 'updatedAtString', + type: 'text', + }, + ], + [props.usersMapping], + ) + + const columnsMobile = useMemo[][]>(() => [ + [ + { + colSpan: 2, + label: 'Group ID', + propertyName: 'id', + renderer: (data: UserGroup) => ( + + + {data.id} + + {' '} + | + {' '} + + {data.name} + + + ), + type: 'element', + }, + ], + [ + { + label: 'Description label', + mobileType: 'label', + propertyName: 'description', + renderer: () =>
Description:
, + type: 'element', + }, + { + ...columns[2], + className: classNames( + columns[2].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Created By label', + mobileType: 'label', + propertyName: 'createdBy', + renderer: () =>
Created by:
, + type: 'element', + }, + { + ...columns[3], + className: classNames( + columns[3].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Created At label', + mobileType: 'label', + propertyName: 'createdAt', + renderer: () =>
Created at:
, + type: 'element', + }, + { + ...columns[4], + className: classNames( + columns[4].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Modified By label', + mobileType: 'label', + propertyName: 'updatedBy', + renderer: () =>
Modified By:
, + type: 'element', + }, + { + ...columns[5], + className: classNames( + columns[5].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Modified at label', + mobileType: 'label', + propertyName: 'updatedAd', + renderer: () =>
Modified at:
, + type: 'element', + }, + { + ...columns[6], + className: classNames( + columns[6].className, + styles.blockRightColumn, + ), + }, + ], + ], [columns]) + + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 1150, [screenWidth]) + + const { + page, + setPage, + totalPages, + results, + setSort, + sort, + }: useTableFilterLocalProps = useTableFilterLocal( + props.datas ?? [], + undefined, + { + createdAtString: 'createdAt', + updatedAtString: 'updatedAt', + }, + ) + + return ( +
+ {isTablet ? ( + + ) : ( +
+ )} + + + ) +} + +export default GroupsTable diff --git a/src/apps/admin/src/lib/components/GroupsTable/index.ts b/src/apps/admin/src/lib/components/GroupsTable/index.ts new file mode 100644 index 000000000..bf0617c4c --- /dev/null +++ b/src/apps/admin/src/lib/components/GroupsTable/index.ts @@ -0,0 +1 @@ +export { default as GroupsTable } from './GroupsTable' diff --git a/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.module.scss b/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.module.scss new file mode 100644 index 000000000..5a3b5c669 --- /dev/null +++ b/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.module.scss @@ -0,0 +1,33 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.selectUserHandlesTitle { + font-weight: 700; + font-size: 14px; + padding-bottom: 2px; +} + +.selectUserHandlesCustomMultiValue { + font-size: 13px; + padding: 0 8px; + margin-right: 6px; + color: $black-60; + background-color: $black-10; + border-radius: 4px; + + &.invalid { + color: $red-100; + } + + .label { + font-weight: 700; + } +} + +.selectUserHandlesDropdownContainer { + z-index: 9999 !important; +} diff --git a/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.tsx b/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.tsx new file mode 100644 index 000000000..2e43627cd --- /dev/null +++ b/src/apps/admin/src/lib/components/InputGroupSelector/InputGroupSelector.tsx @@ -0,0 +1,78 @@ +/** + * Input handles selector. + */ +import { FC, useMemo } from 'react' +import ReactSelect, { MultiValue, MultiValueProps } from 'react-select' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import { SelectOption } from '../../models' + +import styles from './InputGroupSelector.module.scss' + +interface Props { + label?: string + className?: string + placeholder?: string + readonly value?: SelectOption[] + readonly onChange?: (event: SelectOption[]) => void + readonly disabled?: boolean + readonly isLoading?: boolean + readonly options: SelectOption[] +} + +const CustomMultiValue = ( + props: MultiValueProps, +): JSX.Element => ( +
+ {props.data.label} + + + +
+) + +export const InputGroupSelector: FC = (props: Props) => { + const components = useMemo( + () => ({ + DropdownIndicator: undefined, + MultiValue: CustomMultiValue, + }), + [], + ) + + return ( +
+
+ {props.label ?? 'Group IDs'} +
+ styles.select, + menuPortal: () => styles.selectUserHandlesDropdownContainer, + }} + classNamePrefix={styles.sel} + onChange={function onChange(value: MultiValue) { + props.onChange?.( + value.map(v => ({ + label: v.label, + value: v.value, + })), + ) + }} + value={props.value} + options={props.options} + isDisabled={props.disabled} + isLoading={props.isLoading} + /> +
+ ) +} + +export default InputGroupSelector diff --git a/src/apps/admin/src/lib/components/InputGroupSelector/index.ts b/src/apps/admin/src/lib/components/InputGroupSelector/index.ts new file mode 100644 index 000000000..adc423ed6 --- /dev/null +++ b/src/apps/admin/src/lib/components/InputGroupSelector/index.ts @@ -0,0 +1 @@ +export * from './InputGroupSelector' diff --git a/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss new file mode 100644 index 000000000..5a3b5c669 --- /dev/null +++ b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss @@ -0,0 +1,33 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.selectUserHandlesTitle { + font-weight: 700; + font-size: 14px; + padding-bottom: 2px; +} + +.selectUserHandlesCustomMultiValue { + font-size: 13px; + padding: 0 8px; + margin-right: 6px; + color: $black-60; + background-color: $black-10; + border-radius: 4px; + + &.invalid { + color: $red-100; + } + + .label { + font-weight: 700; + } +} + +.selectUserHandlesDropdownContainer { + z-index: 9999 !important; +} diff --git a/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.tsx b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.tsx new file mode 100644 index 000000000..0835fbb34 --- /dev/null +++ b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.tsx @@ -0,0 +1,110 @@ +/** + * Input handles selector. + */ +import { + FC, + useMemo, +} from 'react' +import { MultiValue, MultiValueProps } from 'react-select' +import _ from 'lodash' +import AsyncSelect from 'react-select/async' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import { SearchUserInfo, SelectOption } from '../../models' +import { getMemberSuggestionsByHandle } from '../../services' + +import styles from './InputHandlesSelector.module.scss' + +interface Props { + label?: string + className?: string + placeholder?: string + readonly value?: SearchUserInfo[] + readonly onChange?: (event: SearchUserInfo[]) => void + readonly disabled?: boolean +} + +const CustomMultiValue = ( + props: MultiValueProps, +): JSX.Element => ( +
+ {props.data.label} + + + +
+) + +const mapDataToInputOption = (data: SearchUserInfo): SelectOption => ({ + ...data, + label: data.handle, + value: data.userId, +}) + +async function autoCompleteDatas(queryTerm: string): Promise { + if (!queryTerm) { + return Promise.resolve([]) + } + + return getMemberSuggestionsByHandle(queryTerm) +} + +const fetchDatas = ( + queryTerm: string, + callback: (options: SelectOption[]) => void, +): void => { + autoCompleteDatas(queryTerm) + .then(datas => { + callback( + datas.map(data => ({ + label: data.handle, + value: data.userId, + })), + ) + }) +} + +const fetchDatasDebounce = _.debounce(fetchDatas, 300) + +export const InputHandlesSelector: FC = (props: Props) => { + const components = useMemo( + () => ({ + DropdownIndicator: undefined, + MultiValue: CustomMultiValue, + }), + [], + ) + + return ( +
+
{props.label ?? 'Handle'}
+ styles.select, + menuPortal: () => styles.selectUserHandlesDropdownContainer, + }} + classNamePrefix={styles.sel} + onChange={function onChange(value: MultiValue) { + props.onChange?.( + value.map(v => ({ + handle: v.label, + userId: v.value as number, + })), + ) + }} + value={props.value?.map(mapDataToInputOption)} + loadOptions={fetchDatasDebounce} + isDisabled={props.disabled} + /> +
+ ) +} + +export default InputHandlesSelector diff --git a/src/apps/admin/src/lib/components/InputHandlesSelector/index.ts b/src/apps/admin/src/lib/components/InputHandlesSelector/index.ts new file mode 100644 index 000000000..5b08462fb --- /dev/null +++ b/src/apps/admin/src/lib/components/InputHandlesSelector/index.ts @@ -0,0 +1 @@ +export * from './InputHandlesSelector' diff --git a/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.module.scss b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.module.scss new file mode 100644 index 000000000..92a63f9a4 --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.module.scss @@ -0,0 +1,34 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: 0 $sp-8; + + @include ltelg { + padding: 0 $sp-4; + } +} + +.fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px 30px; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.blockBottom { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 30px; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + align-items: flex-end; + } +} diff --git a/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx new file mode 100644 index 000000000..fded1fc95 --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx @@ -0,0 +1,86 @@ +/** + * Role members filters ui. + */ +import { FC, useCallback } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, InputText } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { FormRoleMembersFilters } from '../../models' +import { formRoleMembersFiltersSchema } from '../../utils' + +import styles from './RoleMembersFilters.module.scss' + +interface Props { + className?: string + onSubmitForm?: (filter: FormRoleMembersFilters) => void + isLoading?: boolean +} + +export const RoleMembersFilters: FC = props => { + const { + register, + handleSubmit, + formState: { isValid }, + }: UseFormReturn = useForm({ + defaultValues: {}, + mode: 'all', + resolver: yupResolver(formRoleMembersFiltersSchema), + }) + const onSubmit = useCallback( + (data: FormRoleMembersFilters) => { + props.onSubmitForm?.(data) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onSubmitForm], + ) + + return ( +
+
+ + +
+ +
+ +
+ + ) +} + +export default RoleMembersFilters diff --git a/src/apps/admin/src/lib/components/RoleMembersFilters/index.ts b/src/apps/admin/src/lib/components/RoleMembersFilters/index.ts new file mode 100644 index 000000000..e25d1b696 --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersFilters/index.ts @@ -0,0 +1 @@ +export { default as RoleMembersFilters } from './RoleMembersFilters' diff --git a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.module.scss b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.module.scss new file mode 100644 index 000000000..bcfb6832a --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: 0 $sp-8; + + @include ltelg { + padding: 0 $sp-4; + } + + th:first-child { + padding-left: 16px !important; + + @include ltemd { + padding-left: 5px !important; + } + } +} + +.removeSelectionButtonContainer { + padding: 20px 0 30px; + + + @include ltemd { + text-align: center; + } +} + +.blockRightColumn { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} + +.blockCellCheckBox { + width: 52px; +} diff --git a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx new file mode 100644 index 000000000..eb1e53f1f --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx @@ -0,0 +1,228 @@ +/** + * Role members table. + */ +import { FC, useContext, useEffect, useMemo } from 'react' +import _ from 'lodash' +import classNames from 'classnames' + +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Button, InputCheckbox, Table, TableColumn } from '~/libs/ui' + +import { AdminAppContext } from '../../contexts' +import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { AdminAppContextType, RoleMemberInfo } from '../../models' +import { MobileTableColumn } from '../../models/MobileTableColumn.model' +import { Pagination } from '../common/Pagination' +import { TableMobile } from '../common/TableMobile' +import { useTableSelection, useTableSelectionProps } from '../../hooks/useTableSelection' + +import styles from './RoleMembersTable.module.scss' + +interface Props { + className?: string + datas: RoleMemberInfo[] + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + doRemoveRoleMember: (roleMember: RoleMemberInfo) => void + doRemoveRoleMembers: (roleMemberIds: string[], callback: () => void) => void +} + +export const RoleMembersTable: FC = (props: Props) => { + const { loadUser, usersMapping, cancelLoadUser }: AdminAppContextType + = useContext(AdminAppContext) + const { + page, + setPage, + totalPages, + results, + setSort, + sort, + }: useTableFilterLocalProps = useTableFilterLocal( + props.datas ?? [], + ) + const datasIds = useMemo(() => results.map(item => item.id), [results]) + const { + selectedDatas, + selectedDatasArray, + toggleSelect, + hasSelected, + isSelectAll, + toggleSelectAll, + unselectAll, + }: useTableSelectionProps = useTableSelection(datasIds) + + useEffect(() => { + // clear queue of currently loading user handles + cancelLoadUser() + // load user handles for members visible on the current page + _.forEach(results, result => { + loadUser(result.id) + }) + + return () => { + // clear queue of currently loading user handles after exit ui + cancelLoadUser() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [results]) + + useEffect(() => { + _.forEach(results, result => { + result.handle = usersMapping[result.id] + }) + }, [usersMapping, results]) + + const columns = useMemo[]>( + () => [ + { + className: styles.blockCellCheckBox, + label: () => ( // eslint-disable-line react/no-unstable-nested-components +
+ +
+ ), + renderer: (data: RoleMemberInfo) => ( + + ), + type: 'element', + }, + { + label: 'User ID', + propertyName: 'id', + type: 'text', + }, + { + label: 'Handle', + propertyName: 'handle', + renderer: (data: RoleMemberInfo) => { + if (!data.id) { + return <> + } + + return ( + <> + {!usersMapping[data.id] + ? 'loading...' + : usersMapping[data.id]} + + ) + }, + type: 'element', + }, + { + className: styles.blockColumnAction, + label: '', + renderer: (data: RoleMemberInfo) => ( + + ), + type: 'action', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + isSelectAll, + selectedDatas, + usersMapping, + props.isRemoving, + props.isRemovingBool, + props.doRemoveRoleMember, + toggleSelect, + toggleSelectAll, + ], + ) + + const columnsMobile = useMemo[][]>(() => [ + [ + columns[0], + { + label: 'User ID', + propertyName: 'id', + renderer: (data: RoleMemberInfo) => ( + + {data.id} + {' '} + | + {' '} + {data.handle} + + ), + type: 'element', + }, + ], + [ + { + ...columns[3], + className: classNames( + columns[3].className, + styles.blockRightColumn, + ), + colSpan: 2, + }, + ], + ], [columns]) + + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) + + return ( +
+ {isTablet ? ( + + ) : ( +
+ )} + +
+ +
+ + + ) +} + +export default RoleMembersTable diff --git a/src/apps/admin/src/lib/components/RoleMembersTable/index.ts b/src/apps/admin/src/lib/components/RoleMembersTable/index.ts new file mode 100644 index 000000000..8ae30c564 --- /dev/null +++ b/src/apps/admin/src/lib/components/RoleMembersTable/index.ts @@ -0,0 +1 @@ +export { default as RoleMembersTable } from './RoleMembersTable' diff --git a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.module.scss b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.module.scss new file mode 100644 index 000000000..e2b0c8f60 --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.module.scss @@ -0,0 +1,43 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.fields { + display: flex; + gap: 15px; + align-items: flex-start; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + gap: 0; + align-items: flex-end; + } +} + +.field { + flex: 1; + max-width: 500px; + + @include ltelg { + width: 100%; + } + + @include ltemd { + max-width: none; + } +} + +.blockBottom { + display: flex; + gap: 10px; + margin-top: 3px; +} diff --git a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx new file mode 100644 index 000000000..5374cb2bf --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx @@ -0,0 +1,113 @@ +/** + * Roles filter ui. + */ +import { FC, useCallback, useEffect } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, InputText } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { FormRolesFilter, TableRolesFilter } from '../../models' +import { formRolesFilterSchema } from '../../utils' + +import styles from './RolesFilter.module.scss' + +interface Props { + className?: string + isLoading?: boolean + isAdding?: boolean + setFilters: (filterDatas: TableRolesFilter) => void + doAddRole: (roleName: string, success: () => void) => void +} + +const defaultValues: FormRolesFilter = { + roleName: '', +} + +export const RolesFilter: FC = props => { + const { + register, + handleSubmit, + watch, + reset, + formState: { isValid }, + }: UseFormReturn = useForm({ + defaultValues: { + roleName: '', + }, + mode: 'all', + resolver: yupResolver(formRolesFilterSchema), + }) + const onSubmit = useCallback( + (data: FormRolesFilter) => { + props.doAddRole(data.roleName, () => { + reset({ + roleName: '', + }) + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.doAddRole], + ) + + const roleName = watch('roleName') + + useEffect(() => { + props.setFilters({ + createdAtString: roleName, + createdByHandle: roleName, + id: roleName, + modifiedAtString: roleName, + modifiedByHandle: roleName, + roleName, + }) + }, [roleName]) // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+
+ + +
+ + +
+
+ + ) +} + +export default RolesFilter diff --git a/src/apps/admin/src/lib/components/RolesFilter/index.ts b/src/apps/admin/src/lib/components/RolesFilter/index.ts new file mode 100644 index 000000000..9efdd6f50 --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesFilter/index.ts @@ -0,0 +1 @@ +export { default as RolesFilter } from './RolesFilter' diff --git a/src/apps/admin/src/lib/components/RolesTable/RolesTable.module.scss b/src/apps/admin/src/lib/components/RolesTable/RolesTable.module.scss new file mode 100644 index 000000000..8b0f302d9 --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesTable/RolesTable.module.scss @@ -0,0 +1,43 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: 0 $sp-8; + + @include ltelg { + padding: 0 $sp-4; + } + + a { + color: $blue-110; + + &:hover { + color: $blue-110; + } + } + + th:first-child { + padding-left: 16px !important; + + @include ltemd { + padding-left: 5px !important; + } + } +} + +.blockRightColumn { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} + +.blockCellWrap { + white-space: break-spaces !important; +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} diff --git a/src/apps/admin/src/lib/components/RolesTable/RolesTable.tsx b/src/apps/admin/src/lib/components/RolesTable/RolesTable.tsx new file mode 100644 index 000000000..baf0e48ac --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesTable/RolesTable.tsx @@ -0,0 +1,237 @@ +/** + * Roles table. + */ +import { FC, useMemo } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' + +import { Table, TableColumn } from '~/libs/ui' +import { useWindowSize, WindowSize } from '~/libs/shared' + +import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { MSG_NO_RECORD_FOUND } from '../../../config/index.config' +import { TableMobile } from '../common/TableMobile' +import { MobileTableColumn } from '../../models/MobileTableColumn.model' +import { UserMappingType, UserRole } from '../../models' +import { Pagination } from '../common/Pagination' + +import styles from './RolesTable.module.scss' + +interface Props { + className?: string + datas: UserRole[] + usersMapping: UserMappingType +} + +export const RolesTable: FC = (props: Props) => { + const columns = useMemo[]>( + () => [ + { + label: 'Role ID', + propertyName: 'id', + renderer: (data: UserRole) => ( +
+ {data.id} +
+ ), + type: 'element', + }, + { + label: 'Role Name', + propertyName: 'roleName', + renderer: (data: UserRole) => ( +
+ + {data.roleName} + +
+ ), + type: 'element', + }, + { + className: styles.blockCellWrap, + label: 'Created By', + propertyName: 'createdByHandle', + renderer: (data: UserRole) => { + if (!data.createdBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.createdBy] + ? 'loading...' + : props.usersMapping[data.createdBy]} + + ) + }, + type: 'element', + }, + { + label: 'Created at', + propertyName: 'createdAtString', + type: 'text', + }, + { + className: styles.blockCellWrap, + label: 'Modified By', + propertyName: 'modifiedByHandle', + renderer: (data: UserRole) => { + if (!data.modifiedBy) { + return <> + } + + return ( + <> + {!props.usersMapping[data.modifiedBy] + ? 'loading...' + : props.usersMapping[data.modifiedBy]} + + ) + }, + type: 'element', + }, + { + label: 'Modified at', + propertyName: 'modifiedAtString', + type: 'text', + }, + ], + [props.usersMapping], + ) + + const columnsMobile = useMemo[][]>(() => [ + [ + { + colSpan: 2, + label: 'Role ID', + propertyName: 'id', + renderer: (data: UserRole) => ( + + + {data.id} + + {' '} + | + {' '} + + {data.roleName} + + + ), + type: 'element', + }, + ], + [ + { + label: 'Created By label', + mobileType: 'label', + propertyName: 'createdBy', + renderer: () =>
Created by:
, + type: 'element', + }, + { + ...columns[2], + className: classNames( + columns[2].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Created At label', + mobileType: 'label', + propertyName: 'createdAt', + renderer: () =>
Created at:
, + type: 'element', + }, + { + ...columns[3], + className: classNames( + columns[3].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Modified By label', + mobileType: 'label', + propertyName: 'modifiedBy', + renderer: () =>
Modified By:
, + type: 'element', + }, + { + ...columns[4], + className: classNames( + columns[4].className, + styles.blockRightColumn, + ), + }, + ], + [ + { + label: 'Modified at label', + mobileType: 'label', + propertyName: 'modifiedAt', + renderer: () =>
Modified at:
, + type: 'element', + }, + { + ...columns[5], + className: classNames( + columns[5].className, + styles.blockRightColumn, + ), + }, + ], + ], [columns]) + const { + page, + setPage, + totalPages, + results, + setSort, + sort, + }: useTableFilterLocalProps = useTableFilterLocal( + props.datas ?? [], + undefined, + { + createdAtString: 'createdAt', + modifiedAtString: 'modifiedAt', + }, + ) + + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 984, [screenWidth]) + + if (results.length === 0) { + return

{MSG_NO_RECORD_FOUND}

+ } + + return ( +
+ {isTablet ? ( + + ) : ( +
+ )} + + + + ) +} + +export default RolesTable diff --git a/src/apps/admin/src/lib/components/RolesTable/index.ts b/src/apps/admin/src/lib/components/RolesTable/index.ts new file mode 100644 index 000000000..a572d0e47 --- /dev/null +++ b/src/apps/admin/src/lib/components/RolesTable/index.ts @@ -0,0 +1 @@ +export { default as RolesTable } from './RolesTable' diff --git a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx index d42334f2a..9d5f943c8 100644 --- a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx +++ b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx @@ -64,7 +64,12 @@ const DropdownMenu: FC> = props => { }, ) - useOnScroll({ onScroll: () => setOpen(false), target: triggerRef.current }) + useOnScroll( + { + onScroll: () => { setOpen(false) }, + target: triggerRef.current, + }, + ) const context = { open, setOpen } @@ -72,7 +77,7 @@ const DropdownMenu: FC> = props => { <>
{props.trigger?.(context)} {props.triggerUI} @@ -84,6 +89,7 @@ const DropdownMenu: FC> = props => { style={{ ...popper.styles.popper, width: `${props.width || triggerRef.current?.clientWidth}px`, + zIndex: 1, }} {...popper.attributes.popper} > diff --git a/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss b/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss index 68e35ae7f..283bc6d49 100644 --- a/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss +++ b/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss @@ -3,7 +3,6 @@ .layout { position: relative; - min-height: calc(100vh - #{$header-height}); .main { margin: $sp-8 0; @@ -20,3 +19,8 @@ .contentLayoutOuter { margin: $sp-8 auto !important; } + +.contantentLayoutInner { + box-sizing: border-box; + width: 100%; +} diff --git a/src/apps/admin/src/lib/components/common/Layout/Layout.tsx b/src/apps/admin/src/lib/components/common/Layout/Layout.tsx index 1c420f2c7..3e249d083 100644 --- a/src/apps/admin/src/lib/components/common/Layout/Layout.tsx +++ b/src/apps/admin/src/lib/components/common/Layout/Layout.tsx @@ -11,7 +11,10 @@ export const NullLayout: FC = props => ( ) export const Layout: FC = props => ( - +
diff --git a/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx b/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx index d9f7af2d3..90726857a 100644 --- a/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx +++ b/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx @@ -15,6 +15,7 @@ interface PaginationProps { } const Pagination: FC = (props: PaginationProps) => { + const totalPages = props.totalPages || 1 const MAX_PAGE_DISPLAY = 5 const MAX_PAGE_MOBILE_DISPLAY = 3 const { width: screenWidth }: WindowSize = useWindowSize() @@ -41,21 +42,27 @@ const Pagination: FC = (props: PaginationProps) => { return displayPages.slice(start, end) }, [displayPages, props.page, screenWidth]) // eslint-disable-line react-hooks/exhaustive-deps, max-len -- unneccessary dependency: screenWidth - const createDisplayPages = useCallback(() => { + const createDisplayPages = useCallback((reset: boolean) => { + // eslint-disable-next-line complexity setDisplayPages(oldDisplayPages => { - if (oldDisplayPages.includes(props.page)) { - return [...oldDisplayPages] + let expectedDisplayPages = oldDisplayPages + if (expectedDisplayPages.includes(props.page) && !reset) { + return [...expectedDisplayPages] + } + + if (reset) { + expectedDisplayPages = [] } // Initial - if (oldDisplayPages.length === 0) { + if (expectedDisplayPages.length === 0) { const pages = [] for ( let i = props.page - MAX_PAGE_DISPLAY + 1; i <= props.page + MAX_PAGE_DISPLAY; i++ ) { - if (i >= 1 && i <= props.totalPages && pages.length < MAX_PAGE_DISPLAY) { + if (i >= 1 && i <= totalPages && pages.length < MAX_PAGE_DISPLAY) { pages.push(i) } } @@ -64,7 +71,7 @@ const Pagination: FC = (props: PaginationProps) => { } // Go next - if (props.page > oldDisplayPages[oldDisplayPages.length - 1]) { + if (props.page > expectedDisplayPages[expectedDisplayPages.length - 1]) { const pages = [] for ( let i = props.page - MAX_PAGE_DISPLAY + 1; @@ -80,7 +87,7 @@ const Pagination: FC = (props: PaginationProps) => { } // Go previous - if (props.page < oldDisplayPages[0] && props.page >= 1) { + if (props.page < expectedDisplayPages[0] && props.page >= 1) { const pages = [] for ( let i = props.page; @@ -93,16 +100,20 @@ const Pagination: FC = (props: PaginationProps) => { return pages } - return [...oldDisplayPages] + return [...expectedDisplayPages] }) - }, [props.page, props.totalPages]) + }, [props.page, totalPages]) + + useEffect(() => { + createDisplayPages(true) + }, [totalPages]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - createDisplayPages() - }, [createDisplayPages]) + createDisplayPages(false) + }, [props.page]) // eslint-disable-line react-hooks/exhaustive-deps const createHandlePageClick = (p: number) => () => { - if (p === 0 || p > props.totalPages || p === props.page) { + if (p === 0 || p > totalPages || p === props.page) { return } @@ -112,7 +123,7 @@ const Pagination: FC = (props: PaginationProps) => { const handleFirstClick = useEventCallback(() => props.onPageChange(1)) const handlePreviousClick = useEventCallback(() => props.onPageChange(props.page - 1)) const handleNextClick = useEventCallback(() => props.onPageChange(props.page + 1)) - const handleLastClick = useEventCallback(() => props.onPageChange(props.totalPages)) + const handleLastClick = useEventCallback(() => props.onPageChange(totalPages)) return (
@@ -156,10 +167,10 @@ const Pagination: FC = (props: PaginationProps) => { icon={IconOutline.ChevronRightIcon} iconToRight label='NEXT' - disabled={props.page === props.totalPages || props.disabled} + disabled={props.page === totalPages || props.disabled} className={styles.next} /> - {!Number.isNaN(props.totalPages) && ( + {!Number.isNaN(totalPages) && (
+ + {props.data.map((itemData, indexData) => ( + + {props.columns.map((itemColumns, indexColumns) => ( + + {itemColumns.map( + (itemItemColumns, indexItemColumns) => ( + + ), + )} + + ))} + + ))} + +
+ ) + +export default TableMobile diff --git a/src/apps/admin/src/lib/components/common/TableMobile/index.ts b/src/apps/admin/src/lib/components/common/TableMobile/index.ts new file mode 100644 index 000000000..bf8dd5417 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableMobile/index.ts @@ -0,0 +1 @@ +export { default as TableMobile } from './TableMobile' diff --git a/src/apps/admin/src/lib/contexts/AdminAppContextProvider.tsx b/src/apps/admin/src/lib/contexts/AdminAppContextProvider.tsx new file mode 100644 index 000000000..5c289a07c --- /dev/null +++ b/src/apps/admin/src/lib/contexts/AdminAppContextProvider.tsx @@ -0,0 +1,77 @@ +/** + * Context provider for admin app + */ +import { + Context, + createContext, + FC, + PropsWithChildren, + useEffect, + useMemo, +} from 'react' +import _ from 'lodash' + +import { AdminAppContextType } from '../models' +import { useLoadGroup, useLoadGroupProps, useLoadUser, useLoadUserProps } from '../hooks' + +export const AdminAppContext: Context + = createContext({ + cancelLoadGroup: _.noop, + cancelLoadUser: _.noop, + groupsMapping: {}, + loadGroup: _.noop, + loadUser: _.noop, + setGroupFromSearch: _.noop, + setUserFromSearch: _.noop, + usersMapping: {}, + }) + +export const AdminAppContextProvider: FC = props => { + const { + loadUser, + setUserFromSearch, + usersMapping, + cancelLoadUser, + }: useLoadUserProps = useLoadUser() + const { + loadGroup, + groupsMapping, + cancelLoadGroup, + setGroupFromSearch, + }: useLoadGroupProps = useLoadGroup() + const value = useMemo( + () => ({ + cancelLoadGroup, + cancelLoadUser, + groupsMapping, + loadGroup, + loadUser, + setGroupFromSearch, + setUserFromSearch, + usersMapping, + }), + [ + loadUser, + setUserFromSearch, + usersMapping, + cancelLoadUser, + groupsMapping, + loadGroup, + cancelLoadGroup, + setGroupFromSearch, + ], + ) + + useEffect(() => () => { + // clear queue of currently loading user handles after exit ui + cancelLoadUser() + // clear queue of currently loading group handles after exit ui + cancelLoadGroup() + }, [cancelLoadUser, cancelLoadGroup]) + + return ( + + {props.children} + + ) +} diff --git a/src/apps/admin/src/lib/contexts/index.ts b/src/apps/admin/src/lib/contexts/index.ts index 6170d45c5..322973320 100644 --- a/src/apps/admin/src/lib/contexts/index.ts +++ b/src/apps/admin/src/lib/contexts/index.ts @@ -1,3 +1,4 @@ export * from './SWRConfigProvider' export * from './ChallengeManagementContextProvider' export * from './ReviewManagementContextProvider' +export * from './AdminAppContextProvider' diff --git a/src/apps/admin/src/lib/hooks/index.ts b/src/apps/admin/src/lib/hooks/index.ts index adfaee6c4..ed14fa33e 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -7,3 +7,11 @@ export * from './useManageUserRoles' export * from './useTableFilterLocal' export * from './useManageUserTerms' export * from './useManageUserAchievements' +export * from './useManagePermissionRoles' +export * from './useManagePermissionRoleMembers' +export * from './useManageAddRoleMembers' +export * from './useManagePermissionGroups' +export * from './useLoadUser' +export * from './useLoadGroup' +export * from './useManagePermissionGroupMembers' +export * from './useManageAddRoleMembers' diff --git a/src/apps/admin/src/lib/hooks/useLoadGroup.ts b/src/apps/admin/src/lib/hooks/useLoadGroup.ts new file mode 100644 index 000000000..0df92ca92 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useLoadGroup.ts @@ -0,0 +1,90 @@ +/** + * Fetch and save group info + */ + +import { useCallback, useRef, useState } from 'react' +import _ from 'lodash' + +import { SelectOption, UserIdType, UserMappingType } from '../models' +import { findGroupById } from '../services' + +export interface useLoadGroupProps { + groupsMapping: UserMappingType // from group id to group handle + loadGroup: (groupId: UserIdType) => void + cancelLoadGroup: () => void + setGroupFromSearch: (options: SelectOption[]) => void +} + +/** + * Fetch and save group info + * @returns group info + */ +export function useLoadGroup(): useLoadGroupProps { + const [groupsMapping, setGroupsMapping] = useState({}) + const groupsMappingRef = useRef({}) + const groupLoadQueue = useRef([]) + const isLoading = useRef(false) + + const setGroupFromSearch = useCallback((options: SelectOption[]) => { + if (options.length > 0) { + _.forEach(options, option => { + groupsMappingRef.current[option.label] = option.value as string + }) + setGroupsMapping({ + ...groupsMappingRef.current, + }) + } + }, []) + + const fetchNextGroupInQueue = useCallback(() => { + if (isLoading.current || !groupLoadQueue.current.length) { + return + } + + const nextGroupId = groupLoadQueue.current[0] + groupLoadQueue.current = groupLoadQueue.current.slice(1) + if (groupsMappingRef.current[nextGroupId]) { + fetchNextGroupInQueue() + return + } + + isLoading.current = true + findGroupById(`${nextGroupId}`) + .then(res => { + groupsMappingRef.current[nextGroupId] = res.name + setGroupsMapping({ + ...groupsMappingRef.current, + }) + }) + .catch(() => { + groupsMappingRef.current[ + nextGroupId + ] = `${nextGroupId} (not found)` + setGroupsMapping({ + ...groupsMappingRef.current, + }) + }) + .finally(() => { + isLoading.current = false + fetchNextGroupInQueue() + }) + }, []) + + const loadGroup = useCallback((groupId: UserIdType) => { + if (groupId && !groupsMappingRef.current[groupId]) { + groupLoadQueue.current.push(groupId) + fetchNextGroupInQueue() + } + }, [fetchNextGroupInQueue]) + + const cancelLoadGroup = useCallback(() => { + groupLoadQueue.current = [] + }, []) + + return { + cancelLoadGroup, + groupsMapping, + loadGroup, + setGroupFromSearch, + } +} diff --git a/src/apps/admin/src/lib/hooks/useLoadUser.ts b/src/apps/admin/src/lib/hooks/useLoadUser.ts new file mode 100644 index 000000000..3a1936084 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useLoadUser.ts @@ -0,0 +1,90 @@ +/** + * Fetch and save user info + */ +import { useCallback, useRef, useState } from 'react' +import _ from 'lodash' + +import { SearchUserInfo, UserIdType, UserMappingType } from '../models' +import { findUserById } from '../services' + +export interface useLoadUserProps { + usersMapping: UserMappingType // from user id to user handle + loadUser: (userId: UserIdType) => void + cancelLoadUser: () => void + setUserFromSearch: (userHandles: SearchUserInfo[]) => void +} + +/** + * Fetch and save user info + * @returns user info + */ +export function useLoadUser(): useLoadUserProps { + const [usersMapping, setUsersMapping] = useState({}) + const usersMappingRef = useRef({}) + const userLoadQueue = useRef([]) + const isLoading = useRef(false) + + const setUserFromSearch = useCallback((userHandles: SearchUserInfo[]) => { + if (userHandles.length > 0) { + _.forEach(userHandles, userHandle => { + usersMappingRef.current[`${userHandle.userId}`] + = userHandle.handle + }) + setUsersMapping({ + ...usersMappingRef.current, + }) + } + }, []) + + const fetchNextUserInQueue = useCallback(() => { + if (isLoading.current || !userLoadQueue.current.length) { + return + } + + const nextUserId = userLoadQueue.current[0] + userLoadQueue.current = userLoadQueue.current.slice(1) + if (usersMappingRef.current[nextUserId]) { + fetchNextUserInQueue() + return + } + + isLoading.current = true + findUserById(nextUserId) + .then(res => { + usersMappingRef.current[nextUserId] = res.handle + setUsersMapping({ + ...usersMappingRef.current, + }) + }) + .catch(() => { + usersMappingRef.current[ + nextUserId + ] = `${nextUserId} (not found)` + setUsersMapping({ + ...usersMappingRef.current, + }) + }) + .finally(() => { + isLoading.current = false + fetchNextUserInQueue() + }) + }, []) + + const loadUser = useCallback((userId: UserIdType) => { + if (userId && !usersMappingRef.current[userId]) { + userLoadQueue.current.push(userId) + fetchNextUserInQueue() + } + }, [fetchNextUserInQueue]) + + const cancelLoadUser = useCallback(() => { + userLoadQueue.current = [] + }, []) + + return { + cancelLoadUser, + loadUser, + setUserFromSearch, + usersMapping, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageAddGroupMembers.ts b/src/apps/admin/src/lib/hooks/useManageAddGroupMembers.ts new file mode 100644 index 000000000..d2c47617a --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAddGroupMembers.ts @@ -0,0 +1,134 @@ +/** + * Manage permission add group members redux state + */ +import { useCallback, useReducer } from 'react' +import { toast } from 'react-toastify' + +import { addGroupMember } from '../services' +import { handleError } from '../utils' + +/// ///////////////// +// Permission add group members reducer +/// //////////////// + +type RolesState = { + isAdding: boolean +} + +const RolesActionType = { + ADD_ROLE_MEMBERS_DONE: 'ADD_ROLE_MEMBERS_DONE' as const, + ADD_ROLE_MEMBERS_FAILED: 'ADD_ROLE_MEMBERS_FAILED' as const, + ADD_ROLE_MEMBERS_INIT: 'ADD_ROLE_MEMBERS_INIT' as const, +} + +type RolesReducerAction = { + type: + | typeof RolesActionType.ADD_ROLE_MEMBERS_DONE + | typeof RolesActionType.ADD_ROLE_MEMBERS_INIT + | typeof RolesActionType.ADD_ROLE_MEMBERS_FAILED +} + +const reducer = ( + previousState: RolesState, + action: RolesReducerAction, +): RolesState => { + switch (action.type) { + case RolesActionType.ADD_ROLE_MEMBERS_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case RolesActionType.ADD_ROLE_MEMBERS_DONE: { + return { + ...previousState, + isAdding: false, + } + } + + case RolesActionType.ADD_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManageAddGroupMembersProps { + isAdding: boolean + doAddGroup: ( + membershipType: string, + memberIds: string[], + callBack: () => void, + ) => void +} + +/** + * Manage permission add group members redux state + * @param groupId group id + * @returns state data + */ +export function useManageAddGroupMembers( + groupId: string, +): useManageAddGroupMembersProps { + const [state, dispatch] = useReducer(reducer, { + isAdding: false, + }) + + const doAddGroup = useCallback( + (membershipType: string, memberIds: string[], callBack: () => void) => { + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_INIT, + }) + let hasSubmissionErrors = false + Promise.all( + memberIds.map(async item => addGroupMember( + groupId, + { + memberId: item, + membershipType: membershipType as 'user' | 'group', + }, + ) + .catch(e => { + hasSubmissionErrors = true + handleError(e) + })), + ) + .then(() => { + if (!hasSubmissionErrors) { + toast.success( + `${ + memberIds.length > 1 ? 'Groups' : 'Group' + } added successfully`, + { + toastId: 'Add groups', + }, + ) + callBack() + } + + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, groupId], + ) + + return { + doAddGroup, + isAdding: state.isAdding, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageAddRoleMembers.ts b/src/apps/admin/src/lib/hooks/useManageAddRoleMembers.ts new file mode 100644 index 000000000..d82844913 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAddRoleMembers.ts @@ -0,0 +1,194 @@ +/** + * Manage permission add role members redux state + */ +import { useCallback, useEffect, useReducer, useRef } from 'react' +import { toast } from 'react-toastify' + +import { SearchUserInfo, UserRole } from '../models' +import { assignRole, fetchRole } from '../services' +import { handleError } from '../utils' + +/// ///////////////// +// Permission role Members reducer +/// //////////////// + +type RolesState = { + isLoading: boolean + isAdding: boolean + roleInfo?: UserRole +} + +const RolesActionType = { + ADD_ROLE_MEMBERS_DONE: 'ADD_ROLE_MEMBERS_DONE' as const, + ADD_ROLE_MEMBERS_FAILED: 'ADD_ROLE_MEMBERS_FAILED' as const, + ADD_ROLE_MEMBERS_INIT: 'ADD_ROLE_MEMBERS_INIT' as const, + FETCH_ROLE_MEMBERS_DONE: 'FETCH_ROLE_MEMBERS_DONE' as const, + FETCH_ROLE_MEMBERS_FAILED: 'FETCH_ROLE_MEMBERS_FAILED' as const, + FETCH_ROLE_MEMBERS_INIT: 'FETCH_ROLE_MEMBERS_INIT' as const, +} + +type RolesReducerAction = + | { + type: + | typeof RolesActionType.ADD_ROLE_MEMBERS_DONE + | typeof RolesActionType.ADD_ROLE_MEMBERS_INIT + | typeof RolesActionType.ADD_ROLE_MEMBERS_FAILED + | typeof RolesActionType.FETCH_ROLE_MEMBERS_INIT + | typeof RolesActionType.FETCH_ROLE_MEMBERS_FAILED + } + | { + type: typeof RolesActionType.FETCH_ROLE_MEMBERS_DONE + payload: UserRole + } + +const reducer = ( + previousState: RolesState, + action: RolesReducerAction, +): RolesState => { + switch (action.type) { + case RolesActionType.FETCH_ROLE_MEMBERS_INIT: { + return { + ...previousState, + isLoading: true, + } + } + + case RolesActionType.FETCH_ROLE_MEMBERS_DONE: { + const roleInfo = action.payload + return { + ...previousState, + isLoading: false, + roleInfo, + } + } + + case RolesActionType.FETCH_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case RolesActionType.ADD_ROLE_MEMBERS_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case RolesActionType.ADD_ROLE_MEMBERS_DONE: { + return { + ...previousState, + isAdding: false, + } + } + + case RolesActionType.ADD_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManageAddRoleMembersProps { + isLoading: boolean + isAdding: boolean + roleInfo?: UserRole + doAddRole: (userHandles: SearchUserInfo[], callBack: () => void) => void +} + +/** + * Manage permission add role members redux state + * @param roleId role id + * @returns state data + */ +export function useManageAddRoleMembers( + roleId: string, +): useManageAddRoleMembersProps { + const [state, dispatch] = useReducer(reducer, { + isAdding: false, + isLoading: false, + }) + const isLoadingRef = useRef(false) + + const doFetchRole = useCallback(() => { + dispatch({ + type: RolesActionType.FETCH_ROLE_MEMBERS_INIT, + }) + isLoadingRef.current = true + fetchRole(roleId, ['id', 'roleName']) + .then(result => { + isLoadingRef.current = false + dispatch({ + payload: result, + type: RolesActionType.FETCH_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + isLoadingRef.current = false + dispatch({ + type: RolesActionType.FETCH_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + }, [dispatch, roleId]) + + const doAddRole = useCallback( + (userHandles: SearchUserInfo[], callBack: () => void) => { + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_INIT, + }) + let hasSubmissionErrors = false + Promise.all( + userHandles.map(async item => assignRole(roleId, `${item.userId}`) + .catch(e => { + hasSubmissionErrors = true + handleError(e) + })), + ) + .then(() => { + if (!hasSubmissionErrors) { + toast.success( + `${ + userHandles.length > 1 ? 'Roles' : 'Role' + } added successfully`, + { + toastId: 'Add roles', + }, + ) + callBack() + } + + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + dispatch({ + type: RolesActionType.ADD_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, roleId], + ) + + useEffect(() => { + if (!isLoadingRef.current) { + doFetchRole() + } + }, [roleId, doFetchRole]) + + return { + doAddRole, + isAdding: state.isAdding, + isLoading: state.isLoading, + roleInfo: state.roleInfo, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManagePermissionGroupMembers.ts b/src/apps/admin/src/lib/hooks/useManagePermissionGroupMembers.ts new file mode 100644 index 000000000..ec466edf3 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManagePermissionGroupMembers.ts @@ -0,0 +1,707 @@ +/** + * Manage permission group members redux state + */ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' +import moment from 'moment' + +import { + FormGroupMembersFilters, + UserGroup, + UserGroupMember, + UserIdType, + UserInfo, + UserMappingType, +} from '../models' +import { fetchGroupMembers, fetchGroups, removeGroupMember, searchUsers } from '../services' +import { handleError } from '../utils' + +/// ///////////////// +// Permission group members reducer +/// //////////////// + +type GroupsState = { + isLoading: boolean + isFiltering: { [key: string]: boolean } + isRemoving: { [key: string]: boolean } + filteredGroupMembers: { [memberType: string]: UserGroupMember[] } + allGroupMembers: { [memberType: string]: UserGroupMember[] } +} + +const GroupsActionType = { + FETCH_GROUP_MEMBERS_DONE: 'FETCH_GROUP_MEMBERS_DONE' as const, + FETCH_GROUP_MEMBERS_FAILED: 'FETCH_GROUP_MEMBERS_FAILED' as const, + FETCH_GROUP_MEMBERS_INIT: 'FETCH_GROUP_MEMBERS_INIT' as const, + FILTER_GROUP_MEMBERS_DONE: 'FILTER_GROUP_MEMBERS_DONE' as const, + FILTER_GROUP_MEMBERS_FAILED: 'FILTER_GROUP_MEMBERS_FAILED' as const, + FILTER_GROUP_MEMBERS_INIT: 'FILTER_GROUP_MEMBERS_INIT' as const, + REMOVE_GROUP_MEMBERS_DONE: 'REMOVE_GROUP_MEMBERS_DONE' as const, + REMOVE_GROUP_MEMBERS_FAILED: 'REMOVE_GROUP_MEMBERS_FAILED' as const, + REMOVE_GROUP_MEMBERS_INIT: 'REMOVE_GROUP_MEMBERS_INIT' as const, +} +// used to get all groups +const PAGE = 1 +const PER_PAGE = 5000 + +type GroupsReducerAction = + | { + type: + | typeof GroupsActionType.FETCH_GROUP_MEMBERS_INIT + | typeof GroupsActionType.FETCH_GROUP_MEMBERS_FAILED + } + | { + type: + | typeof GroupsActionType.FILTER_GROUP_MEMBERS_INIT + | typeof GroupsActionType.FILTER_GROUP_MEMBERS_FAILED + payload: string + } + | { + type: typeof GroupsActionType.FILTER_GROUP_MEMBERS_DONE + payload: { memberType: string; datas: UserGroupMember[] } + } + | { + type: typeof GroupsActionType.FETCH_GROUP_MEMBERS_DONE + payload: { [memberType: string]: UserGroupMember[] } + } + | { + type: + | typeof GroupsActionType.REMOVE_GROUP_MEMBERS_DONE + | typeof GroupsActionType.REMOVE_GROUP_MEMBERS_INIT + | typeof GroupsActionType.REMOVE_GROUP_MEMBERS_FAILED + payload: number + } + +const reducer = ( + previousState: GroupsState, + action: GroupsReducerAction, +): GroupsState => { + switch (action.type) { + case GroupsActionType.FETCH_GROUP_MEMBERS_INIT: { + return { + ...previousState, + allGroupMembers: {}, + filteredGroupMembers: {}, + isLoading: true, + } + } + + case GroupsActionType.FETCH_GROUP_MEMBERS_DONE: { + return { + ...previousState, + allGroupMembers: action.payload, + filteredGroupMembers: action.payload, + isLoading: false, + } + } + + case GroupsActionType.FETCH_GROUP_MEMBERS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case GroupsActionType.FILTER_GROUP_MEMBERS_INIT: { + return { + ...previousState, + isFiltering: { + ...previousState.isFiltering, + [action.payload]: true, + }, + } + } + + case GroupsActionType.FILTER_GROUP_MEMBERS_DONE: { + return { + ...previousState, + filteredGroupMembers: { + ...previousState.filteredGroupMembers, + [action.payload.memberType]: action.payload.datas, + }, + isFiltering: { + ...previousState.isFiltering, + [action.payload.memberType]: false, + }, + } + } + + case GroupsActionType.FILTER_GROUP_MEMBERS_FAILED: { + return { + ...previousState, + isFiltering: { + ...previousState.isFiltering, + [action.payload]: false, + }, + } + } + + case GroupsActionType.REMOVE_GROUP_MEMBERS_INIT: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: true, + }, + } + } + + case GroupsActionType.REMOVE_GROUP_MEMBERS_DONE: { + _.forOwn(previousState.allGroupMembers, (value, key) => { + previousState.allGroupMembers[key] = _.filter( + previousState.allGroupMembers[key], + group => group.memberId !== action.payload, + ) + }) + _.forOwn(previousState.filteredGroupMembers, (value, key) => { + previousState.filteredGroupMembers[key] = _.filter( + previousState.filteredGroupMembers[key], + group => group.memberId !== action.payload, + ) + }) + return { + ...previousState, + allGroupMembers: { + ...previousState.allGroupMembers, + }, + filteredGroupMembers: { + ...previousState.filteredGroupMembers, + }, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + case GroupsActionType.REMOVE_GROUP_MEMBERS_FAILED: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + default: { + return previousState + } + } +} + +/** + * Find group id by its name + * + * This is helper function which return value in special shape + * along with group ids array it return label defined by 'valueType' + * + * This function just retrieves all the groups and after resolves names by + * searching in group list client side. + * @param groupName group name + * @param valueType label of the returned value + * @returns resolved to object {value: , type: valueType} + */ +async function getGroupIdsFilteredByName(groupName: string, valueType: string): Promise<{ + type: string; + value: string[]; +}> { + const groupNameKey = groupName.toLowerCase() + + return fetchGroups({ + page: PAGE, + perPage: PER_PAGE, + }) + .then((groups: UserGroup[]) => { + const filteredGroups = _.filter( + groups, + (group: UserGroup) => _.includes(group.name.toLowerCase(), groupNameKey), + ) + + return { + type: valueType, + value: _.map(filteredGroups, 'id'), + } + }) +} + +/** + * Find user id by its handle + * + * This is helper function which return value in special shape + * along with user ids array it return label defined by 'valueType' + * + * This function makes request for each user handle and saves resolved promise, + * so second time server request is not being sent to the server for the same user handle. + * @param userHandle user handle + * @param valueType label of the returned value + * @returns resolved to object {value: , type: valueType} + */ +async function getUserIdsFilteredByHandle( + userHandle: string, + valueType: string, +): Promise<{ + type: string; + value: string[]; +}> { + return searchUsers({ + fields: 'id', + filter: `handle=*${userHandle}*&like=true`, + limit: 1000000, // set big limit to make sure server returns all records + }) + .then((users: UserInfo[]) => _.map(users, 'id')) + .then((userIds: string[]) => ({ + type: valueType, + value: userIds, + })) +} + +/** + * Helper function which performs all the requests to the server which are required to filter membership tables + * @param filterCriteria filter criteria + * @param memberType member type + * @param filteredMembers list of membership to filter + * @returns resolves to filtered membership list + */ +async function filterWithRequests( + filterCriteria: FormGroupMembersFilters, + memberType: string, + filteredMembers: UserGroupMember[], +): Promise { + let filteredMembersResults = filteredMembers + // list of all the server requests which we have to make to filter members + const requests = [] + + // as on client side we don't know user handles for member, createdBy, modifiedBy users + // and we don't group names + // to filter by them we have to get their according user ids and group ids + // so we create requests to the server + + if (memberType === 'group' && filterCriteria.memberName) { + requests.push( + getGroupIdsFilteredByName(filterCriteria.memberName, 'memberName'), + ) + } + + if (memberType === 'user' && filterCriteria.memberName) { + requests.push( + getUserIdsFilteredByHandle(filterCriteria.memberName, 'memberName'), + ) + } + + if (filterCriteria.createdBy) { + requests.push( + getUserIdsFilteredByHandle(filterCriteria.createdBy, 'createdBy'), + ) + } + + if (filterCriteria.modifiedBy) { + requests.push( + getUserIdsFilteredByHandle(filterCriteria.modifiedBy, 'modifiedBy'), + ) + } + + // after we get all ids from the server we can filter data client side + return Promise.all(requests) + .then((ids: { + type: string; + value: string[]; + }[]) => { + const idsObj: { [type: string]: string[] } = {} + + ids.forEach((result: { + type: string; + value: string[]; + }) => { + idsObj[result.type] = result.value + }) + + // memberName + if (filterCriteria.memberName) { + if (!idsObj.memberName.length) { + filteredMembersResults = [] + } else { + filteredMembersResults = _.filter( + filteredMembersResults, + (membership: UserGroupMember) => _.includes( + idsObj.memberName, + membership.memberId.toString(), + ), + ) + } + } + + // createdBy + if (filterCriteria.createdBy) { + if (!idsObj.createdBy) { + filteredMembersResults = [] + } else { + filteredMembersResults = _.filter( + filteredMembersResults, + (membership: UserGroupMember) => ( + membership.createdBy + && _.includes( + idsObj.createdBy, + membership.createdBy.toString(), + ) + ), + ) as UserGroupMember[] + } + } + + // modifiedBy + if (filterCriteria.modifiedBy) { + if (!idsObj.modifiedBy) { + filteredMembersResults = [] + } else { + filteredMembersResults = _.filter( + filteredMembersResults, + (membership: UserGroupMember) => ( + !!membership.updatedBy + && _.includes( + idsObj.modifiedBy, + membership.updatedBy.toString(), + ) + ), + ) as UserGroupMember[] + } + } + + return filteredMembersResults + }) +} + +export interface useManagePermissionGroupMembersProps { + isLoading: boolean + isFiltering: { [key: string]: boolean } + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + groupMembers: { [memberType: string]: UserGroupMember[] } + doFilterGroupMembers: ( + filterCriteria: FormGroupMembersFilters, + memberType: string, + ) => void + doRemoveGroupMember: (memberId: number) => void + doRemoveGroupMembers: ( + memberIds: number[], + callBack: () => void, + ) => void +} + +/** + * Manage permission group members redux state + * @param groupId group id + * @param loadUsers load list of users function + * @param cancelLoadUser cancel load users + * @param usersMapping mapping user id to user handle + * @param loadGroups load list of group function + * @param cancelLoadGroup cancel load groups + * @param groupsMapping mapping group id to group name + * @returns state data + */ +export function useManagePermissionGroupMembers( + groupId: string, + loadUser: (userId: UserIdType) => void, + cancelLoadUser: () => void, + usersMapping: UserMappingType, // from user id to user handle + loadGroup: (userId: UserIdType) => void, + cancelLoadGroup: () => void, + groupsMapping: UserMappingType, // from group id to group name +): useManagePermissionGroupMembersProps { + const memberTypes = useMemo(() => ['group', 'user'], []) + const [state, dispatch] = useReducer(reducer, { + allGroupMembers: {}, + filteredGroupMembers: {}, + isFiltering: {}, + isLoading: false, + isRemoving: {}, + }) + const isLoadingRef = useRef(false) + const isRemovingBool = useMemo( + () => _.some(state.isRemoving, value => value === true), + [state.isRemoving], + ) + + const doFetchGroup = useCallback(() => { + dispatch({ + type: GroupsActionType.FETCH_GROUP_MEMBERS_INIT, + }) + isLoadingRef.current = true + fetchGroupMembers(groupId, { page: PAGE, perPage: PER_PAGE }) + .then(result => { + isLoadingRef.current = false + + const data: { [memberType: string]: UserGroupMember[] } = {} + _.forEach(memberTypes, memberType => { + data[memberType] = result.filter( + (membership: UserGroupMember) => membership.membershipType === memberType, + ) + data[memberType].forEach((membership: UserGroupMember) => { + loadUser(membership.createdBy) + loadUser(membership.updatedBy) + + if (memberType === 'user') { + // for user members load handles + loadUser(membership.memberId) + } else { + // for group members load names + loadGroup(membership.memberId) + } + }) + }) + + dispatch({ + payload: data, + type: GroupsActionType.FETCH_GROUP_MEMBERS_DONE, + }) + }) + .catch(e => { + isLoadingRef.current = false + dispatch({ + type: GroupsActionType.FETCH_GROUP_MEMBERS_FAILED, + }) + handleError(e) + }) + }, [dispatch, groupId, memberTypes, loadUser, loadGroup]) + + const doFilterGroupMembers = useCallback( + (filterCriteria: FormGroupMembersFilters, memberType: string) => { + let filteredMembers = _.clone(state.allGroupMembers[memberType]) + + // memberId + if (filterCriteria.memberId) { + filteredMembers = _.filter( + filteredMembers, + (membership: UserGroupMember) => ( + `${membership.memberId}` === filterCriteria.memberId + ), + ) + } + + // createdAtFrom + if (filterCriteria.createdAtFrom) { + const createdAtFrom = moment(filterCriteria.createdAtFrom) + + filteredMembers = _.filter( + filteredMembers, + (membership: UserGroupMember) => ( + membership.createdAt + && createdAtFrom.isSameOrBefore( + membership.createdAt, + 'day', + ) + ), + ) + } + + // createdAtTo + if (filterCriteria.createdAtTo) { + const createdAtTo = moment(filterCriteria.createdAtTo) + + filteredMembers = _.filter( + filteredMembers, + (membership: UserGroupMember) => ( + membership.createdAt + && createdAtTo.isSameOrAfter( + membership.createdAt, + 'day', + ) + ), + ) + } + + // modifiedAtFrom + if (filterCriteria.modifiedAtFrom) { + const modifiedAtFrom = moment(filterCriteria.modifiedAtFrom) + + filteredMembers = _.filter( + filteredMembers, + (membership: UserGroupMember) => ( + membership.updatedAt + && modifiedAtFrom.isSameOrBefore( + membership.updatedAt, + 'day', + ) + ), + ) + } + + // modifiedAtTo + if (filterCriteria.modifiedAtTo) { + const modifiedAtTo = moment(filterCriteria.modifiedAtTo) + + filteredMembers = _.filter( + filteredMembers, + (membership: UserGroupMember) => ( + membership.updatedAt + && modifiedAtTo.isSameOrAfter( + membership.updatedAt, + 'day', + ) + ), + ) + } + + if ( + filteredMembers.length > 0 + && (filterCriteria.memberName + || filterCriteria.createdBy + || filterCriteria.modifiedBy) + ) { + dispatch({ + payload: memberType, + type: GroupsActionType.FILTER_GROUP_MEMBERS_INIT, + }) + + return filterWithRequests( + filterCriteria, + memberType, + filteredMembers, + ) + .then(datas => { + // after we filtered data we redraw table + dispatch({ + payload: { + datas, + memberType, + }, + type: GroupsActionType.FILTER_GROUP_MEMBERS_DONE, + }) + }) + .catch(error => { + dispatch({ + payload: memberType, + type: GroupsActionType.FILTER_GROUP_MEMBERS_FAILED, + }) + handleError(error) + }) + } + + dispatch({ + payload: { + datas: filteredMembers, + memberType, + }, + type: GroupsActionType.FILTER_GROUP_MEMBERS_DONE, + }) + return undefined + }, + [dispatch, state.allGroupMembers], + ) + + const doRemoveGroupMember = useCallback( + (memberId: number) => { + dispatch({ + payload: memberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_INIT, + }) + removeGroupMember(groupId, memberId) + .then(() => { + toast.success('Member removed successfully', { + toastId: 'Remove group member', + }) + dispatch({ + payload: memberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_DONE, + }) + }) + .catch(e => { + dispatch({ + payload: memberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, groupId], + ) + + const doRemoveGroupMembers = useCallback( + (memberIds: number[], callBack: () => void) => { + let hasSubmissionErrors = false + _.forEach(memberIds, groupMemberId => { + dispatch({ + payload: groupMemberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_INIT, + }) + }) + Promise.all( + memberIds.map(async groupMemberId => removeGroupMember(groupId, groupMemberId) + .catch(e => { + hasSubmissionErrors = true + handleError(e) + })), + ) + .then(() => { + if (!hasSubmissionErrors) { + toast.success( + `${ + memberIds.length > 1 ? 'Members' : 'Member' + } removed successfully`, + { + toastId: 'Remove members', + }, + ) + callBack() + } + + _.forEach(memberIds, groupMemberId => { + dispatch({ + payload: groupMemberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_DONE, + }) + }) + }) + .catch(e => { + _.forEach(memberIds, groupMemberId => { + dispatch({ + payload: groupMemberId, + type: GroupsActionType.REMOVE_GROUP_MEMBERS_FAILED, + }) + }) + handleError(e) + }) + }, + [dispatch, groupId], + ) + + useEffect(() => { + if (!isLoadingRef.current && groupId) { + doFetchGroup() + } + }, [groupId, doFetchGroup]) + + useEffect(() => () => { + // clear queue of currently loading user handles after exit ui + cancelLoadUser() + // clear queue of currently loading group handles after exit ui + cancelLoadGroup() + }, [cancelLoadUser, cancelLoadGroup]) + + useEffect(() => { + _.forEach(memberTypes, memberType => { + const datas = state.allGroupMembers[memberType] || [] + datas.forEach((membership: UserGroupMember) => { + membership.createdByHandle = usersMapping[membership.createdBy] + membership.updatedByHandle = usersMapping[membership.updatedBy] + + if (memberType === 'user') { + membership.name = usersMapping[membership.memberId] + } else { + membership.name = groupsMapping[membership.memberId] + } + }) + }) + }, [usersMapping, groupsMapping, state.allGroupMembers, memberTypes]) + + return { + doFilterGroupMembers, + doRemoveGroupMember, + doRemoveGroupMembers, + groupMembers: state.filteredGroupMembers, + isFiltering: state.isFiltering, + isLoading: state.isLoading, + isRemoving: state.isRemoving, + isRemovingBool, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManagePermissionGroups.ts b/src/apps/admin/src/lib/hooks/useManagePermissionGroups.ts new file mode 100644 index 000000000..53541d173 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManagePermissionGroups.ts @@ -0,0 +1,223 @@ +/** + * Manage permission groups redux state + */ +import { useCallback, useEffect, useReducer } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { FormAddGroup, UserGroup, UserIdType, UserMappingType } from '../models' +import { createGroup, fetchGroups } from '../services' +import { handleError } from '../utils' + +import { useOnComponentDidMount } from './useOnComponentDidMount' + +/// ///////////////// +// Permission groups reducer +/// //////////////// + +type GroupsState = { + isLoading: boolean + isAdding: boolean + groups: UserGroup[] +} + +// used to get all groups +const PAGE = 1 +const PER_PAGE = 4000 + +const GroupsActionType = { + ADD_GROUPS_DONE: 'ADD_GROUPS_DONE' as const, + ADD_GROUPS_FAILED: 'ADD_GROUPS_FAILED' as const, + ADD_GROUPS_INIT: 'ADD_GROUPS_INIT' as const, + FETCH_GROUPS_DONE: 'FETCH_GROUPS_DONE' as const, + FETCH_GROUPS_FAILED: 'FETCH_GROUPS_FAILED' as const, + FETCH_GROUPS_INIT: 'FETCH_GROUPS_INIT' as const, +} + +type GroupsReducerAction = + | { + type: + | typeof GroupsActionType.ADD_GROUPS_DONE + | typeof GroupsActionType.ADD_GROUPS_INIT + | typeof GroupsActionType.ADD_GROUPS_FAILED + | typeof GroupsActionType.FETCH_GROUPS_INIT + | typeof GroupsActionType.FETCH_GROUPS_FAILED + } + | { + type: typeof GroupsActionType.FETCH_GROUPS_DONE + payload: UserGroup[] + } + +const reducer = ( + previousState: GroupsState, + action: GroupsReducerAction, +): GroupsState => { + switch (action.type) { + case GroupsActionType.FETCH_GROUPS_INIT: { + return { + ...previousState, + groups: [], + isLoading: true, + } + } + + case GroupsActionType.FETCH_GROUPS_DONE: { + const groups = action.payload + return { + ...previousState, + groups, + isLoading: false, + } + } + + case GroupsActionType.FETCH_GROUPS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case GroupsActionType.ADD_GROUPS_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case GroupsActionType.ADD_GROUPS_DONE: { + return { + ...previousState, + isAdding: false, + } + } + + case GroupsActionType.ADD_GROUPS_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManagePermissionGroupsProps { + isLoading: boolean + isAdding: boolean + groups: UserGroup[] + doAddGroup: (groupInfo: FormAddGroup, success: () => void) => void +} + +/** + * Manage permission groups redux state + * @param loadUsers load list of users function + * @param cancelLoadUser cancel load users + * @param usersMapping mapping user id to user handle + * @returns state data + */ +export function useManagePermissionGroups( + loadUser: (userId: UserIdType) => void, + cancelLoadUser: () => void, + usersMapping: UserMappingType, // from user id to user handle +): useManagePermissionGroupsProps { + const [state, dispatch] = useReducer(reducer, { + groups: [], + isAdding: false, + isLoading: false, + }) + + const doFetchGroups = useCallback(() => { + dispatch({ + type: GroupsActionType.FETCH_GROUPS_INIT, + }) + fetchGroups({ + page: PAGE, + perPage: PER_PAGE, + }) + .then(result => { + dispatch({ + payload: result, + type: GroupsActionType.FETCH_GROUPS_DONE, + }) + _.forEach(result, group => { + if (group.createdBy) { + loadUser(group.createdBy) + } + + if (group.updatedBy) { + loadUser(group.updatedBy) + } + }) + }) + .catch(e => { + dispatch({ + type: GroupsActionType.FETCH_GROUPS_FAILED, + }) + handleError(e) + }) + }, [dispatch, loadUser]) + + const doAddGroup = useCallback( + (groupInfo: FormAddGroup, success: () => void) => { + dispatch({ + type: GroupsActionType.ADD_GROUPS_INIT, + }) + function handleSuccess(): void { + toast.success('Group added successfully', { + toastId: 'Add group', + }) + dispatch({ + type: GroupsActionType.ADD_GROUPS_DONE, + }) + success() + } + + createGroup(groupInfo) + .then(() => { + setTimeout(() => { + handleSuccess() + doFetchGroups() + }, 1000) // sometimes the backend does not return the new data + // so I added a 1 second timeout for this + }) + .catch(e => { + dispatch({ + type: GroupsActionType.ADD_GROUPS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, doFetchGroups], + ) + + useOnComponentDidMount(() => { + doFetchGroups() + }) + + useEffect(() => () => { + // clear queue of currently loading user handles after exit ui + cancelLoadUser() + }, [cancelLoadUser]) + + useEffect(() => { + _.forEach(state.groups, group => { + if (group.createdBy) { + group.createdByHandle = usersMapping[group.createdBy] + } + + if (group.updatedBy) { + group.updatedByHandle = usersMapping[group.updatedBy] + } + }) + }, [usersMapping, state.groups]) + + return { + doAddGroup, + groups: state.groups, + isAdding: state.isAdding, + isLoading: state.isLoading, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts b/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts new file mode 100644 index 000000000..df67b4205 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts @@ -0,0 +1,375 @@ +/** + * Manage permission role members redux state + */ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { + FormRoleMembersFilters, + RoleMemberInfo, + UserRole, +} from '../models' +import { fetchRole, searchUsers, unassignRole } from '../services' +import { handleError } from '../utils' + +/// ///////////////// +// Permission role members reducer +/// //////////////// + +type RolesState = { + isLoading: boolean + isFiltering: boolean + isRemoving: { [key: string]: boolean } + roleInfo?: UserRole + filteredRoleMembers: RoleMemberInfo[] + allRoleMembers: RoleMemberInfo[] +} + +const RolesActionType = { + FETCH_ROLE_MEMBERS_DONE: 'FETCH_ROLE_MEMBERS_DONE' as const, + FETCH_ROLE_MEMBERS_FAILED: 'FETCH_ROLE_MEMBERS_FAILED' as const, + FETCH_ROLE_MEMBERS_INIT: 'FETCH_ROLE_MEMBERS_INIT' as const, + FILTER_ROLE_MEMBERS_DONE: 'FILTER_ROLE_MEMBERS_DONE' as const, + FILTER_ROLE_MEMBERS_FAILED: 'FILTER_ROLE_MEMBERS_FAILED' as const, + FILTER_ROLE_MEMBERS_INIT: 'FILTER_ROLE_MEMBERS_INIT' as const, + REMOVE_ROLE_MEMBERS_DONE: 'REMOVE_ROLE_MEMBERS_DONE' as const, + REMOVE_ROLE_MEMBERS_FAILED: 'REMOVE_ROLE_MEMBERS_FAILED' as const, + REMOVE_ROLE_MEMBERS_INIT: 'REMOVE_ROLE_MEMBERS_INIT' as const, +} + +type RolesReducerAction = + | { + type: + | typeof RolesActionType.FETCH_ROLE_MEMBERS_INIT + | typeof RolesActionType.FETCH_ROLE_MEMBERS_FAILED + | typeof RolesActionType.FILTER_ROLE_MEMBERS_INIT + | typeof RolesActionType.FILTER_ROLE_MEMBERS_FAILED + } + | { + type: typeof RolesActionType.FETCH_ROLE_MEMBERS_DONE + payload: UserRole + } + | { + type: typeof RolesActionType.FILTER_ROLE_MEMBERS_DONE + payload: RoleMemberInfo[] + } + | { + type: + | typeof RolesActionType.REMOVE_ROLE_MEMBERS_DONE + | typeof RolesActionType.REMOVE_ROLE_MEMBERS_INIT + | typeof RolesActionType.REMOVE_ROLE_MEMBERS_FAILED + payload: string + } + +const reducer = ( + previousState: RolesState, + action: RolesReducerAction, +): RolesState => { + switch (action.type) { + case RolesActionType.FETCH_ROLE_MEMBERS_INIT: { + return { + ...previousState, + allRoleMembers: [], + filteredRoleMembers: [], + isLoading: true, + } + } + + case RolesActionType.FETCH_ROLE_MEMBERS_DONE: { + const roleInfo = action.payload + const allRoleMembers = (roleInfo.subjects || []).map( + memberId => ({ id: memberId }), + ) + return { + ...previousState, + allRoleMembers, + filteredRoleMembers: allRoleMembers, + isLoading: false, + roleInfo, + } + } + + case RolesActionType.FETCH_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case RolesActionType.FILTER_ROLE_MEMBERS_INIT: { + return { + ...previousState, + filteredRoleMembers: [], + isFiltering: true, + } + } + + case RolesActionType.FILTER_ROLE_MEMBERS_DONE: { + return { + ...previousState, + filteredRoleMembers: action.payload, + isFiltering: false, + } + } + + case RolesActionType.FILTER_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isFiltering: false, + } + } + + case RolesActionType.REMOVE_ROLE_MEMBERS_INIT: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: true, + }, + } + } + + case RolesActionType.REMOVE_ROLE_MEMBERS_DONE: { + const allRoleMembers = _.filter( + previousState.allRoleMembers, + role => role.id !== action.payload, + ) + const filteredRoleMembers = _.filter( + previousState.allRoleMembers, + role => role.id !== action.payload, + ) + return { + ...previousState, + allRoleMembers, + filteredRoleMembers, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + case RolesActionType.REMOVE_ROLE_MEMBERS_FAILED: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + default: { + return previousState + } + } +} + +export interface useManagePermissionRoleMembersProps { + isLoading: boolean + isFiltering: boolean + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + roleInfo?: UserRole + roleMembers: RoleMemberInfo[] + doFilterRoleMembers: (filterData: FormRoleMembersFilters) => void + doRemoveRoleMember: (roleMember: RoleMemberInfo) => void + doRemoveRoleMembers: (roleMemberIds: string[], callBack: () => void) => void +} + +/** + * Manage permission role members redux state + * @param roleId role id + * @returns state data + */ +export function useManagePermissionRoleMembers( + roleId: string, +): useManagePermissionRoleMembersProps { + const [state, dispatch] = useReducer(reducer, { + allRoleMembers: [], + filteredRoleMembers: [], + isFiltering: false, + isLoading: false, + isRemoving: {}, + }) + const isLoadingRef = useRef(false) + const isRemovingBool = useMemo( + () => _.some(state.isRemoving, value => value === true), + [state.isRemoving], + ) + + const doFetchRole = useCallback(() => { + dispatch({ + type: RolesActionType.FETCH_ROLE_MEMBERS_INIT, + }) + isLoadingRef.current = true + fetchRole(roleId, ['id', 'roleName', 'subjects']) + .then(result => { + isLoadingRef.current = false + dispatch({ + payload: result, + type: RolesActionType.FETCH_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + isLoadingRef.current = false + dispatch({ + type: RolesActionType.FETCH_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + }, [dispatch, roleId]) + + const doFilterRoleMembers = useCallback( + (filterData: FormRoleMembersFilters) => { + let filteredMembers = _.clone(state.allRoleMembers) + + // filter by ids first, it works immediately as we know all the data + // so we don't need to show loader for this + if (filterData.userId) { + filteredMembers = _.filter(filteredMembers, { + id: filterData.userId, + }) + } + + // if handle filter is defined and we still have some rows to filter + if (filterData.userHandle && filteredMembers.length > 0) { + // we show loader as we need to make request to the server + dispatch({ + type: RolesActionType.FILTER_ROLE_MEMBERS_INIT, + }) + + // As there is no server API to filter role members and we don't have + // user handles to filter, we first have to find user ids by it's handle + // and after we can filter users by id + searchUsers({ + fields: 'id', + filter: `handle=*${filterData.userHandle}*&like=true`, + limit: 1000000, // set big limit to make sure server returns all records + }) + .then(result => { + const foundIds = _.map(result, 'id') + + filteredMembers = _.filter( + filteredMembers, + (member: RoleMemberInfo) => _.includes(foundIds, member.id), + ) + dispatch({ + payload: filteredMembers, + type: RolesActionType.FILTER_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + dispatch({ + type: RolesActionType.FILTER_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + + // if we don't filter by handle which makes server request + // redraw table immediately + } else { + dispatch({ + payload: filteredMembers, + type: RolesActionType.FILTER_ROLE_MEMBERS_DONE, + }) + } + }, + [dispatch, state.allRoleMembers], + ) + + const doRemoveRoleMember = useCallback( + (roleMember: RoleMemberInfo) => { + dispatch({ + payload: roleMember.id, + type: RolesActionType.REMOVE_ROLE_MEMBERS_INIT, + }) + unassignRole(roleId, roleMember.id) + .then(() => { + toast.success('Role removed successfully', { + toastId: 'Remove role', + }) + dispatch({ + payload: roleMember.id, + type: RolesActionType.REMOVE_ROLE_MEMBERS_DONE, + }) + }) + .catch(e => { + dispatch({ + payload: roleMember.id, + type: RolesActionType.REMOVE_ROLE_MEMBERS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, roleId], + ) + + const doRemoveRoleMembers = useCallback( + (roleMemberIds: string[], callBack: () => void) => { + let hasSubmissionErrors = false + _.forEach(roleMemberIds, roleMemberId => { + dispatch({ + payload: roleMemberId, + type: RolesActionType.REMOVE_ROLE_MEMBERS_INIT, + }) + }) + Promise.all( + roleMemberIds.map(async roleMemberId => unassignRole(roleId, roleMemberId) + .catch(e => { + hasSubmissionErrors = true + handleError(e) + })), + ) + .then(() => { + if (!hasSubmissionErrors) { + toast.success( + `${ + roleMemberIds.length > 1 ? 'Roles' : 'Role' + } removed successfully`, + { + toastId: 'Remove roles', + }, + ) + callBack() + } + + _.forEach(roleMemberIds, roleMemberId => { + dispatch({ + payload: roleMemberId, + type: RolesActionType.REMOVE_ROLE_MEMBERS_DONE, + }) + }) + }) + .catch(e => { + _.forEach(roleMemberIds, roleMemberId => { + dispatch({ + payload: roleMemberId, + type: RolesActionType.REMOVE_ROLE_MEMBERS_FAILED, + }) + }) + handleError(e) + }) + }, + [dispatch, roleId], + ) + + useEffect(() => { + if (!isLoadingRef.current) { + doFetchRole() + } + }, [roleId, doFetchRole]) + + return { + doFilterRoleMembers, + doRemoveRoleMember, + doRemoveRoleMembers, + isFiltering: state.isFiltering, + isLoading: state.isLoading, + isRemoving: state.isRemoving, + isRemovingBool, + roleInfo: state.roleInfo, + roleMembers: state.filteredRoleMembers, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManagePermissionRoles.ts b/src/apps/admin/src/lib/hooks/useManagePermissionRoles.ts new file mode 100644 index 000000000..0e57a0b90 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManagePermissionRoles.ts @@ -0,0 +1,266 @@ +/** + * Manage permission roles redux state + */ +import { useCallback, useEffect, useReducer } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { + TableFilterType, + UserIdType, + UserMappingType, + UserRole, +} from '../models' +import { createRole, fetchRoles } from '../services' +import { handleError } from '../utils' + +import { useOnComponentDidMount } from './useOnComponentDidMount' + +/// ///////////////// +// Permission roles reducer +/// //////////////// + +type RolesState = { + isLoading: boolean + isAdding: boolean + allRoles: UserRole[] + filteredRoles: UserRole[] +} + +const RolesActionType = { + ADD_ROLES_DONE: 'ADD_ROLES_DONE' as const, + ADD_ROLES_FAILED: 'ADD_ROLES_FAILED' as const, + ADD_ROLES_INIT: 'ADD_ROLES_INIT' as const, + FETCH_ROLES_DONE: 'FETCH_ROLES_DONE' as const, + FETCH_ROLES_FAILED: 'FETCH_ROLES_FAILED' as const, + FETCH_ROLES_INIT: 'FETCH_ROLES_INIT' as const, + FILTER_ROLES_DONE: 'FILTER_ROLES_DONE' as const, +} + +type RolesReducerAction = + | { + type: + | typeof RolesActionType.ADD_ROLES_DONE + | typeof RolesActionType.ADD_ROLES_INIT + | typeof RolesActionType.ADD_ROLES_FAILED + | typeof RolesActionType.FETCH_ROLES_INIT + | typeof RolesActionType.FETCH_ROLES_FAILED + } + | { + type: + | typeof RolesActionType.FETCH_ROLES_DONE + | typeof RolesActionType.FILTER_ROLES_DONE + payload: UserRole[] + } + +const reducer = ( + previousState: RolesState, + action: RolesReducerAction, +): RolesState => { + switch (action.type) { + case RolesActionType.FETCH_ROLES_INIT: { + return { + ...previousState, + allRoles: [], + filteredRoles: [], + isLoading: true, + } + } + + case RolesActionType.FETCH_ROLES_DONE: { + const allRoles = action.payload + return { + ...previousState, + allRoles, + filteredRoles: allRoles, + isLoading: false, + } + } + + case RolesActionType.FILTER_ROLES_DONE: { + const filteredRoles = action.payload + return { + ...previousState, + filteredRoles, + } + } + + case RolesActionType.FETCH_ROLES_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case RolesActionType.ADD_ROLES_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case RolesActionType.ADD_ROLES_DONE: { + return { + ...previousState, + isAdding: false, + } + } + + case RolesActionType.ADD_ROLES_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManagePermissionRolesProps { + isLoading: boolean + isAdding: boolean + roles: UserRole[] + doAddRole: (roleName: string, success: () => void) => void + doFilterRole: (filterData: TableFilterType) => void +} + +/** + * Manage permission roles redux state + * @param loadUsers load list of users function + * @param usersMapping mapping user id to user handle + * @returns state data + */ +export function useManagePermissionRoles( + loadUser: (userId: UserIdType) => void, + usersMapping: UserMappingType, // from user id to user handle +): useManagePermissionRolesProps { + const [state, dispatch] = useReducer(reducer, { + allRoles: [], + filteredRoles: [], + isAdding: false, + isLoading: false, + }) + + const doFetchRoles = useCallback(() => { + dispatch({ + type: RolesActionType.FETCH_ROLES_INIT, + }) + fetchRoles() + .then(result => { + dispatch({ + payload: result, + type: RolesActionType.FETCH_ROLES_DONE, + }) + _.forEach(result, role => { + if (role.createdBy) { + loadUser(role.createdBy) + } + + if (role.modifiedBy) { + loadUser(role.modifiedBy) + } + }) + }) + .catch(e => { + dispatch({ + type: RolesActionType.FETCH_ROLES_FAILED, + }) + handleError(e) + }) + }, [dispatch, loadUser]) + + const doAddRole = useCallback( + (roleName: string, success: () => void) => { + dispatch({ + type: RolesActionType.ADD_ROLES_INIT, + }) + function handleSuccess(): void { + toast.success('Role added successfully', { + toastId: 'Add role', + }) + dispatch({ + type: RolesActionType.ADD_ROLES_DONE, + }) + success() + } + + createRole(roleName) + .then(() => { + setTimeout(() => { + handleSuccess() + doFetchRoles() // sometimes the backend does not return the new data + // so I added a 1 second timeout for this + }, 1000) + }) + .catch(e => { + dispatch({ + type: RolesActionType.ADD_ROLES_FAILED, + }) + handleError(e) + }) + }, + [dispatch, doFetchRoles], + ) + + const doFilterRole = useCallback( + (filterData: TableFilterType) => { + const datas = state.allRoles + const results = _.filter(datas, data => { + let isMatched = false + // eslint-disable-next-line consistent-return + _.forOwn(filterData, (value, key) => { + if (value) { + const valueData = `${_.get(data, key)}`.toLowerCase() + const escapedSearchText = _.escapeRegExp( + `${value}`.toLowerCase(), + ) + if ( + new RegExp(escapedSearchText, 'i') + .test(valueData) + ) { + isMatched = true + return false + } + } else { + isMatched = true + return false + } + }) + return isMatched + }) + + dispatch({ + payload: results, + type: RolesActionType.FILTER_ROLES_DONE, + }) + }, + [dispatch, state.allRoles], + ) + + useOnComponentDidMount(() => { + doFetchRoles() + }) + + useEffect(() => { + _.forEach(state.allRoles, role => { + if (role.createdBy) { + role.createdByHandle = usersMapping[role.createdBy] + } + + if (role.modifiedBy) { + role.modifiedByHandle = usersMapping[role.modifiedBy] + } + }) + }, [usersMapping, state.allRoles]) + + return { + doAddRole, + doFilterRole, + isAdding: state.isAdding, + isLoading: state.isLoading, + roles: state.filteredRoles, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageUserGroups.ts b/src/apps/admin/src/lib/hooks/useManageUserGroups.ts index ab5d9458a..eb9f79f45 100644 --- a/src/apps/admin/src/lib/hooks/useManageUserGroups.ts +++ b/src/apps/admin/src/lib/hooks/useManageUserGroups.ts @@ -7,11 +7,11 @@ import _ from 'lodash' import { UserGroup, UserGroupMember, UserInfo } from '../models' import { - addMember, + addGroupMember, fetchGroupMembers, fetchGroups, - findByMember, - removeMember, + findGroupByMember, + removeGroupMember, } from '../services' import { handleError } from '../utils' @@ -223,7 +223,7 @@ export function useManageUserGroups( type: UserGroupsActionType.FETCH_USER_GROUPS_INIT, }) Promise.all([ - findByMember({ + findGroupByMember({ memberId: userInfo.id, membershipType: 'user', page: PAGE, @@ -253,7 +253,7 @@ export function useManageUserGroups( dispatch({ type: UserGroupsActionType.ADD_USER_GROUP_INIT, }) - addMember(newGroupId, { + addGroupMember(newGroupId, { memberId: userInfo.id, membershipType: 'user', }) @@ -313,7 +313,7 @@ export function useManageUserGroups( type: UserGroupsActionType.REMOVE_USER_GROUP_DONE, }) } else { - removeMember(group.id, membership.memberId) + removeGroupMember(group.id, membership.memberId) .then(() => { toast.success('Group removed successfully', { toastId: 'Remove group', diff --git a/src/apps/admin/src/lib/hooks/useTableFilterLocal.ts b/src/apps/admin/src/lib/hooks/useTableFilterLocal.ts index 80841b113..b52d2cf1b 100644 --- a/src/apps/admin/src/lib/hooks/useTableFilterLocal.ts +++ b/src/apps/admin/src/lib/hooks/useTableFilterLocal.ts @@ -14,31 +14,59 @@ export interface useTableFilterLocalProps { totalPages: number results: T[] setSort: Dispatch> + sort: Sort | undefined } /** * Use to manage table filter * @param allDatas all table datas * @param defaultSort default sort + * @param mappingSortField mapping from property field to sort field */ -export function useTableFilterLocal(allDatas: T[], defaultSort?: Sort): useTableFilterLocalProps { +export function useTableFilterLocal( + allDatas: T[], + defaultSort?: Sort, + mappingSortField?: { [key: string]: string }, +): useTableFilterLocalProps { const [page, setPage] = useState(1) const [sort, setSort] = useState(defaultSort) + const [results, setResults] = useState([]) + const [displayDatas, setDisplayDatas] = useState([]) const totalPages = useMemo( - () => Math.ceil(allDatas.length / TABLE_PAGINATION_ITEM_PER_PAGE), - [allDatas], + () => Math.ceil(displayDatas.length / TABLE_PAGINATION_ITEM_PER_PAGE), + [displayDatas], ) - const [results, setResults] = useState([]) + const [sortedDatas, setSortedDatas] = useState([]) + + // update filter datas + useEffect(() => { + setSort(defaultSort) // reset sort + setPage(1) // reset pagination when changing sort + setDisplayDatas(allDatas) + }, [allDatas, defaultSort]) + + // update sort datas + useEffect(() => { + let datas = displayDatas + if (sort && sort.fieldName && sort.direction && datas.length > 0) { + let sortField = sort.fieldName + if (mappingSortField && mappingSortField[sortField]) { + sortField = mappingSortField[sortField] + } + + datas = _.orderBy(datas, [sortField], [sort.direction]) + } + setSortedDatas(datas) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayDatas, sort]) + + // update pagination datas useEffect(() => { - let datas = allDatas + let datas = sortedDatas if (!datas.length) { setResults([]) } else { - if (sort && sort.fieldName && sort.direction) { - datas = _.orderBy(datas, [sort.fieldName], [sort.direction]) - } - const pageFrom0 = (page || 1) - 1 const itemOffset = (pageFrom0 * TABLE_PAGINATION_ITEM_PER_PAGE) % datas.length @@ -48,13 +76,14 @@ export function useTableFilterLocal(allDatas: T[], defaultSort?: Sort): useTa ) setResults(datas) } - }, [allDatas, page, sort]) + }, [sortedDatas, page]) return { page, results, setPage, setSort, + sort, totalPages, } } diff --git a/src/apps/admin/src/lib/hooks/useTableSelection.ts b/src/apps/admin/src/lib/hooks/useTableSelection.ts new file mode 100644 index 000000000..5aa314198 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useTableSelection.ts @@ -0,0 +1,121 @@ +/** + * Use to manage table selection + */ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import _ from 'lodash' + +type KeyType = string | number + +export interface useTableSelectionProps { + selectedDatas: { + [id: KeyType]: boolean + } + selectedDatasArray: T[], + toggleSelect: (key: T) => void + forceSelect: (key: T) => void + forceUnSelect: (key: T) => void + toggleSelectAll: () => void + unselectAll: () => void + hasSelected: boolean + isSelectAll: boolean +} + +/** + * Use to manage table selection + * @param datasIds datas + * @returns selection info + */ +export function useTableSelection( + datasIds: T[], +): useTableSelectionProps { + const [selectedDatas, setSelectedDatas] = useState<{ + [id: KeyType]: boolean + }>({}) + const selectedDatasArray = useMemo( + () => _.filter( + _.keys(selectedDatas) as T[], + item => selectedDatas[item] === true, + ), + [selectedDatas], + ) + const hasSelected = useMemo( + () => selectedDatasArray.length > 0, + [selectedDatasArray], + ) + const isSelectAll = useMemo( + () => selectedDatasArray.length === datasIds.length, + [selectedDatasArray, datasIds], + ) + + const toggleSelect = useCallback( + (key: T) => { + setSelectedDatas(old => ({ + ...old, + [key]: !old[key], + })) + }, + [], + ) + const forceSelect = useCallback( + (key: T) => { + setSelectedDatas(old => ({ + ...old, + [key]: true, + })) + }, + [], + ) + const forceUnSelect = useCallback( + (key: T) => { + setSelectedDatas(old => ({ + ...old, + [key]: false, + })) + }, + [], + ) + + const unselectAll = useCallback(() => { + setSelectedDatas({}) + }, []) + + const selectAll = useCallback(() => { + setSelectedDatas( + _.reduce( + datasIds, + (selectedData, data) => ({ ...selectedData, [data]: true }), + {}, + ), + ) + }, [datasIds]) + + const toggleSelectAll = useCallback(() => { + if (isSelectAll) { + unselectAll() + } else { + selectAll() + } + }, [isSelectAll, unselectAll, selectAll]) + + // reset select + useEffect(() => { + unselectAll() + }, [datasIds, unselectAll]) + + return { + forceSelect, + forceUnSelect, + hasSelected, + isSelectAll, + selectedDatas, + selectedDatasArray, + toggleSelect, + toggleSelectAll, + unselectAll, + } +} diff --git a/src/apps/admin/src/lib/models/AdminAppContextType.type.ts b/src/apps/admin/src/lib/models/AdminAppContextType.type.ts new file mode 100644 index 000000000..107652ab6 --- /dev/null +++ b/src/apps/admin/src/lib/models/AdminAppContextType.type.ts @@ -0,0 +1,15 @@ +import { SearchUserInfo } from './SearchUserInfo.model' +import { SelectOption } from './SelectOption.model' + +export type UserIdType = number | string +export type UserMappingType = { [userId: UserIdType]: string } +export type AdminAppContextType = { + usersMapping: UserMappingType // from user id to user handle + loadUser: (userId: UserIdType) => void + cancelLoadUser: () => void + setUserFromSearch: (userHandles: SearchUserInfo[]) => void + setGroupFromSearch: (userHandles: SelectOption[]) => void + groupsMapping: UserMappingType // from group id to group name + loadGroup: (groupId: UserIdType) => void + cancelLoadGroup: () => void +} diff --git a/src/apps/admin/src/lib/models/FormAddGroup.model.ts b/src/apps/admin/src/lib/models/FormAddGroup.model.ts new file mode 100644 index 000000000..1ae49dc5c --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddGroup.model.ts @@ -0,0 +1,9 @@ +/** + * Model for add group form + */ +export interface FormAddGroup { + name: string + description?: string + privateGroup?: boolean + selfRegister?: boolean +} diff --git a/src/apps/admin/src/lib/models/FormAddGroupMembers.type.ts b/src/apps/admin/src/lib/models/FormAddGroupMembers.type.ts new file mode 100644 index 000000000..24defa01f --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddGroupMembers.type.ts @@ -0,0 +1,11 @@ +import { SearchUserInfo } from './SearchUserInfo.model' +import { SelectOption } from './SelectOption.model' + +/** + * Model for add group member form + */ +export type FormAddGroupMembers = { + userHandles?: SearchUserInfo[] + groupIds?: SelectOption[] + membershipType: string +} diff --git a/src/apps/admin/src/lib/models/FormAddRoleMembers.type.ts b/src/apps/admin/src/lib/models/FormAddRoleMembers.type.ts new file mode 100644 index 000000000..2f2190419 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddRoleMembers.type.ts @@ -0,0 +1,8 @@ +import { SearchUserInfo } from './SearchUserInfo.model' + +/** + * Model for add role member form + */ +export type FormAddRoleMembers = { + userHandles: SearchUserInfo[] +} diff --git a/src/apps/admin/src/lib/models/FormGroupMembersFilters.model.ts b/src/apps/admin/src/lib/models/FormGroupMembersFilters.model.ts new file mode 100644 index 000000000..9a521b5f7 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormGroupMembersFilters.model.ts @@ -0,0 +1,13 @@ +/** + * Model for group members filters form + */ +export interface FormGroupMembersFilters { + memberId?: string + memberName?: string + createdBy?: string + modifiedBy?: string + createdAtFrom?: Date | null + createdAtTo?: Date | null + modifiedAtFrom?: Date | null + modifiedAtTo?: Date | null +} diff --git a/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts b/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts new file mode 100644 index 000000000..f517c412a --- /dev/null +++ b/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts @@ -0,0 +1,7 @@ +/** + * Model for role members filters form + */ +export interface FormRoleMembersFilters { + userId?: string + userHandle?: string +} diff --git a/src/apps/admin/src/lib/models/FormRolesFilter.type.ts b/src/apps/admin/src/lib/models/FormRolesFilter.type.ts new file mode 100644 index 000000000..1c44aa6ea --- /dev/null +++ b/src/apps/admin/src/lib/models/FormRolesFilter.type.ts @@ -0,0 +1,6 @@ +/** + * Model for roles filter form + */ +export type FormRolesFilter = { + roleName: string +} diff --git a/src/apps/admin/src/lib/models/MobileTableColumn.model.ts b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts new file mode 100644 index 000000000..4f24b98a4 --- /dev/null +++ b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts @@ -0,0 +1,8 @@ +/** + * Model for table column config on mobile + */ +import { TableColumn } from '~/libs/ui' + +export interface MobileTableColumn extends TableColumn { + readonly mobileType?: 'label' +} diff --git a/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts b/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts new file mode 100644 index 000000000..0930bee91 --- /dev/null +++ b/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts @@ -0,0 +1,7 @@ +/** + * Model for role member info + */ +export interface RoleMemberInfo { + id: string + handle?: string +} diff --git a/src/apps/admin/src/lib/models/SearchUserInfo.model.ts b/src/apps/admin/src/lib/models/SearchUserInfo.model.ts new file mode 100644 index 000000000..84585aae2 --- /dev/null +++ b/src/apps/admin/src/lib/models/SearchUserInfo.model.ts @@ -0,0 +1,7 @@ +/** + * Model for search user info + */ +export interface SearchUserInfo { + userId: number + handle: string +} diff --git a/src/apps/admin/src/lib/models/SelectOption.model.ts b/src/apps/admin/src/lib/models/SelectOption.model.ts new file mode 100644 index 000000000..a504e501c --- /dev/null +++ b/src/apps/admin/src/lib/models/SelectOption.model.ts @@ -0,0 +1,7 @@ +/** + * Select option for select field + */ +export interface SelectOption { + readonly label: string + readonly value: number | string +} diff --git a/src/apps/admin/src/lib/models/TableFilterType.type.ts b/src/apps/admin/src/lib/models/TableFilterType.type.ts new file mode 100644 index 000000000..0c7dd556a --- /dev/null +++ b/src/apps/admin/src/lib/models/TableFilterType.type.ts @@ -0,0 +1 @@ +export type TableFilterType = { [key: string]: string | number } diff --git a/src/apps/admin/src/lib/models/TableRolesFilter.type.ts b/src/apps/admin/src/lib/models/TableRolesFilter.type.ts new file mode 100644 index 000000000..09c2b9832 --- /dev/null +++ b/src/apps/admin/src/lib/models/TableRolesFilter.type.ts @@ -0,0 +1,12 @@ +/** + * Model for table roles filter + */ +export type TableRolesFilter = { + id: string + roleName: string + createdAtString: string + modifiedAtString: string + createdByHandle: string + modifiedByHandle: string + +} diff --git a/src/apps/admin/src/lib/models/UserGroup.model.ts b/src/apps/admin/src/lib/models/UserGroup.model.ts index b2327dea1..fab355e1b 100644 --- a/src/apps/admin/src/lib/models/UserGroup.model.ts +++ b/src/apps/admin/src/lib/models/UserGroup.model.ts @@ -1,7 +1,45 @@ +import moment from 'moment' + +import { TABLE_DATE_FORMAT } from '../../config/index.config' + /** * Model for user group info */ export interface UserGroup { id: string name: string + description: string + createdBy: string + createdByHandle?: string + createdAt: Date + createdAtString?: string + updatedBy: string + updatedByHandle?: string + updatedAt: Date + updatedAtString?: string +} + +/** + * Update user group to show in ui + * @param data data from backend response + * @returns updated user group info + */ +export function adjustUserGroupResponse(data: UserGroup): UserGroup { + const createdAt = data.createdAt ? new Date(data.createdAt) : data.createdAt + const updatedAt = data.updatedAt ? new Date(data.updatedAt) : data.updatedAt + return { + ...data, + createdAt, + createdAtString: data.createdAt + ? moment(data.createdAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.createdAt, + updatedAt, + updatedAtString: data.updatedAt + ? moment(data.updatedAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.updatedAt, + } } diff --git a/src/apps/admin/src/lib/models/UserGroupMember.model.ts b/src/apps/admin/src/lib/models/UserGroupMember.model.ts index 6f75bcd96..48c1878d7 100644 --- a/src/apps/admin/src/lib/models/UserGroupMember.model.ts +++ b/src/apps/admin/src/lib/models/UserGroupMember.model.ts @@ -1,6 +1,47 @@ +import moment from 'moment' + +import { TABLE_DATE_FORMAT } from '../../config/index.config' + /** * Model for user group member info */ export interface UserGroupMember { memberId: number + name?: string + membershipType: string + createdBy: string + createdByHandle?: string + createdAt: Date + createdAtString?: string + updatedBy: string + updatedByHandle?: string + updatedAt: Date + updatedAtString?: string +} + +/** + * Update user group member to show in ui + * @param data data from backend response + * @returns updated user group member info + */ +export function adjustUserGroupMemberResponse( + data: UserGroupMember, +): UserGroupMember { + const createdAt = data.createdAt ? new Date(data.createdAt) : data.createdAt + const updatedAt = data.updatedAt ? new Date(data.updatedAt) : data.updatedAt + return { + ...data, + createdAt, + createdAtString: data.createdAt + ? moment(data.createdAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.createdAt, + updatedAt, + updatedAtString: data.updatedAt + ? moment(data.updatedAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.updatedAt, + } } diff --git a/src/apps/admin/src/lib/models/UserRole.model.ts b/src/apps/admin/src/lib/models/UserRole.model.ts index 7a7d170d0..1be364325 100644 --- a/src/apps/admin/src/lib/models/UserRole.model.ts +++ b/src/apps/admin/src/lib/models/UserRole.model.ts @@ -1,7 +1,47 @@ +import moment from 'moment' + +import { TABLE_DATE_FORMAT } from '../../config/index.config' + /** * Model for user role info */ export interface UserRole { id: string roleName: string + createdBy?: string + createdByHandle?: string + createdAt: Date + createdAtString?: string + modifiedBy?: string + modifiedAt: Date + modifiedAtString?: string + modifiedByHandle?: string + subjects?: string[] +} + +/** + * Update user role to show in ui + * @param data data from backend response + * @returns updated user role info + */ +export function adjustUserRoleResponse(data: UserRole): UserRole { + const createdAt = data.createdAt ? new Date(data.createdAt) : data.createdAt + const modifiedAt = data.modifiedAt + ? new Date(data.modifiedAt) + : data.modifiedAt + return { + ...data, + createdAt, + createdAtString: data.createdAt + ? moment(data.createdAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.createdAt, + modifiedAt, + modifiedAtString: data.modifiedAt + ? moment(data.modifiedAt) + .local() + .format(TABLE_DATE_FORMAT) + : data.modifiedAt, + } } diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index daac21ba8..3381fd7ad 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -13,3 +13,14 @@ export * from './ApiV3Response.model' export * from './UserGroupMember.model' export * from './ApiV5ResponseSuccess.model' export * from './review-management' +export * from './FormRolesFilter.type' +export * from './FormRoleMembersFilters.model' +export * from './SearchUserInfo.model' +export * from './FormAddGroup.model' +export * from './FormGroupMembersFilters.model' +export * from './RoleMemberInfo.model' +export * from './SelectOption.model' +export * from './FormAddGroupMembers.type' +export * from './TableFilterType.type' +export * from './TableRolesFilter.type' +export * from './AdminAppContextType.type' diff --git a/src/apps/admin/src/lib/services/groups.service.ts b/src/apps/admin/src/lib/services/groups.service.ts index 7d51c1f7b..b1f259dc1 100644 --- a/src/apps/admin/src/lib/services/groups.service.ts +++ b/src/apps/admin/src/lib/services/groups.service.ts @@ -6,15 +6,20 @@ import qs from 'qs' import { EnvironmentConfig } from '~/config' import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync } from '~/libs/core' -import { UserGroup, UserGroupMember } from '../models' +import { + adjustUserGroupMemberResponse, + adjustUserGroupResponse, + FormAddGroup, + UserGroup, + UserGroupMember, +} from '../models' /** * Get a groups of the particular member * @param params query params. - * @returns resolves to the members group list - * by names. + * @returns resolves to the group list. */ -export const findByMember = async (params: { +export const findGroupByMember = async (params: { page: number perPage: number memberId: string @@ -23,7 +28,24 @@ export const findByMember = async (params: { const result = await xhrGetAsync( `${EnvironmentConfig.API.V5}/groups/?${qs.stringify(params)}`, ) - return result + return result.map(adjustUserGroupResponse) +} + +/** + * Get a groups of the particular id + * @param groupId group id. + * @param fields group info fields. + * @returns resolves to the group info + */ +export const findGroupById = async ( + groupId: string, + fields?: string[], +): Promise => { + const fieldsQuery = fields ? `?fields=${fields.join(',')}` : '' + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V5}/groups/${groupId}${fieldsQuery}`, + ) + return adjustUserGroupResponse(result) } /** @@ -44,7 +66,7 @@ export const fetchGroupMembers = async ( params, )}`, ) - return result + return result.map(adjustUserGroupMemberResponse) } /** @@ -58,7 +80,21 @@ export const fetchGroups = async (params: { const result = await xhrGetAsync( `${EnvironmentConfig.API.V5}/groups?${qs.stringify(params)}`, ) - return result + return result.map(adjustUserGroupResponse) +} + +/** + * Create new group + * @param data group info + * @returns resolves to the group info + */ +/** */ +export const createGroup = async (data: FormAddGroup): Promise => { + const result = await xhrPostAsync( + `${EnvironmentConfig.API.V5}/groups`, + data, + ) + return adjustUserGroupResponse(result) } /** @@ -67,17 +103,17 @@ export const fetchGroups = async (params: { * @param entity membership entity to add. * @returns resolves to the groupId, if success. */ -export const addMember = async ( +export const addGroupMember = async ( groupId: string, entity: { memberId: string - membershipType: 'user' + membershipType: 'user' | 'group' }, ): Promise => { const result = await xhrPostAsync< { memberId: string - membershipType: 'user' + membershipType: 'user' | 'group' }, string >(`${EnvironmentConfig.API.V5}/groups/${groupId}/members`, entity) @@ -90,7 +126,7 @@ export const addMember = async ( * @param memberId member id. * @returns resolves to the groupId, if success. */ -export const removeMember = async ( +export const removeGroupMember = async ( groupId: string, memberId: number, ): Promise => { diff --git a/src/apps/admin/src/lib/services/roles.service.ts b/src/apps/admin/src/lib/services/roles.service.ts index 3cbe91f51..64f2f9a46 100644 --- a/src/apps/admin/src/lib/services/roles.service.ts +++ b/src/apps/admin/src/lib/services/roles.service.ts @@ -6,7 +6,7 @@ import _ from 'lodash' import { EnvironmentConfig } from '~/config' import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync } from '~/libs/core' -import { ApiV3Response, UserRole } from '../models' +import { adjustUserRoleResponse, ApiV3Response, UserRole } from '../models' /** * Fetchs roles of the specified subject @@ -20,7 +20,8 @@ export const fetchRolesBySubject = async ( const result = await xhrGetAsync>( `${EnvironmentConfig.API.V3}/roles/?filter=subjectID=${subjectId}`, ) - return _.orderBy(result.result.content, ['roleName'], ['asc']) + const roles = result.result.content.map(adjustUserRoleResponse) + return _.orderBy(roles, ['roleName'], ['asc']) } /** @@ -32,7 +33,25 @@ export const fetchRoles = async (): Promise => { const result = await xhrGetAsync>( `${EnvironmentConfig.API.V3}/roles`, ) - return _.orderBy(result.result.content, ['roleName'], ['asc']) + const roles = result.result.content.map(adjustUserRoleResponse) + return _.orderBy(roles, ['roleName'], ['asc']) +} + +/** + * Create role. + * @param roleName role name. + * @returns resolves to the role object, if success. + */ +export const createRole = async (roleName: string): Promise => { + const result = await xhrPostAsync>( + `${EnvironmentConfig.API.V3}/roles`, + { + param: { + roleName, + }, + }, + ) + return adjustUserRoleResponse(result.result.content) } /** @@ -67,3 +86,78 @@ export const unassignRole = async ( ) return result.result.content } + +/** + * Fetchs role info + * @param roleId role id. + * @param fields role info fields. + * @returns resolves to the role object. + */ +export const fetchRole = async ( + roleId: string, + fields: string[], +): Promise => { + // there is a bug in backend, when we ask to get role subjects + // but there are no subjects, backend returns 404 even if role exists + // as a workaround we get role without subjects first to check if it exists + // and only after we try to get it subject + // TODO: remove code in this if, after this bug is fixed at the backend + // keep only the part after else + if (fields && _.includes(fields, 'subjects')) { + const fieldsWithouSubjects = _.without(fields, 'subjects') + // if there are no fields after removing 'subjects', add 'id' to retrieve minimum data + if (!fieldsWithouSubjects.length) { + fieldsWithouSubjects.push('id') + } + + const fieldsQuery = fields + ? `?fields=${fieldsWithouSubjects.join(',')}` + : '' + + return xhrGetAsync>( + `${EnvironmentConfig.API.V3}/roles/${roleId}${fieldsQuery}`, + ) + .then(async (res: ApiV3Response) => { + const roleWithoutSubjects = res.result.content + + // now let's try to get subjects + return xhrGetAsync>( + `${EnvironmentConfig.API.V3}/roles/${roleId}?fields=subjects`, + ) + // populate role with subjects and return it + .then((resChild: ApiV3Response) => _.assign( + roleWithoutSubjects, + { + subjects: resChild.result.content.subjects, + }, + )) + .catch((error: any) => { + // if get error 404 in this case we know role exits + // so just return roleWithoutSubjects with subjects as en empty array + if ( + error.data + && error.data.result + && error.data.result.status === 404 + ) { + return adjustUserRoleResponse( + _.assign(roleWithoutSubjects, { + subjects: [], + }), + ) + + } + + // for other errors return rejected promise with error + return Promise.reject(error) + }) + }) + + } + + // if don't ask for subjects, then just normal request + const fieldsQuery = fields ? `?fields=${fields.join(',')}` : '' + const result = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/roles/${roleId}${fieldsQuery}`, + ) + return adjustUserRoleResponse(result.result.content) +} diff --git a/src/apps/admin/src/lib/services/user.service.ts b/src/apps/admin/src/lib/services/user.service.ts index a17af363e..5e062c3b6 100644 --- a/src/apps/admin/src/lib/services/user.service.ts +++ b/src/apps/admin/src/lib/services/user.service.ts @@ -17,11 +17,11 @@ import { */ export const getMemberSuggestionsByHandle = async ( handle: string, -): Promise> => { +): Promise> => { type v3Response = { result: { content: T } } - const data = await xhrGetAsync>>( - `${EnvironmentConfig.API.V3}/members/_suggest/${handle}`, - ) + const data = await xhrGetAsync< + v3Response> + >(`${EnvironmentConfig.API.V3}/members/_suggest/${handle}`) return data.result.content } @@ -50,13 +50,13 @@ export const getMembersByHandle = async ( export const searchUsers = async (options?: { fields?: string filter?: string - limit?: string + limit?: number }): Promise => { let query = '' const opts: { fields?: string filter?: string - limit?: string + limit?: number } = options || {} _.forOwn( { @@ -138,3 +138,15 @@ export const fetchAchievements = async ( ) return result.result.content.map(adjustUserStatusHistoryResponse) } + +/** + * Find user by id. + * @param userId user id. + * @returns resolves to user info + */ +export const findUserById = async (userId: string | number): Promise => { + const result = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/users/${userId}`, + ) + return adjustUserInfoResponse(result.result.content) +} diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index c77e553af..9153c777e 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -1,13 +1,19 @@ import * as Yup from 'yup' import { + FormAddGroup, + FormAddGroupMembers, FormEditUserEmail, FormEditUserGroup, FormEditUserRole, + FormGroupMembersFilters, + FormRoleMembersFilters, + FormRolesFilter, FormSearchByKey, FormUsersFilters, } from '../models' import { FormEditUserStatus } from '../models/FormEditUserStatus.model' +import { FormAddRoleMembers } from '../models/FormAddRoleMembers.type' /** * validation schema for form filter users @@ -25,6 +31,127 @@ export const formUsersFiltersSchema: Yup.ObjectSchema .optional(), }) +/** + * validation schema for form role members filters + */ +export const formRoleMembersFiltersSchema: Yup.ObjectSchema + = Yup.object({ + userHandle: Yup.string() + .trim() + .optional(), + userId: Yup.string() + .trim() + .optional(), + }) + +/** + * validation schema for form group members filters + */ +export const formGroupMembersFiltersSchema: Yup.ObjectSchema + = Yup.object({ + createdAtFrom: Yup.date() + .optional() + .nullable(), + createdAtTo: Yup.date() + .optional() + .nullable(), + createdBy: Yup.string() + .trim() + .optional(), + memberId: Yup.string() + .trim() + .optional(), + memberName: Yup.string() + .trim() + .optional(), + modifiedAtFrom: Yup.date() + .optional() + .nullable(), + modifiedAtTo: Yup.date() + .optional() + .nullable(), + modifiedBy: Yup.string() + .trim() + .optional(), + }) + +/** + * validation schema for form filter roles + */ +export const formRolesFilterSchema: Yup.ObjectSchema + = Yup.object({ + roleName: Yup.string() + .trim() + .required('Role is required.'), + }) + +/** + * validation schema for form add role members + */ +export const formAddRoleMembersSchema: Yup.ObjectSchema + = Yup.object({ + userHandles: Yup.array() + .of( + Yup.object() + .shape({ + handle: Yup.string() + .required('Handle is required.'), + userId: Yup.number() + .required('User id is required.'), + }), + ) + .required('Please choose at least one user handle.') + .min(1, 'Please choose at least one user handle.'), + }) + +/** + * validation schema for form add group members + */ +export const formAddGroupMembersSchema: Yup.ObjectSchema + = Yup.object({ + groupIds: Yup.array() + .of( + Yup.object() + .shape({ + label: Yup.string() + .required('label id is required.'), + value: Yup.string() + .required('value is required.'), + }), + ) + .when('membershipType', (membershipType, schema) => { + if (membershipType[0] === 'group') { + return schema + .required('Please choose at least one group id.') + .min(1, 'Please choose at least one group id.') + } + + return schema + }), + membershipType: Yup.string() + .trim() + .required('membershipType is required.'), + userHandles: Yup.array() + .of( + Yup.object() + .shape({ + handle: Yup.string() + .required('Handle is required.'), + userId: Yup.number() + .required('User id is required.'), + }), + ) + .when('membershipType', (membershipType, schema) => { + if (membershipType[0] === 'user') { + return schema + .required('Please choose at least one user handle.') + .min(1, 'Please choose at least one user handle.') + } + + return schema + }), + }) + /** * validation schema for form edit user email */ @@ -36,6 +163,23 @@ export const formEditUserEmailSchema: Yup.ObjectSchema .required('Email address is required.'), }) +/** + * validation schema for form add group + */ +export const formAddGroupSchema: Yup.ObjectSchema + = Yup.object({ + description: Yup.string() + .trim() + .optional(), + name: Yup.string() + .trim() + .required('Name is required.'), + privateGroup: Yup.boolean() + .optional(), + selfRegister: Yup.boolean() + .optional(), + }) + /** * validation schema for form edit user status * @param previousStatus diff --git a/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.module.scss b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.module.scss new file mode 100644 index 000000000..5a88bd311 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.module.scss @@ -0,0 +1,84 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.textTableTitle { + font-size: 22px; + line-height: 26px; + color: $black-100; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.blockForm { + padding: 0 $sp-8 $sp-8; + display: flex; + flex-direction: column; + gap: 15px; + position: relative; + + @include ltelg { + padding: 0 $sp-4 $sp-4; + } +} + +.blockFormFields { + display: flex; + flex-direction: column; + gap: 18px; +} + +.formRadioBtn { + display: flex; + gap: 15px; +} + +.blockFieldRadio { + display: flex; + flex-direction: column; + gap: 10px; +} + +.blockBtns { + display: flex; + gap: 15px; + justify-content: flex-end; +} + +.textRadiosLabel { + font-weight: 700; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.tsx new file mode 100644 index 000000000..7a6d58882 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/PermissionAddGroupMembersPage.tsx @@ -0,0 +1,266 @@ +/** + * Permission add group members page. + */ +import { + FC, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import { NavigateFunction, useNavigate, useParams } from 'react-router-dom' +import _ from 'lodash' +import classNames from 'classnames' + +import { + Button, + InputRadio, + LinkButton, + LoadingSpinner, + PageDivider, + PageTitle, +} from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { InputGroupSelector } from '../../lib/components/InputGroupSelector' +import { InputHandlesSelector } from '../../lib/components/InputHandlesSelector' +import { useManagePermissionGroups, useManagePermissionGroupsProps } from '../../lib/hooks' +import { AdminAppContext, PageContent, PageHeader } from '../../lib' +import { + AdminAppContextType, + FormAddGroupMembers, + SelectOption, +} from '../../lib/models' +import { formAddGroupMembersSchema } from '../../lib/utils' +import { useManageAddGroupMembers, useManageAddGroupMembersProps } from '../../lib/hooks/useManageAddGroupMembers' + +import styles from './PermissionAddGroupMembersPage.module.scss' + +interface Props { + className?: string +} + +const pageTitle = 'Add Group Members' + +const membershipTypes: ('user' | 'group')[] = ['user', 'group'] + +export const PermissionAddGroupMembersPage: FC = (props: Props) => { + const navigate: NavigateFunction = useNavigate() + const { groupId = '' }: { groupId?: string } = useParams<{ + groupId: string + }>() + const { + control, + handleSubmit, + watch, + formState: { isValid }, + }: UseFormReturn = useForm({ + defaultValues: { + groupIds: [], + membershipType: 'user', + userHandles: [], + }, + mode: 'all', + resolver: yupResolver(formAddGroupMembersSchema), + }) + const membershipType = watch('membershipType') + + const { + usersMapping, + setUserFromSearch, + setGroupFromSearch, + loadGroup, + groupsMapping, + }: AdminAppContextType = useContext(AdminAppContext) + + const loadingGroup = useMemo( + () => !groupsMapping[groupId], + [groupsMapping, groupId], + ) + + useEffect(() => { + loadGroup(groupId) + }, [groupId, loadGroup]) + + const { isLoading: isLoadingGroups, groups }: useManagePermissionGroupsProps = useManagePermissionGroups( + _.noop, + _.noop, + usersMapping, + ) + const { isAdding, doAddGroup }: useManageAddGroupMembersProps = useManageAddGroupMembers(groupId) + + const groupsOptions = useMemo( + () => groups.map(item => ({ + label: item.id, + value: item.name, + })), + [groups], + ) + const onSubmit = useCallback( + (data: FormAddGroupMembers) => { + let ids: string[] = [] + if (data.membershipType === 'user' && data.userHandles) { + setUserFromSearch(data.userHandles) + ids = data.userHandles.map(item => `${item.userId}`) + } + + if (data.membershipType === 'group' && data.groupIds) { + setGroupFromSearch(data.groupIds) + ids = data.groupIds.map(item => `${item.label}`) + } + + doAddGroup(data.membershipType, ids, () => { + navigate('./..') + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ {loadingGroup ? ( + +
+ +
+
+ ) : ( + +

+ {groupsMapping[groupId]} +

+ +
+
+
+ + Add member of type + + + }) { + return ( +
+ {membershipTypes.map(item => ( + + ))} +
+ ) + }} + /> +
+ {membershipType === 'user' && ( + + }) { + return ( + + ) + }} + /> + )} + {membershipType === 'group' && ( + + }) { + return ( + + ) + }} + /> + )} +
+ +
+ + + Cancel + +
+ + {isAdding && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +export default PermissionAddGroupMembersPage diff --git a/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/index.ts b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/index.ts new file mode 100644 index 000000000..c373a6725 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddGroupMembersPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionAddGroupMembersPage } from './PermissionAddGroupMembersPage' diff --git a/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.module.scss b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.module.scss new file mode 100644 index 000000000..f300254fe --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.module.scss @@ -0,0 +1,63 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.textTableTitle { + font-size: 22px; + line-height: 26px; + color: $black-100; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.blockForm { + padding: 0 $sp-8 $sp-8; + display: flex; + flex-direction: column; + gap: 15px; + position: relative; + + @include ltelg { + padding: 0 $sp-4 $sp-4; + } +} + +.blockBtns { + display: flex; + gap: 15px; + justify-content: flex-end; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.tsx new file mode 100644 index 000000000..49d27637b --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/PermissionAddRoleMembersPage.tsx @@ -0,0 +1,154 @@ +/** + * Permission add role members page. + */ +import { FC, useCallback, useContext } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import { NavigateFunction, useNavigate, useParams } from 'react-router-dom' +import classNames from 'classnames' + +import { + Button, + LinkButton, + LoadingSpinner, + PageDivider, + PageTitle, +} from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { useManageAddRoleMembers, useManageAddRoleMembersProps } from '../../lib/hooks' +import { InputHandlesSelector } from '../../lib/components/InputHandlesSelector' +import { AdminAppContext, PageContent, PageHeader } from '../../lib' +import { AdminAppContextType } from '../../lib/models' +import { FormAddRoleMembers } from '../../lib/models/FormAddRoleMembers.type' +import { formAddRoleMembersSchema } from '../../lib/utils' + +import styles from './PermissionAddRoleMembersPage.module.scss' + +interface Props { + className?: string +} + +const pageTitle = 'Add Role Members' + +export const PermissionAddRoleMembersPage: FC = (props: Props) => { + const navigate: NavigateFunction = useNavigate() + const { setUserFromSearch }: AdminAppContextType + = useContext(AdminAppContext) + const { + control, + handleSubmit, + formState: { isValid }, + }: UseFormReturn = useForm({ + defaultValues: { + userHandles: [], + }, + mode: 'all', + resolver: yupResolver(formAddRoleMembersSchema), + }) + const { roleId = '' }: { roleId?: string } = useParams<{ + roleId: string + }>() + const { + isLoading, + roleInfo, + isAdding, + doAddRole, + }: useManageAddRoleMembersProps = useManageAddRoleMembers(roleId) + const onSubmit = useCallback( + (data: FormAddRoleMembers) => { + setUserFromSearch(data.userHandles) + doAddRole(data.userHandles, () => { + navigate('./..') + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ {isLoading ? ( + +
+ +
+
+
+ + Cancel + +
+
+
+ ) : ( + +

+ {roleInfo?.roleName} +

+ +
+ + }) { + return ( + + ) + }} + /> + +
+ + + Cancel + +
+ + {isAdding && ( +
+ +
+ )} + +
+ )} +
+ ) +} + +export default PermissionAddRoleMembersPage diff --git a/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/index.ts b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/index.ts new file mode 100644 index 000000000..eee066ae1 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionAddRoleMembersPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionAddRoleMembersPage } from './PermissionAddRoleMembersPage' diff --git a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.module.scss b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.module.scss new file mode 100644 index 000000000..4826c3d31 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.module.scss @@ -0,0 +1,88 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.headerActions { + display: flex; + gap: 30px; + margin-left: auto; + margin-top: $sp-2; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.textTableTitle { + font-size: 22px; + line-height: 26px; + color: $black-100; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.blockSections { + display: flex; + flex-direction: column; + gap: 30px; +} + +.textSectionTitle { + font-size: 21px; + line-height: 26px; + color: $black-100; + padding-left: $sp-8; + + @include ltelg { + padding-left: $sp-4; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} + +.removeSelectionButtonContainer { + padding: 20px 0 30px $sp-8; + + + @include ltemd { + text-align: center; + padding-left: $sp-4; + } +} + +.blockTableContainer { + position: relative; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx new file mode 100644 index 000000000..1ff610dcf --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx @@ -0,0 +1,222 @@ +/** + * Permission group members page. + */ +import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import _ from 'lodash' +import classNames from 'classnames' + +import { + Button, + LinkButton, + LoadingSpinner, + PageDivider, + PageTitle, +} from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { GroupMembersFilters } from '../../lib/components/GroupMembersFilters' +import { GroupMembersTable } from '../../lib/components/GroupMembersTable' +import { useManagePermissionGroupMembers, useManagePermissionGroupMembersProps } from '../../lib/hooks' +import { AdminAppContext, PageContent, PageHeader } from '../../lib' +import { AdminAppContextType, FormGroupMembersFilters, UserGroupMember } from '../../lib/models' +import { useTableSelection, useTableSelectionProps } from '../../lib/hooks/useTableSelection' + +import styles from './PermissionGroupMembersPage.module.scss' + +interface Props { + className?: string +} +const pageTitle = 'Group Members' + +export const PermissionGroupMembersPage: FC = (props: Props) => { + const memberTypes = useMemo(() => ['group', 'user'], []) + const { groupId = '' }: { groupId?: string } = useParams<{ + groupId: string + }>() + const { + loadGroup, + groupsMapping, + loadUser, + usersMapping, + cancelLoadUser, + cancelLoadGroup, + }: AdminAppContextType = useContext(AdminAppContext) + const { + isLoading, + groupMembers, + isFiltering, + doFilterGroupMembers, + isRemoving, + isRemovingBool, + doRemoveGroupMember, + doRemoveGroupMembers, + }: useManagePermissionGroupMembersProps = useManagePermissionGroupMembers( + groupId, + loadUser, + cancelLoadUser, + usersMapping, + loadGroup, + cancelLoadGroup, + groupsMapping, + ) + const [datasIdsMapping, setDatasIdsMapping] = useState<{ + [memberType: string]: number[] + }>({ + group: [], + user: [], + }) + const datasIds = useMemo( + () => _.reduce( + datasIdsMapping, + (acc: number[], datas: number[]) => [...acc, ...datas], + [], + ), + [datasIdsMapping], + ) + const { + selectedDatas, + selectedDatasArray, + toggleSelect, + hasSelected, + forceSelect, + forceUnSelect, + unselectAll, + }: useTableSelectionProps = useTableSelection(datasIds) + + useEffect(() => { + loadGroup(groupId) + }, [groupId, loadGroup]) + + const loadingGroup = useMemo( + () => !groupsMapping[groupId], + [groupsMapping, groupId], + ) + + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ + + Back + +
+
+ {loadingGroup ? ( + +
+ +
+
+ ) : ( + +

+ {groupsMapping[groupId]} +

+ +
+ {memberTypes.map(memberType => ( +
+ + {_.startCase(`${memberType}s`)} + + + {isLoading || isFiltering[memberType] ? ( +
+ +
+ ) : ( + <> + {(groupMembers[memberType] || []) + .length === 0 ? ( +

+ No members +

+ ) : ( +
+ ({ + ...prev, + [memberType]: datas.map( + item => item.memberId, + ), + }), + ) + }} + /> + + {isRemovingBool && ( +
+ +
+ )} +
+ )} + + )} +
+ ))} +
+
+ +
+
+ )} +
+ ) +} + +export default PermissionGroupMembersPage diff --git a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/index.ts b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/index.ts new file mode 100644 index 000000000..cbd353085 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionGroupMembersPage } from './PermissionGroupMembersPage' diff --git a/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.module.scss b/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.module.scss new file mode 100644 index 000000000..7862fdfea --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.module.scss @@ -0,0 +1,28 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} + +.btnNewGroup { + margin: $sp-8 $sp-8 $sp-4 $sp-8; + + @include ltelg { + margin: $sp-4; + } +} diff --git a/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.tsx b/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.tsx new file mode 100644 index 000000000..fb94e452a --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupsPage/PermissionGroupsPage.tsx @@ -0,0 +1,97 @@ +/** + * Permission groups page. + */ +import { FC, useContext, useState } from 'react' +import classNames from 'classnames' + +import { Button, LoadingSpinner, PageTitle } from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { DialogAddGroup } from '../../lib/components/DialogAddGroup' +import { GroupsTable } from '../../lib/components/GroupsTable' +import { useManagePermissionGroups, useManagePermissionGroupsProps } from '../../lib/hooks' +import { MSG_NO_RECORD_FOUND } from '../../config/index.config' +import { AdminAppContext, PageContent, PageHeader } from '../../lib' +import { AdminAppContextType, FormAddGroup } from '../../lib/models' + +import styles from './PermissionGroupsPage.module.scss' + +interface Props { + className?: string +} + +const pageTitle = 'Groups' + +export const PermissionGroupsPage: FC = (props: Props) => { + const [openDialogAddGroup, setOpenDialogAddGroup] = useState(false) + const { loadUser, cancelLoadUser, usersMapping }: AdminAppContextType + = useContext(AdminAppContext) + const { + isLoading, + groups, + isAdding, + doAddGroup, + }: useManagePermissionGroupsProps = useManagePermissionGroups( + loadUser, + cancelLoadUser, + usersMapping, + ) + + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ + + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ ) +} + +export default PermissionGroupsPage diff --git a/src/apps/admin/src/permission-management/PermissionGroupsPage/index.ts b/src/apps/admin/src/permission-management/PermissionGroupsPage/index.ts new file mode 100644 index 000000000..d2c8d0934 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionGroupsPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionGroupsPage } from './PermissionGroupsPage' diff --git a/src/apps/admin/src/permission-management/PermissionManagement.tsx b/src/apps/admin/src/permission-management/PermissionManagement.tsx new file mode 100644 index 000000000..623f68ea8 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionManagement.tsx @@ -0,0 +1,37 @@ +/** + * Wrapper for permission managment + */ +import { FC, useContext, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { adminRoutes } from '../admin-app.routes' +import { permissionManagementRouteId } from '../config/routes.config' + +/** + * The router outlet with layout. + */ +export const PermissionManagement: FC = () => { + const childRoutes = useChildRoutes() + + return ( + <> + + {childRoutes} + + ) +} + +function useChildRoutes(): Array | undefined { + const { getRouteElement }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo( + () => adminRoutes[0].children + ?.find(r => r.id === permissionManagementRouteId) + ?.children?.map(getRouteElement), + [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement + ) + return childRoutes +} + +export default PermissionManagement diff --git a/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.module.scss b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.module.scss new file mode 100644 index 000000000..12273a0d8 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.module.scss @@ -0,0 +1,61 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.headerActions { + display: flex; + gap: 30px; + margin-left: auto; + margin-top: $sp-2; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.textTableTitle { + font-size: 22px; + line-height: 26px; + color: $black-100; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} + +.blockTableContainer { + position: relative; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx new file mode 100644 index 000000000..b2e8fba48 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx @@ -0,0 +1,110 @@ +/** + * Permission role members page. + */ +import { FC } from 'react' +import { useParams } from 'react-router-dom' +import classNames from 'classnames' + +import { LinkButton, LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { useManagePermissionRoleMembers, useManagePermissionRoleMembersProps } from '../../lib/hooks' +import { PageContent, PageHeader } from '../../lib' +import { RoleMembersFilters } from '../../lib/components/RoleMembersFilters' +import { RoleMembersTable } from '../../lib/components/RoleMembersTable' + +import styles from './PermissionRoleMembersPage.module.scss' + +interface Props { + className?: string +} +const pageTitle = 'Role Members' + +export const PermissionRoleMembersPage: FC = (props: Props) => { + const { roleId = '' }: { roleId?: string } = useParams<{ + roleId: string + }>() + const { + isLoading, + roleInfo, + roleMembers, + doFilterRoleMembers, + isFiltering, + isRemoving, + isRemovingBool, + doRemoveRoleMember, + doRemoveRoleMembers, + }: useManagePermissionRoleMembersProps = useManagePermissionRoleMembers(roleId) + + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ + + Back + +
+
+ {isLoading ? ( + +
+ +
+
+ ) : ( + +

+ {roleInfo?.roleName} +

+ + + + {isFiltering ? ( +
+ +
+ ) : ( + <> + {roleMembers.length === 0 ? ( +

+ No members +

+ ) : ( +
+ + + {isRemovingBool && ( +
+ +
+ )} +
+ )} + + )} +
+ )} +
+ ) +} + +export default PermissionRoleMembersPage diff --git a/src/apps/admin/src/permission-management/PermissionRoleMembersPage/index.ts b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/index.ts new file mode 100644 index 000000000..35f2e364b --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionRoleMembersPage } from './PermissionRoleMembersPage' diff --git a/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.module.scss b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.module.scss new file mode 100644 index 000000000..e143ae4de --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.module.scss @@ -0,0 +1,18 @@ +.container { + display: flex; + flex-direction: column; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} diff --git a/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx new file mode 100644 index 000000000..f42bf1dd2 --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx @@ -0,0 +1,82 @@ +/** + * Permission roles page. + */ +import { FC, useContext } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui' + +import { + useManagePermissionRoles, + useManagePermissionRolesProps, +} from '../../lib/hooks' +import { MSG_NO_RECORD_FOUND } from '../../config/index.config' +import { AdminAppContext, PageContent, PageHeader } from '../../lib' +import { AdminAppContextType, TableRolesFilter } from '../../lib/models' +import { RolesFilter } from '../../lib/components/RolesFilter' +import { RolesTable } from '../../lib/components/RolesTable' + +import styles from './PermissionRolesPage.module.scss' + +interface Props { + className?: string +} + +const pageTitle = 'Roles' + +export const PermissionRolesPage: FC = (props: Props) => { + const { loadUser, usersMapping }: AdminAppContextType + = useContext(AdminAppContext) + const { + isLoading, + roles, + doAddRole, + isAdding, + doFilterRole, + }: useManagePermissionRolesProps = useManagePermissionRoles( + loadUser, + usersMapping, + ) + + return ( +
+ {pageTitle} + +

{pageTitle}

+
+ + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {roles.length === 0 ? ( +

+ {MSG_NO_RECORD_FOUND} +

+ ) : ( + + )} + + )} +
+
+ ) +} + +export default PermissionRolesPage diff --git a/src/apps/admin/src/permission-management/PermissionRolesPage/index.ts b/src/apps/admin/src/permission-management/PermissionRolesPage/index.ts new file mode 100644 index 000000000..fe0f31dde --- /dev/null +++ b/src/apps/admin/src/permission-management/PermissionRolesPage/index.ts @@ -0,0 +1 @@ +export { default as PermissionRolesPage } from './PermissionRolesPage' diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.module.scss index a7bcb2a2f..e0b193b4b 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-checkbox/InputCheckbox.module.scss @@ -49,6 +49,20 @@ border-radius: 3px; border-color: $black-20; background-color: $tc-white; + + &:after { + transform: rotate(45deg); + display: table; + width: 6px; + height: 10px; + border: 2px solid $tc-white; + border-top: 0; + border-left: 0; + content: ' '; + + position: relative; + top: -2px; + } } &:global(.rc-checkbox-checked) { diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx index 1575c64f8..b9d81503c 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx @@ -12,7 +12,7 @@ import { IconOutline } from '../../../../svgs' import styles from './InputDatePicker.module.scss' interface InputDatePickerProps { - date: Date | undefined + date: Date | undefined | null onChange: (date: Date | null) => void readonly className?: string readonly dateFormat?: string | string[] @@ -29,6 +29,7 @@ interface InputDatePickerProps { readonly placeholder?: string readonly showMonthPicker?: boolean readonly showYearPicker?: boolean + readonly isClearable?: boolean readonly tabIndex?: number } @@ -180,6 +181,7 @@ const InputDatePicker: FC = (props: InputDatePickerProps) portalId='react-date-portal' onFocus={() => setStateHasFocus(true)} onBlur={() => setStateHasFocus(false)} + isClearable={props.isClearable} /> ) diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx index 4a4f56523..ccfa44f0f 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx @@ -1,4 +1,5 @@ import { FC, FocusEvent } from 'react' +import { UseFormRegisterReturn } from 'react-hook-form' import { FormInputAutocompleteOption } from '../form-input-autcomplete-option.enum' import { InputWrapper } from '../input-wrapper' @@ -21,6 +22,7 @@ interface InputTextareaProps { readonly spellCheck?: boolean readonly tabIndex?: number readonly value?: string | number + readonly inputControl?: UseFormRegisterReturn } const InputTextarea: FC = (props: InputTextareaProps) => ( @@ -35,14 +37,15 @@ const InputTextarea: FC = (props: InputTextareaProps) => (