From df6208e309be89901b2c888306d04ee3ff7851c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 22 Jul 2022 08:31:08 +0100 Subject: [PATCH] feat: add user groups (#1130) * feat: add user groups table * add groups and group view * fix top level await on mock data * add UG flag * create group files, refactor group cards * add generic badge component * adapt hooks to use endpoints * implement basic create group * fix: update snap * fix: type id as string for now * implement create group, use api, refactoring * add stars to group owners * refactor GroupForm.tsx to use styled components * feat: remove group * add edit group * add group card actions * feat: edit and remove group users * add users to groups * Initial commit * refine project access table * add project access group view * Take users and groups from backend * Add onsubmit * new project access, assign and edit * fix EditGroup, Group * Finish assigning roles in project * List assigned projects in group card * Run prettier * Add added column to project access table Co-authored-by: Jaanus Sellin Co-authored-by: sighphyre --- .../BillingPlan/BillingPlan.tsx | 12 +- .../admin/groups/CreateGroup/CreateGroup.tsx | 97 ++++ .../admin/groups/EditGroup/EditGroup.tsx | 94 ++++ .../Group/AddGroupUser/AddGroupUser.tsx | 160 ++++++ .../Group/EditGroupUser/EditGroupUser.tsx | 180 +++++++ .../component/admin/groups/Group/Group.tsx | 402 ++++++++++++++ .../Group/RemoveGroupUser/RemoveGroupUser.tsx | 70 +++ .../admin/groups/GroupForm/GroupForm.tsx | 120 +++++ .../GroupFormUsersSelect.tsx | 121 +++++ .../GroupFormUsersTable.tsx | 148 +++++ .../GroupUserRoleCell/GroupUserRoleCell.tsx | 66 +++ .../component/admin/groups/GroupsAdmin.tsx | 11 + .../groups/GroupsList/GroupCard/GroupCard.tsx | 124 +++++ .../GroupCardActions/GroupCardActions.tsx | 114 ++++ .../GroupCardAvatars/GroupCardAvatars.tsx | 92 ++++ .../admin/groups/GroupsList/GroupsList.tsx | 137 +++++ .../admin/groups/RemoveGroup/RemoveGroup.tsx | 60 +++ .../admin/groups/hooks/useGroupForm.ts | 60 +++ .../src/component/admin/menu/AdminMenu.tsx | 13 + frontend/src/component/common/Badge/Badge.tsx | 85 +++ .../common/MainHeader/MainHeader.tsx | 55 ++ .../common/PageContent/PageContent.tsx | 3 +- .../common/PageHeader/PageHeader.tsx | 6 +- .../common/StatusBadge/StatusBadge.tsx | 34 -- .../CellSortable/CellSortable.tsx | 1 + .../common/Table/cells/LinkCell/LinkCell.tsx | 10 + frontend/src/component/common/flags.ts | 1 + .../EnvironmentNameCell.tsx | 11 +- .../FeatureOverviewEnvironment.tsx | 9 +- .../__snapshots__/routes.test.tsx.snap | 38 ++ frontend/src/component/menu/routes.ts | 42 +- .../project/ProjectAccess/ProjectAccess.tsx | 7 +- .../ProjectAccessAddUser.tsx | 227 -------- .../ProjectAccessAssign.tsx | 390 ++++++++++++++ .../ProjectRoleDescription.tsx | 103 ++++ .../ProjectAccess/ProjectAccessPage.tsx | 97 ---- .../ProjectAccessTable/ProjectAccessTable.tsx | 505 ++++++++++++++---- .../ProjectRoleCell.styles.tsx | 9 - .../ProjectRoleCell/ProjectRoleCell.tsx | 38 -- .../ProjectGroupView/ProjectGroupView.tsx | 270 ++++++++++ .../ProjectRoleSelect/ProjectRoleSelect.tsx | 82 --- .../StrategiesList/StrategiesList.tsx | 13 +- .../__snapshots__/TagTypeList.test.tsx.snap | 5 +- .../api/actions/useGroupApi/useGroupApi.ts | 64 +++ .../actions/useProjectApi/useProjectApi.ts | 56 ++ .../hooks/api/getters/useGroup/useGroup.ts | 42 ++ .../hooks/api/getters/useGroups/useGroups.ts | 40 ++ .../useProjectAccess/useProjectAccess.ts | 42 +- .../api/getters/useUiConfig/defaultValue.ts | 1 + .../api/getters/useUiConfig/useUiConfig.ts | 6 +- frontend/src/interfaces/group.ts | 28 + frontend/src/interfaces/uiConfig.ts | 1 + frontend/src/interfaces/user.ts | 1 + frontend/src/themes/theme.ts | 14 +- frontend/src/themes/themeTypes.ts | 12 +- frontend/src/utils/testIds.ts | 5 + 56 files changed, 3789 insertions(+), 645 deletions(-) create mode 100644 frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx create mode 100644 frontend/src/component/admin/groups/EditGroup/EditGroup.tsx create mode 100644 frontend/src/component/admin/groups/Group/AddGroupUser/AddGroupUser.tsx create mode 100644 frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx create mode 100644 frontend/src/component/admin/groups/Group/Group.tsx create mode 100644 frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx create mode 100644 frontend/src/component/admin/groups/GroupForm/GroupForm.tsx create mode 100644 frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx create mode 100644 frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx create mode 100644 frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx create mode 100644 frontend/src/component/admin/groups/GroupsAdmin.tsx create mode 100644 frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx create mode 100644 frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx create mode 100644 frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx create mode 100644 frontend/src/component/admin/groups/GroupsList/GroupsList.tsx create mode 100644 frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx create mode 100644 frontend/src/component/admin/groups/hooks/useGroupForm.ts create mode 100644 frontend/src/component/common/Badge/Badge.tsx create mode 100644 frontend/src/component/common/MainHeader/MainHeader.tsx delete mode 100644 frontend/src/component/common/StatusBadge/StatusBadge.tsx delete mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx delete mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessPage.tsx delete mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.styles.tsx delete mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.tsx create mode 100644 frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx delete mode 100644 frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx create mode 100644 frontend/src/hooks/api/actions/useGroupApi/useGroupApi.ts create mode 100644 frontend/src/hooks/api/getters/useGroup/useGroup.ts create mode 100644 frontend/src/hooks/api/getters/useGroups/useGroups.ts create mode 100644 frontend/src/interfaces/group.ts diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx index 8cd01433ff..e32726c912 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx @@ -14,6 +14,7 @@ import { GridRow } from 'component/common/GridRow/GridRow'; import { GridCol } from 'component/common/GridCol/GridCol'; import { GridColLink } from './GridColLink/GridColLink'; import { STRIPE } from 'component/admin/billing/flags'; +import { Badge } from 'component/common/Badge/Badge'; const StyledPlanBox = styled('aside')(({ theme }) => ({ padding: theme.spacing(2.5), @@ -30,15 +31,6 @@ const StyledInfoLabel = styled(Typography)(({ theme }) => ({ color: theme.palette.text.secondary, })); -const StyledPlanBadge = styled('span')(({ theme }) => ({ - padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, - borderRadius: theme.shape.borderRadiusLarge, - fontSize: theme.fontSizes.smallerBody, - backgroundColor: theme.palette.statusBadge.success, - color: theme.palette.success.dark, - fontWeight: theme.fontWeight.bold, -})); - const StyledPlanSpan = styled('span')(({ theme }) => ({ fontSize: '3.25rem', lineHeight: 1, @@ -116,7 +108,7 @@ export const BillingPlan: FC = ({ instanceStatus }) => { } /> - Current plan + Current plan ({ marginBottom: theme.spacing(3) })}> diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx new file mode 100644 index 0000000000..8b6bcec4d7 --- /dev/null +++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx @@ -0,0 +1,97 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import { GroupForm } from '../GroupForm/GroupForm'; +import { useGroupForm } from '../hooks/useGroupForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { UG_CREATE_BTN_ID } from 'utils/testIds'; +import { Button } from '@mui/material'; +import { CREATE } from 'constants/misc'; + +export const CreateGroup = () => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + const { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + } = useGroupForm(); + + const { createGroup, loading } = useGroupApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + + const payload = getGroupPayload(); + try { + const group = await createGroup(payload); + navigate(`/admin/groups/${group.id}`); + setToastData({ + title: 'Group created successfully', + text: 'Now you can start using your group.', + confetti: true, + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/groups' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(-1); + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx new file mode 100644 index 0000000000..ab0f3a1972 --- /dev/null +++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx @@ -0,0 +1,94 @@ +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { useNavigate } from 'react-router-dom'; +import { GroupForm } from '../GroupForm/GroupForm'; +import { useGroupForm } from '../hooks/useGroupForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { Button } from '@mui/material'; +import { EDIT } from 'constants/misc'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; + +export const EditGroup = () => { + const groupId = Number(useRequiredPathParam('groupId')); + const { group, refetchGroup } = useGroup(groupId); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + const navigate = useNavigate(); + + const { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + } = useGroupForm(group?.name, group?.description, group?.users); + + const { updateGroup, loading } = useGroupApi(); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + clearErrors(); + + const payload = getGroupPayload(); + try { + await updateGroup(groupId, payload); + refetchGroup(); + navigate(-1); + setToastData({ + title: 'Group updated successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/groups/${groupId}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`; + }; + + const handleCancel = () => { + navigate(-1); + }; + + return ( + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/Group/AddGroupUser/AddGroupUser.tsx b/frontend/src/component/admin/groups/Group/AddGroupUser/AddGroupUser.tsx new file mode 100644 index 0000000000..34e2729efc --- /dev/null +++ b/frontend/src/component/admin/groups/Group/AddGroupUser/AddGroupUser.tsx @@ -0,0 +1,160 @@ +import { Button, styled } from '@mui/material'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { IGroup, IGroupUser } from 'interfaces/group'; +import { FC, FormEvent, useEffect, useMemo, useState } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect'; +import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +interface IAddGroupUserProps { + open: boolean; + setOpen: React.Dispatch>; + group: IGroup; +} + +export const AddGroupUser: FC = ({ + open, + setOpen, + group, +}) => { + const { refetchGroup } = useGroup(group.id); + const { updateGroup, loading } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const [users, setUsers] = useState(group.users); + + useEffect(() => { + setUsers(group.users); + }, [group.users, open]); + + const newUsers = useMemo(() => { + return users.filter( + user => !group.users.some(({ id }) => id === user.id) + ); + }, [group.users, users]); + + const payload = useMemo(() => { + const addUsers = [...group.users, ...newUsers]; + return { + name: group.name, + description: group.description, + users: addUsers.map(({ id, role }) => ({ + user: { id }, + role, + })), + }; + }, [group, newUsers]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + try { + const message = + newUsers.length === 1 + ? `${ + newUsers[0].name || + newUsers[0].username || + newUsers[0].email + } added to the group` + : `${newUsers.length} users added to the group`; + await updateGroup(group.id, payload); + refetchGroup(); + setOpen(false); + setToastData({ + title: message, + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/groups/${group.id}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(payload, undefined, 2)}'`; + }; + + return ( + { + setOpen(false); + }} + label="Add user" + > + + +
+ + Add users to this group + + + +
+ + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx b/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx new file mode 100644 index 0000000000..bab86186d3 --- /dev/null +++ b/frontend/src/component/admin/groups/Group/EditGroupUser/EditGroupUser.tsx @@ -0,0 +1,180 @@ +import { Button, MenuItem, Select, styled } from '@mui/material'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { IGroup, IGroupUser, Role } from 'interfaces/group'; +import { FC, FormEvent, useEffect, useMemo, useState } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledUser = styled('div')(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), + padding: theme.spacing(1.5), + borderRadius: theme.shape.borderRadiusMedium, + backgroundColor: theme.palette.secondaryContainer, + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledSelect = styled(Select)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +interface IEditGroupUserProps { + open: boolean; + setOpen: React.Dispatch>; + user?: IGroupUser; + group: IGroup; +} + +export const EditGroupUser: FC = ({ + open, + setOpen, + user, + group, +}) => { + const { refetchGroup } = useGroup(group.id); + const { updateGroup, loading } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const [role, setRole] = useState(user?.role || Role.Member); + + useEffect(() => { + setRole(user?.role || Role.Member); + }, [user, open]); + + const payload = useMemo(() => { + const editUsers = [...group.users]; + const editUserIndex = editUsers.findIndex(({ id }) => id === user?.id); + editUsers[editUserIndex] = { + ...user!, + role, + }; + return { + name: group.name, + description: group.description, + users: editUsers.map(({ id, role }) => ({ + user: { id }, + role, + })), + }; + }, [group, user, role]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + try { + await updateGroup(group.id, payload); + refetchGroup(); + setOpen(false); + setToastData({ + title: 'User edited successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request PUT '${ + uiConfig.unleashUrl + }/api/admin/groups/${group.id}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(payload, undefined, 2)}'`; + }; + + return ( + { + setOpen(false); + }} + label="Edit user" + > + + +
+ + {user?.name || user?.username} + {user?.email} + + + Assign the role the user should have in this group + + + setRole(event.target.value as Role) + } + > + {Object.values(Role).map(role => ( + + {role} + + ))} + +
+ + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/admin/groups/Group/Group.tsx b/frontend/src/component/admin/groups/Group/Group.tsx new file mode 100644 index 0000000000..a0a35cc620 --- /dev/null +++ b/frontend/src/component/admin/groups/Group/Group.tsx @@ -0,0 +1,402 @@ +import { useEffect, useMemo, useState, VFC } from 'react'; +import { + Avatar, + Button, + IconButton, + styled, + Tooltip, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { useSearchParams } from 'react-router-dom'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { sortTypes } from 'utils/sortTypes'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import { IGroupUser, Role } from 'interfaces/group'; +import { useSearch } from 'hooks/useSearch'; +import { Search } from 'component/common/Search/Search'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Delete, Edit } from '@mui/icons-material'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { MainHeader } from 'component/common/MainHeader/MainHeader'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup'; +import { Link } from 'react-router-dom'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { AddGroupUser } from './AddGroupUser/AddGroupUser'; +import { EditGroupUser } from './EditGroupUser/EditGroupUser'; +import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser'; + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(4), + height: theme.spacing(4), + margin: 'auto', +})); + +const StyledEdit = styled(Edit)(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +const StyledDelete = styled(Delete)(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +export const groupUsersPlaceholder: IGroupUser[] = Array(15).fill({ + name: 'Name of the user', + username: 'Username of the user', + role: Role.Member, +}); + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +const defaultSort: SortingRule = { id: 'role', desc: true }; + +const { value: storedParams, setValue: setStoredParams } = createLocalStorage( + 'Group:v1', + defaultSort +); + +export const Group: VFC = () => { + const groupId = Number(useRequiredPathParam('groupId')); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const { group, loading } = useGroup(groupId); + const [removeOpen, setRemoveOpen] = useState(false); + const [addUserOpen, setAddUserOpen] = useState(false); + const [editUserOpen, setEditUserOpen] = useState(false); + const [removeUserOpen, setRemoveUserOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(); + + const columns = useMemo( + () => [ + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + maxWidth: 85, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: IGroupUser) => row.name || '', + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: IGroupUser) => row.username || row.email, + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + Header: 'User type', + accessor: 'role', + Cell: GroupUserRoleCell, + maxWidth: 150, + filterName: 'type', + }, + { + Header: 'Joined', + accessor: 'joinedAt', + Cell: DateCell, + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Last login', + accessor: (row: IGroupUser) => row.seenAt || '', + Cell: ({ row: { original: user } }: any) => ( + + ), + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: rowUser } }: any) => ( + + + { + setSelectedUser(rowUser); + setEditUserOpen(true); + }} + > + + + + + { + setSelectedUser(rowUser); + setRemoveUserOpen(true); + }} + > + + + + + ), + maxWidth: 100, + disableSortBy: true, + }, + ], + [setSelectedUser, setRemoveUserOpen] + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + globalFilter: searchParams.get('search') || '', + })); + const [searchValue, setSearchValue] = useState(initialState.globalFilter); + + const { + data: searchedData, + getSearchText, + getSearchContext, + } = useSearch(columns, searchValue, group?.users ?? []); + + const data = useMemo( + () => + searchedData?.length === 0 && loading + ? groupUsersPlaceholder + : searchedData, + [searchedData, loading] + ); + + const { + headerGroups, + rows, + prepareRow, + state: { sortBy }, + } = useTable( + { + columns: columns as any[], + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + useEffect(() => { + const tableState: PageQueryType = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [sortBy, searchValue, setSearchParams]); + + return ( + + + + + + setRemoveOpen(true)} + permission={ADMIN} + tooltipProps={{ + title: 'Remove group', + }} + > + + + + } + /> + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No users found matching “ + {searchValue} + ” in this group. + + } + elseShow={ + + This group is empty. Get started by + adding a user to the group. + + } + /> + } + /> + + + + + + + } + /> + ); +}; diff --git a/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx new file mode 100644 index 0000000000..5e8faf6ac2 --- /dev/null +++ b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx @@ -0,0 +1,70 @@ +import { Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroup } from 'hooks/api/getters/useGroup/useGroup'; +import useToast from 'hooks/useToast'; +import { IGroup, IGroupUser } from 'interfaces/group'; +import { FC } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IRemoveGroupUserProps { + open: boolean; + setOpen: React.Dispatch>; + user?: IGroupUser; + group: IGroup; +} + +export const RemoveGroupUser: FC = ({ + open, + setOpen, + user, + group, +}) => { + const { refetchGroup } = useGroup(group.id); + const { updateGroup } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + + const onRemoveClick = async () => { + try { + const groupPayload = { + ...group, + users: group.users + .filter(({ id }) => id !== user?.id) + .map(({ id, role }) => ({ + user: { id }, + role, + })), + }; + await updateGroup(group.id, groupPayload); + refetchGroup(); + setOpen(false); + setToastData({ + title: 'User removed from group successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + { + setOpen(false); + }} + title="Remove user from group" + > + + Are you sure you wish to remove{' '} + {user?.name || user?.username || user?.email}{' '} + from {group.name}? Removing the user from this + group may also remove their access from projects this group is + assigned to. + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx new file mode 100644 index 0000000000..9a387b06fd --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -0,0 +1,120 @@ +import { FC } from 'react'; +import { Button, styled } from '@mui/material'; +import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds'; +import Input from 'component/common/Input/Input'; +import { IGroupUser } from 'interfaces/group'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { GroupFormUsersSelect } from './GroupFormUsersSelect/GroupFormUsersSelect'; +import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +interface IGroupForm { + name: string; + description: string; + users: IGroupUser[]; + setName: React.Dispatch>; + setDescription: React.Dispatch>; + setUsers: React.Dispatch>; + handleSubmit: (e: any) => void; + handleCancel: () => void; + errors: { [key: string]: string }; + mode: 'Create' | 'Edit'; + clearErrors: () => void; +} + +export const GroupForm: FC = ({ + name, + description, + users, + setName, + setDescription, + setUsers, + handleSubmit, + handleCancel, + errors, + mode, + clearErrors, + children, +}) => ( + +
+ + What would you like to call your group? + + clearErrors()} + value={name} + onChange={e => setName(e.target.value)} + data-testid={UG_NAME_ID} + /> + + How would you describe your group? + + setDescription(e.target.value)} + data-testid={UG_DESC_ID} + /> + + + Add users to this group + + + + + } + /> +
+ + + {children} + + Cancel + + +
+); diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx new file mode 100644 index 0000000000..60f5f70fa9 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx @@ -0,0 +1,121 @@ +import { + Autocomplete, + Button, + Checkbox, + styled, + TextField, +} from '@mui/material'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import { IUser } from 'interfaces/user'; +import { useMemo, useState, VFC } from 'react'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { IGroupUser, Role } from 'interfaces/group'; + +const StyledOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({ + display: 'flex', + marginBottom: theme.spacing(3), + '& > div:first-of-type': { + width: '100%', + maxWidth: theme.spacing(50), + marginRight: theme.spacing(1), + }, +})); + +const renderOption = ( + props: React.HTMLAttributes, + option: IUser, + selected: boolean +) => ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name || option.username} + {option.email} + +
  • +); + +interface IGroupFormUsersSelectProps { + users: IGroupUser[]; + setUsers: React.Dispatch>; +} + +export const GroupFormUsersSelect: VFC = ({ + users, + setUsers, +}) => { + const { users: usersAll } = useUsers(); + const [selectedUsers, setSelectedUsers] = useState([]); + + const usersOptions = useMemo( + () => + usersAll.filter( + (user: IUser) => !users?.map(({ id }) => id).includes(user.id) + ), + [usersAll, users] + ); + + const onAdd = () => { + const usersToBeAdded = selectedUsers.map( + (user: IUser): IGroupUser => ({ + ...user, + role: Role.Member, + }) + ); + setUsers((users: IGroupUser[]) => [...users, ...usersToBeAdded]); + setSelectedUsers([]); + }; + + return ( + + { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setSelectedUsers(newValue); + }} + options={[...usersOptions].sort((a, b) => { + const aName = a.name || a.username || ''; + const bName = b.name || b.username || ''; + return aName.localeCompare(bName); + })} + renderOption={(props, option, { selected }) => + renderOption(props, option as IUser, selected) + } + getOptionLabel={(option: IUser) => + option.email || option.name || option.username || '' + } + renderInput={params => ( + + )} + /> + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx new file mode 100644 index 0000000000..46cc82d0f0 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx @@ -0,0 +1,148 @@ +import { useMemo, VFC } from 'react'; +import { Avatar, IconButton, styled, Tooltip } from '@mui/material'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { IGroupUser } from 'interfaces/group'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { Delete } from '@mui/icons-material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { VirtualizedTable } from 'component/common/Table'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(4), + height: theme.spacing(4), + margin: 'auto', +})); + +interface IGroupFormUsersTableProps { + users: IGroupUser[]; + setUsers: React.Dispatch>; +} + +export const GroupFormUsersTable: VFC = ({ + users, + setUsers, +}) => { + const columns = useMemo( + () => [ + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + maxWidth: 85, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: IGroupUser) => row.name || '', + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: IGroupUser) => row.username || row.email, + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + Header: 'Group role', + accessor: 'role', + Cell: ({ row: { original: rowUser } }: any) => ( + + setUsers((users: IGroupUser[]) => { + const newUsers = [...users]; + const index = newUsers.findIndex( + user => user.id === rowUser.id + ); + newUsers[index] = { + ...rowUser, + role, + }; + return newUsers; + }) + } + /> + ), + maxWidth: 150, + filterName: 'type', + }, + { + Header: 'Action', + id: 'Action', + align: 'center', + Cell: ({ row: { original: rowUser } }: any) => ( + + + + setUsers((users: IGroupUser[]) => + users.filter( + user => user.id !== rowUser.id + ) + ) + } + > + + + + + ), + maxWidth: 100, + disableSortBy: true, + }, + ], + [setUsers] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns: columns as any[], + data: users as any[], + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + 0} + show={ + + } + /> + ); +}; diff --git a/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx b/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx new file mode 100644 index 0000000000..3d26d2d3a5 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupUserRoleCell/GroupUserRoleCell.tsx @@ -0,0 +1,66 @@ +import { capitalize, MenuItem, Select, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { Role } from 'interfaces/group'; + +const StyledBadge = styled('div')(({ theme }) => ({ + padding: theme.spacing(0.5, 1), + textDecoration: 'none', + color: theme.palette.text.secondary, + border: `1px solid ${theme.palette.dividerAlternative}`, + background: theme.palette.activityIndicators.unknown, + display: 'inline-block', + borderRadius: theme.shape.borderRadius, + marginLeft: theme.spacing(1.5), + fontSize: theme.fontSizes.smallerBody, + fontWeight: theme.fontWeight.bold, + lineHeight: 1, +})); + +const StyledOwnerBadge = styled(StyledBadge)(({ theme }) => ({ + color: theme.palette.success.dark, + border: `1px solid ${theme.palette.success.border}`, + background: theme.palette.success.light, +})); + +interface IGroupUserRoleCellProps { + value?: string; + onChange?: (role: Role) => void; +} + +export const GroupUserRoleCell = ({ + value = Role.Member, + onChange, +}: IGroupUserRoleCellProps) => { + const renderBadge = () => ( + {capitalize(value)}} + elseShow={{capitalize(value)}} + /> + ); + + return ( + + + onChange!(event.target.value as Role) + } + > + {Object.values(Role).map(role => ( + + {role} + + ))} + + } + elseShow={() => renderBadge()} + /> + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsAdmin.tsx b/frontend/src/component/admin/groups/GroupsAdmin.tsx new file mode 100644 index 0000000000..ffe8b39814 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsAdmin.tsx @@ -0,0 +1,11 @@ +import { GroupsList } from './GroupsList/GroupsList'; +import AdminMenu from '../menu/AdminMenu'; + +export const GroupsAdmin = () => { + return ( +
    + + +
    + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx new file mode 100644 index 0000000000..a41e22a736 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx @@ -0,0 +1,124 @@ +import { styled, Tooltip } from '@mui/material'; +import { IGroup } from 'interfaces/group'; +import { Link } from 'react-router-dom'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars'; +import { Badge } from 'component/common/Badge/Badge'; +import { GroupCardActions } from './GroupCardActions/GroupCardActions'; +import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup'; +import { useState } from 'react'; + +const StyledLink = styled(Link)(({ theme }) => ({ + textDecoration: 'none', + color: theme.palette.text.primary, +})); + +const StyledGroupCard = styled('aside')(({ theme }) => ({ + padding: theme.spacing(2.5), + height: '100%', + border: `1px solid ${theme.palette.dividerAlternative}`, + borderRadius: theme.shape.borderRadiusLarge, + boxShadow: theme.boxShadows.card, + [theme.breakpoints.up('md')]: { + padding: theme.spacing(4), + }, +})); + +const StyledRow = styled('div')(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledHeaderTitle = styled('h2')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + fontWeight: theme.fontWeight.medium, +})); + +const StyledHeaderActions = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, +})); + +const StyledDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + marginTop: theme.spacing(1), + marginBottom: theme.spacing(4), +})); + +const StyledCounterDescription = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginLeft: theme.spacing(1), +})); + +const ProjectBadgeContainer = styled('div')(() => ({})); + +interface IGroupCardProps { + group: IGroup; +} + +export const GroupCard = ({ group }: IGroupCardProps) => { + const [removeOpen, setRemoveOpen] = useState(false); + + return ( + <> + + + + {group.name} + + setRemoveOpen(true)} + /> + + + {group.description} + + 0} + show={} + elseShow={ + + This group has no users. + + } + /> + + 0} + show={group.projects.map(project => ( + + {project} + + ))} + elseShow={ + + Not used + + } + /> + + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx new file mode 100644 index 0000000000..24fc4db856 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx @@ -0,0 +1,114 @@ +import { FC, useState } from 'react'; +import { + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + styled, + Tooltip, + Typography, +} from '@mui/material'; +import { Delete, Edit, MoreVert } from '@mui/icons-material'; +import { Link } from 'react-router-dom'; + +const StyledActions = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', +})); + +const StyledPopover = styled(Popover)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1, 1.5), +})); + +interface IGroupCardActions { + groupId: number; + onRemove: () => void; +} + +export const GroupCardActions: FC = ({ + groupId, + onRemove, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const id = `feature-${groupId}-actions`; + const menuId = `${id}-menu`; + + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + + + + + + + + Edit group + + + { + onRemove(); + handleClose(); + }} + > + + + + + + Remove group + + + + + + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx new file mode 100644 index 0000000000..23dd3ebe8b --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx @@ -0,0 +1,92 @@ +import { Avatar, Badge, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IGroupUser, Role } from 'interfaces/group'; +import { useMemo } from 'react'; +import StarIcon from '@mui/icons-material/Star'; + +const StyledAvatars = styled('div')(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + flexWrap: 'wrap', + marginLeft: theme.spacing(1), +})); + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(4), + height: theme.spacing(4), + outline: `2px solid ${theme.palette.background.paper}`, + marginLeft: theme.spacing(-1), +})); + +const StyledAvatarMore = styled(StyledAvatar)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.light, + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + fontWeight: theme.fontWeight.bold, +})); + +const StyledStar = styled(StarIcon)(({ theme }) => ({ + color: theme.palette.warning.main, + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadiusExtraLarge, + fontSize: theme.fontSizes.smallBody, + marginLeft: theme.spacing(-1), +})); + +interface IGroupCardAvatarsProps { + users: IGroupUser[]; +} + +export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => { + const shownUsers = useMemo( + () => users.sort((a, b) => (a.role < b.role ? 1 : -1)).slice(0, 9), + [users] + ); + return ( + + {shownUsers.map(user => ( + + } + elseShow={ + } + > + + + } + /> + ))} + 9} + show={ + + +{users.length - shownUsers.length} + + } + /> + + ); +}; diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx new file mode 100644 index 0000000000..8f61209922 --- /dev/null +++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useState, VFC } from 'react'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import { Link, useSearchParams } from 'react-router-dom'; +import { IGroup } from 'interfaces/group'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Search } from 'component/common/Search/Search'; +import { Button, Grid, useMediaQuery } from '@mui/material'; +import theme from 'themes/theme'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { TablePlaceholder } from 'component/common/Table'; +import { GroupCard } from './GroupCard/GroupCard'; + +type PageQueryType = Partial>; + +const groupsSearch = (group: IGroup, searchValue: string) => { + const search = searchValue.toLowerCase(); + const users = { + names: group.users?.map(user => user.name?.toLowerCase() || ''), + usernames: group.users?.map(user => user.username?.toLowerCase() || ''), + emails: group.users?.map(user => user.email?.toLowerCase() || ''), + }; + return ( + group.name.toLowerCase().includes(search) || + group.description.toLowerCase().includes(search) || + users.names?.some(name => name.includes(search)) || + users.usernames?.some(username => username.includes(search)) || + users.emails?.some(email => email.includes(search)) + ); +}; + +export const GroupsList: VFC = () => { + const { groups = [], loading } = useGroups(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '' + ); + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + useEffect(() => { + const tableState: PageQueryType = {}; + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + }, [searchValue, setSearchParams]); + + const data = useMemo(() => { + const sortedGroups = groups.sort((a, b) => + a.name.localeCompare(b.name) + ); + return searchValue + ? sortedGroups.filter(group => groupsSearch(group, searchValue)) + : sortedGroups; + }, [groups, searchValue]); + + return ( + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + {data.map(group => ( + + + + ))} + + + 0} + show={ + + No groups found matching “ + {searchValue} + ” + + } + elseShow={ + + No groups available. Get started by adding a new + group. + + } + /> + } + /> + + ); +}; diff --git a/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx new file mode 100644 index 0000000000..ddf7d5a34d --- /dev/null +++ b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx @@ -0,0 +1,60 @@ +import { Typography } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import useToast from 'hooks/useToast'; +import { IGroup } from 'interfaces/group'; +import { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +interface IRemoveGroupProps { + open: boolean; + setOpen: React.Dispatch>; + group: IGroup; +} + +export const RemoveGroup: FC = ({ + open, + setOpen, + group, +}) => { + const { refetchGroups } = useGroups(); + const { removeGroup } = useGroupApi(); + const { setToastData, setToastApiError } = useToast(); + const navigate = useNavigate(); + + const onRemoveClick = async () => { + try { + await removeGroup(group.id); + refetchGroups(); + setOpen(false); + navigate('/admin/groups'); + setToastData({ + title: 'Group removed successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + { + setOpen(false); + }} + title="Remove group" + > + + Are you sure you wish to remove {group.name}? + If this group is currently assigned to one or more projects then + users belonging to this group may lose access to those projects. + + + ); +}; diff --git a/frontend/src/component/admin/groups/hooks/useGroupForm.ts b/frontend/src/component/admin/groups/hooks/useGroupForm.ts new file mode 100644 index 0000000000..719578d3ae --- /dev/null +++ b/frontend/src/component/admin/groups/hooks/useGroupForm.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import useQueryParams from 'hooks/useQueryParams'; +import { IGroupUser, Role } from 'interfaces/group'; + +export const useGroupForm = ( + initialName = '', + initialDescription = '', + initialUsers: IGroupUser[] = [] +) => { + const params = useQueryParams(); + const groupQueryName = params.get('name'); + const [name, setName] = useState(groupQueryName || initialName); + const [description, setDescription] = useState(initialDescription); + const [users, setUsers] = useState(initialUsers); + const [errors, setErrors] = useState({}); + + useEffect(() => { + if (!name) { + setName(groupQueryName || initialName); + } + }, [name, initialName, groupQueryName]); + + useEffect(() => { + setDescription(initialDescription); + }, [initialDescription]); + + const initialUsersStringified = JSON.stringify(initialUsers); + + useEffect(() => { + setUsers(initialUsers); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialUsersStringified]); + + const getGroupPayload = () => { + return { + name, + description, + users: users.map(({ id, role }) => ({ + user: { id }, + role: role || Role.Member, + })), + }; + }; + + const clearErrors = () => { + setErrors({}); + }; + + return { + name, + setName, + description, + setDescription, + users, + setUsers, + getGroupPayload, + clearErrors, + errors, + }; +}; diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx index 9b38b7a306..abedaedb45 100644 --- a/frontend/src/component/admin/menu/AdminMenu.tsx +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -51,6 +51,19 @@ function AdminMenu() { } /> + {flags.UG && ( + + Groups + + } + /> + )} {flags.RE && ( ; + children?: ReactNode; +} + +interface IBadgeIconProps { + color?: Color; +} + +const StyledBadge = styled('div')( + ({ theme, color = 'neutral' }) => ({ + display: 'inline-flex', + alignItems: 'center', + padding: theme.spacing(0.5, 1), + borderRadius: theme.shape.borderRadius, + fontSize: theme.fontSizes.smallerBody, + backgroundColor: theme.palette[color].light, + color: theme.palette[color].dark, + border: `1px solid ${theme.palette[color].border}`, + }) +); + +const StyledBadgeIcon = styled('div')( + ({ theme, color = 'neutral' }) => ({ + display: 'flex', + color: theme.palette[color].main, + marginRight: theme.spacing(0.5), + }) +); + +export const Badge: FC = forwardRef( + ( + { + color = 'neutral', + icon, + className, + sx, + children, + ...props + }: IBadgeProps, + ref: ForwardedRef + ) => ( + + + + cloneElement(icon!, { + sx: { fontSize: '16px' }, + }) + } + /> + + } + /> + {children} + + ) +); diff --git a/frontend/src/component/common/MainHeader/MainHeader.tsx b/frontend/src/component/common/MainHeader/MainHeader.tsx new file mode 100644 index 0000000000..2711d6df12 --- /dev/null +++ b/frontend/src/component/common/MainHeader/MainHeader.tsx @@ -0,0 +1,55 @@ +import { Paper, styled } from '@mui/material'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { ReactNode } from 'react'; + +const StyledMainHeader = styled(Paper)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(2.5, 4), + boxShadow: 'none', + marginBottom: theme.spacing(2), + fontSize: theme.fontSizes.smallBody, +})); + +const StyledTitleHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledTitle = styled('h1')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, +})); + +const StyledActions = styled('div')(({ theme }) => ({ + display: 'flex', +})); + +const StyledDescription = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + marginLeft: theme.spacing(1), +})); + +interface IMainHeaderProps { + title?: string; + description?: string; + actions?: ReactNode; +} + +export const MainHeader = ({ + title, + description, + actions, +}: IMainHeaderProps) => { + usePageTitle(title); + + return ( + + + {title} + {actions} + + Description:{description} + + ); +}; diff --git a/frontend/src/component/common/PageContent/PageContent.tsx b/frontend/src/component/common/PageContent/PageContent.tsx index 3238d01d2e..e96973eea2 100644 --- a/frontend/src/component/common/PageContent/PageContent.tsx +++ b/frontend/src/component/common/PageContent/PageContent.tsx @@ -47,12 +47,13 @@ export const PageContent: FC = ({ }) => { const { classes: styles } = useStyles(); - const headerClasses = classnames(styles.headerContainer, { + const headerClasses = classnames('header', styles.headerContainer, { [styles.paddingDisabled]: disablePadding, [styles.borderDisabled]: disableBorder, }); const bodyClasses = classnames( + 'body', bodyClass ? bodyClass : styles.bodyContainer, { [styles.paddingDisabled]: disablePadding, diff --git a/frontend/src/component/common/PageHeader/PageHeader.tsx b/frontend/src/component/common/PageHeader/PageHeader.tsx index 2ad28c281f..d60a5544c6 100644 --- a/frontend/src/component/common/PageHeader/PageHeader.tsx +++ b/frontend/src/component/common/PageHeader/PageHeader.tsx @@ -33,6 +33,7 @@ interface IPageHeaderProps { loading?: boolean; actions?: ReactNode; className?: string; + secondary?: boolean; } const PageHeaderComponent: FC & { @@ -45,12 +46,13 @@ const PageHeaderComponent: FC & { variant, loading, className = '', + secondary, children, }) => { const { classes: styles } = useStyles(); const headerClasses = classnames({ skeleton: loading }); - usePageTitle(title); + usePageTitle(secondary ? '' : title); return (
    @@ -60,7 +62,7 @@ const PageHeaderComponent: FC & { data-loading > {titleElement || title} diff --git a/frontend/src/component/common/StatusBadge/StatusBadge.tsx b/frontend/src/component/common/StatusBadge/StatusBadge.tsx deleted file mode 100644 index ad02c63dbc..0000000000 --- a/frontend/src/component/common/StatusBadge/StatusBadge.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { styled, useTheme } from '@mui/material'; -import { ReactNode } from 'react'; - -const StyledStatusBadge = styled('div')(({ theme }) => ({ - padding: theme.spacing(0.5, 1), - textDecoration: 'none', - color: theme.palette.text.primary, - display: 'inline-block', - borderRadius: theme.shape.borderRadius, - marginLeft: theme.spacing(1.5), - fontSize: theme.fontSizes.smallerBody, - lineHeight: 1, -})); - -interface IStatusBadgeProps { - severity: 'success' | 'warning'; - className?: string; - children: ReactNode; -} - -export const StatusBadge = ({ - severity, - className, - children, -}: IStatusBadgeProps) => { - const theme = useTheme(); - const background = theme.palette.statusBadge[severity]; - - return ( - - {children} - - ); -}; diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx index b625cd0ccf..bef033443d 100644 --- a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx +++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx @@ -109,6 +109,7 @@ export const CellSortable: FC = ({ show={
    diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 7ae8ed8b1c..aa97b286ac 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -398,6 +398,44 @@ exports[`returns all baseRoutes 1`] = ` "title": "Users", "type": "protected", }, + { + "component": [Function], + "flag": "UG", + "menu": { + "adminSettings": true, + }, + "parent": "/admin", + "path": "/admin/groups", + "title": "Groups", + "type": "protected", + }, + { + "component": [Function], + "flag": "UG", + "menu": {}, + "parent": "/admin", + "path": "/admin/groups/:groupId", + "title": ":groupId", + "type": "protected", + }, + { + "component": [Function], + "flag": "UG", + "menu": {}, + "parent": "/admin/groups", + "path": "/admin/groups/create-group", + "title": "Create group", + "type": "protected", + }, + { + "component": [Function], + "flag": "UG", + "menu": {}, + "parent": "/admin/groups", + "path": "/admin/groups/:groupId/edit", + "title": "Edit group", + "type": "protected", + }, { "component": [Function], "flag": "RE", diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 790f20b560..c9b9debd2b 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -6,9 +6,10 @@ import { AddonList } from 'component/addons/AddonList/AddonList'; import Admin from 'component/admin'; import AdminApi from 'component/admin/api'; import AdminUsers from 'component/admin/users/UsersAdmin'; +import { GroupsAdmin } from 'component/admin/groups/GroupsAdmin'; import { AuthSettings } from 'component/admin/auth/AuthSettings'; import Login from 'component/user/Login/Login'; -import { C, EEA, P, RE, SE } from 'component/common/flags'; +import { C, EEA, P, RE, SE, UG } from 'component/common/flags'; import { NewUser } from 'component/user/NewUser/NewUser'; import ResetPassword from 'component/user/ResetPassword/ResetPassword'; import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword'; @@ -53,6 +54,9 @@ import FlaggedBillingRedirect from 'component/admin/billing/FlaggedBillingRedire import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable'; import { Billing } from 'component/admin/billing/Billing'; import { Playground } from 'component/playground/Playground/Playground'; +import { Group } from 'component/admin/groups/Group/Group'; +import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup'; +import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup'; export const routes: IRoute[] = [ // Splash @@ -450,6 +454,42 @@ export const routes: IRoute[] = [ type: 'protected', menu: {}, }, + { + path: '/admin/groups', + parent: '/admin', + title: 'Groups', + component: GroupsAdmin, + type: 'protected', + menu: { adminSettings: true }, + flag: UG, + }, + { + path: '/admin/groups/:groupId', + parent: '/admin', + title: ':groupId', + component: Group, + type: 'protected', + menu: {}, + flag: UG, + }, + { + path: '/admin/groups/create-group', + parent: '/admin/groups', + title: 'Create group', + component: CreateGroup, + type: 'protected', + menu: {}, + flag: UG, + }, + { + path: '/admin/groups/:groupId/edit', + parent: '/admin/groups', + title: 'Edit group', + component: EditGroup, + type: 'protected', + menu: {}, + flag: UG, + }, { path: '/admin/roles', parent: '/admin', diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx index 7907f6d7e1..1736778e9f 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx @@ -1,5 +1,4 @@ -import React, { useContext, VFC } from 'react'; -import { ProjectAccessPage } from 'component/project/ProjectAccess/ProjectAccessPage'; +import { useContext, VFC } from 'react'; import { PageContent } from 'component/common/PageContent/PageContent'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { Alert } from '@mui/material'; @@ -8,6 +7,7 @@ import AccessContext from 'contexts/AccessContext'; import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; +import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable'; interface IProjectAccess { projectName: string; @@ -15,6 +15,7 @@ interface IProjectAccess { export const ProjectAccess: VFC = ({ projectName }) => { const projectId = useRequiredPathParam('projectId'); + const { hasAccess } = useContext(AccessContext); const { isOss } = useUiConfig(); usePageTitle(`Project access – ${projectName}`); @@ -48,5 +49,5 @@ export const ProjectAccess: VFC = ({ projectName }) => { ); } - return ; + return ; }; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx deleted file mode 100644 index 1def83082c..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { ChangeEvent, useEffect, useState } from 'react'; -import { - TextField, - CircularProgress, - Grid, - Button, - InputAdornment, - SelectChangeEvent, -} from '@mui/material'; -import { Search } from '@mui/icons-material'; -import Autocomplete from '@mui/material/Autocomplete'; -import { ProjectRoleSelect } from '../ProjectRoleSelect/ProjectRoleSelect'; -import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; -import useToast from 'hooks/useToast'; -import useProjectAccess, { - IProjectAccessUser, -} from 'hooks/api/getters/useProjectAccess/useProjectAccess'; -import { IProjectRole } from 'interfaces/role'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; - -interface IProjectAccessAddUserProps { - roles: IProjectRole[]; -} - -export const ProjectAccessAddUser = ({ roles }: IProjectAccessAddUserProps) => { - const projectId = useRequiredPathParam('projectId'); - const [user, setUser] = useState(); - const [role, setRole] = useState(); - const [options, setOptions] = useState([]); - const [loading, setLoading] = useState(false); - const { setToastData } = useToast(); - const { refetchProjectAccess, access } = useProjectAccess(projectId); - - const { searchProjectUser, addUserToRole } = useProjectApi(); - - useEffect(() => { - if (roles.length > 0) { - const regularRole = roles.find( - r => r.name.toLowerCase() === 'regular' - ); - setRole(regularRole || roles[0]); - } - }, [roles]); - - const search = async (query: string) => { - if (query.length > 1) { - setLoading(true); - - const result = await searchProjectUser(query); - const userSearchResults = await result.json(); - - const filteredUsers = userSearchResults.filter( - (selectedUser: IProjectAccessUser) => { - const selected = access.users.find( - (user: IProjectAccessUser) => - user.id === selectedUser.id - ); - return !selected; - } - ); - setOptions(filteredUsers); - } else { - setOptions([]); - } - setLoading(false); - }; - - const handleQueryUpdate = (evt: { target: { value: string } }) => { - const q = evt.target.value; - search(q); - }; - - const handleBlur = () => { - if (options.length > 0) { - const user = options[0]; - setUser(user); - } - }; - - const handleSelectUser = ( - evt: ChangeEvent<{}>, - selectedUser: string | IProjectAccessUser | null - ) => { - setOptions([]); - - if (typeof selectedUser === 'string' || selectedUser === null) { - return; - } - - if (selectedUser?.id) { - setUser(selectedUser); - } - }; - - const handleRoleChange = (evt: SelectChangeEvent) => { - const roleId = Number(evt.target.value); - const role = roles.find(role => role.id === roleId); - if (role) { - setRole(role); - } - }; - - const handleSubmit = async (evt: React.SyntheticEvent) => { - evt.preventDefault(); - if (!role || !user) { - setToastData({ - type: 'error', - title: 'Invalid selection', - text: `The selected user or role does not exist`, - }); - return; - } - - try { - await addUserToRole(projectId, role.id, user.id); - refetchProjectAccess(); - setUser(undefined); - setOptions([]); - setToastData({ - type: 'success', - title: 'Added user to project', - text: `User added to the project with the role of ${role.name}`, - }); - } catch (e: any) { - let error; - - if ( - e - .toString() - .includes(`User already has access to project=${projectId}`) - ) { - error = `User already has access to project ${projectId}`; - } else { - error = e.toString() || 'Server problems when adding users.'; - } - setToastData({ - type: 'error', - title: error, - }); - } - }; - - const getOptionLabel = (option: IProjectAccessUser | string) => { - if (option && typeof option !== 'string') { - return `${option.name || option.username || '(Empty name)'} <${ - option.email || option.username - }>`; - } else return ''; - }; - - return ( - <> - - - handleBlur()} - value={user || ''} - freeSolo - isOptionEqualToValue={() => true} - filterOptions={o => o} - getOptionLabel={getOptionLabel} - options={options} - loading={loading} - renderInput={params => ( - - - - ), - endAdornment: ( - <> - - } - /> - - {params.InputProps.endAdornment} - - ), - }} - /> - )} - /> - - - - - - - - - - ); -}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx new file mode 100644 index 0000000000..73ff6c6fb3 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx @@ -0,0 +1,390 @@ +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { + Autocomplete, + Button, + capitalize, + Checkbox, + styled, + TextField, +} from '@mui/material'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; +import useToast from 'hooks/useToast'; +import useProjectAccess, { + ENTITY_TYPE, + IProjectAccess, +} from 'hooks/api/getters/useProjectAccess/useProjectAccess'; +import { IProjectRole } from 'interfaces/role'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { IUser } from 'interfaces/user'; +import { IGroup } from 'interfaces/group'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ProjectRoleDescription } from './ProjectRoleDescription/ProjectRoleDescription'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({ + '& > div:first-of-type': { + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), + }, +})); + +const StyledButtonContainer = styled('div')(() => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +const StyledGroupOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:last-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledUserOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const StyledRoleOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:last-of-type': { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, + }, +})); + +interface IAccessOption { + id: number; + entity: IUser | IGroup; + type: ENTITY_TYPE; +} + +interface IProjectAccessAssignProps { + open: boolean; + setOpen: React.Dispatch>; + selected?: IProjectAccess; + accesses: IProjectAccess[]; + roles: IProjectRole[]; + entityType: string; +} + +export const ProjectAccessAssign = ({ + open, + setOpen, + selected, + accesses, + roles, + entityType, +}: IProjectAccessAssignProps) => { + const projectId = useRequiredPathParam('projectId'); + const { refetchProjectAccess } = useProjectAccess(projectId); + const { addAccessToProject, changeUserRole, changeGroupRole, loading } = + useProjectApi(); + const { users = [] } = useUsers(); + const { groups = [] } = useGroups(); + const edit = Boolean(selected); + + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const [selectedOptions, setSelectedOptions] = useState([]); + const [role, setRole] = useState( + roles.find(({ id }) => id === selected?.entity.roleId) ?? null + ); + + useEffect(() => { + setRole(roles.find(({ id }) => id === selected?.entity.roleId) ?? null); + }, [roles, selected]); + + const payload = useMemo( + () => ({ + users: selectedOptions + ?.filter(({ type }) => type === ENTITY_TYPE.USER) + .map(({ id }) => ({ id })), + groups: selectedOptions + ?.filter(({ type }) => type === ENTITY_TYPE.GROUP) + .map(({ id }) => ({ id })), + }), + [selectedOptions] + ); + + const options = useMemo( + () => [ + ...groups + .filter( + (group: IGroup) => + edit || + !accesses.some( + ({ entity: { id }, type }) => + group.id === id && type === ENTITY_TYPE.GROUP + ) + ) + .map(group => ({ + id: group.id, + entity: group, + type: ENTITY_TYPE.GROUP, + })), + ...users + .filter( + (user: IUser) => + edit || + !accesses.some( + ({ entity: { id }, type }) => + user.id === id && type === ENTITY_TYPE.USER + ) + ) + .map((user: IUser) => ({ + id: user.id, + entity: user, + type: ENTITY_TYPE.USER, + })), + ], + [users, accesses, edit, groups] + ); + + useEffect(() => { + const selectedOption = + options.filter( + ({ id, type }) => + id === selected?.entity.id && type === selected?.type + ) || []; + setSelectedOptions(selectedOption); + setRole(roles.find(({ id }) => id === selected?.entity.roleId) || null); + }, [open, selected, options, roles]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!role) return; + + try { + if (!edit) { + await addAccessToProject(projectId, role.id, payload); + } else if (selected?.type === ENTITY_TYPE.USER) { + await changeUserRole(projectId, role.id, selected.entity.id); + } else if (selected?.type === ENTITY_TYPE.GROUP) { + await changeGroupRole(projectId, role.id, selected.entity.id); + } + refetchProjectAccess(); + setOpen(false); + setToastData({ + title: `${selectedOptions.length} ${ + selectedOptions.length === 1 ? 'access' : 'accesses' + } ${!edit ? 'assigned' : 'edited'} successfully`, + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + if (edit) { + return `curl --location --request ${edit ? 'PUT' : 'POST'} '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/${ + selected?.type === ENTITY_TYPE.USER ? 'users' : 'groups' + }/${selected?.entity.id}/roles/${role?.id}' \\ + --header 'Authorization: INSERT_API_KEY'`; + } + return `curl --location --request ${edit ? 'PUT' : 'POST'} '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/role/${role?.id}/access' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(payload, undefined, 2)}'`; + }; + + const renderOption = ( + props: React.HTMLAttributes, + option: IAccessOption, + selected: boolean + ) => { + let optionGroup; + let optionUser; + if (option.type === ENTITY_TYPE.GROUP) { + optionGroup = option.entity as IGroup; + } else { + optionUser = option.entity as IUser; + } + return ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {optionGroup?.name} + {optionGroup?.users.length} users + + } + elseShow={ + + + {optionUser?.name || optionUser?.username} + + {optionUser?.email} + + } + /> +
  • + ); + }; + + const renderRoleOption = ( + props: React.HTMLAttributes, + option: IProjectRole + ) => ( +
  • + + {option.name} + {option.description} + +
  • + ); + + return ( + { + setOpen(false); + }} + label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`} + > + + +
    + + Select the {entityType} + + + { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === + 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setSelectedOptions(newValue); + }} + options={options} + groupBy={option => option.type} + renderOption={(props, option, { selected }) => + renderOption(props, option, selected) + } + getOptionLabel={(option: IAccessOption) => { + if (option.type === ENTITY_TYPE.USER) { + const optionUser = + option.entity as IUser; + return ( + optionUser.email || + optionUser.name || + optionUser.username || + '' + ); + } else { + return option.entity.name; + } + }} + renderInput={params => ( + + )} + /> + + + Select the role to assign for this project + + + setRole(newValue)} + options={roles} + renderOption={renderRoleOption} + getOptionLabel={option => option.name} + renderInput={params => ( + + )} + /> + + } + /> +
    + + + + { + setOpen(false); + }} + > + Cancel + + +
    +
    +
    + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx new file mode 100644 index 0000000000..d4f66881d8 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx @@ -0,0 +1,103 @@ +import { styled } from '@mui/material'; +import { useMemo, VFC } from 'react'; +import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; + +const StyledDescription = styled('div')(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + padding: theme.spacing(3), + backgroundColor: theme.palette.neutral.light, + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallerBody, + borderRadius: theme.shape.borderRadiusMedium, +})); + +const StyledDescriptionBlock = styled('div')(({ theme }) => ({ + '& p:last-child': { + marginBottom: theme.spacing(2), + }, +})); + +const StyledDescriptionHeader = styled('p')(({ theme }) => ({ + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallBody, + fontWeight: theme.fontWeight.bold, + marginBottom: theme.spacing(2), +})); + +const StyledDescriptionSubHeader = styled('p')(({ theme }) => ({ + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallBody, + marginBottom: theme.spacing(1), +})); + +interface IProjectRoleDescriptionProps { + roleId: number; +} + +export const ProjectRoleDescription: VFC = ({ + roleId, +}) => { + const { role } = useProjectRole(roleId.toString()); + + const environments = useMemo(() => { + const environments = new Set(); + role.permissions + ?.filter((permission: any) => permission.environment !== '') + .forEach((permission: any) => { + environments.add(permission.environment); + }); + return [...environments].sort(); + }, [role]); + + return ( + + + Project permissions + + + {role.permissions + ?.filter((permission: any) => permission.environment === '') + .map((permission: any) => permission.displayName) + .sort() + .map((permission: any) => ( +

    {permission}

    + ))} +
    + + + Environment permissions + + {environments.map((environment: any) => ( +
    + + {environment} + + + {role.permissions + .filter( + (permission: any) => + permission.environment === + environment + ) + .map( + (permission: any) => + permission.displayName + ) + .sort() + .map((permission: any) => ( +

    {permission}

    + ))} +
    +
    + ))} + + } + /> +
    + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessPage.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessPage.tsx deleted file mode 100644 index 5b498c7b7e..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessPage.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { SelectChangeEvent } from '@mui/material'; -import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser'; -import { PageContent } from 'component/common/PageContent/PageContent'; -import { useStyles } from './ProjectAccess.styles'; -import useToast from 'hooks/useToast'; -import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue'; -import useProjectAccess, { - IProjectAccessUser, -} from 'hooks/api/getters/useProjectAccess/useProjectAccess'; -import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; -import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { ProjectAccessTable } from './ProjectAccessTable/ProjectAccessTable'; - -export const ProjectAccessPage = () => { - const projectId = useRequiredPathParam('projectId'); - const { classes: styles } = useStyles(); - const { access, refetchProjectAccess } = useProjectAccess(projectId); - const { setToastData } = useToast(); - const { removeUserFromRole, changeUserRole } = useProjectApi(); - const [showDelDialogue, setShowDelDialogue] = useState(false); - const [user, setUser] = useState(); - - const handleRoleChange = useCallback( - (userId: number) => async (evt: SelectChangeEvent) => { - const roleId = Number(evt.target.value); - try { - await changeUserRole(projectId, roleId, userId); - refetchProjectAccess(); - setToastData({ - type: 'success', - title: 'Success', - text: 'User role changed successfully', - }); - } catch (err: any) { - setToastData({ - type: 'error', - title: err.message || 'Server problems when adding users.', - }); - } - }, - [changeUserRole, projectId, refetchProjectAccess, setToastData] - ); - - const handleRemoveAccess = (user: IProjectAccessUser) => { - setUser(user); - setShowDelDialogue(true); - }; - - const removeAccess = (user: IProjectAccessUser | undefined) => async () => { - if (!user) return; - const { id, roleId } = user; - - try { - await removeUserFromRole(projectId, roleId, id); - refetchProjectAccess(); - setToastData({ - type: 'success', - title: `${ - user.email || user.username || 'The user' - } has been removed from project`, - }); - } catch (err: any) { - setToastData({ - type: 'error', - title: err.message || 'Server problems when adding users.', - }); - } - setShowDelDialogue(false); - }; - - return ( - } - className={styles.pageContent} - > - -
    - - { - setUser(undefined); - setShowDelDialogue(false); - }} - title="Really remove user from this project" - /> - - ); -}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx index d55f0784a3..edb500bb84 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx @@ -1,109 +1,240 @@ -import { useMemo, VFC } from 'react'; -import { useSortBy, useTable } from 'react-table'; -import { - Table, - TableBody, - TableRow, - TableCell, - SortableTableHeader, -} from 'component/common/Table'; -import { Avatar, SelectChangeEvent } from '@mui/material'; -import { Delete } from '@mui/icons-material'; +import { useEffect, useMemo, useState, VFC } from 'react'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { VirtualizedTable, TablePlaceholder } from 'component/common/Table'; +import { Avatar, Button, styled, useMediaQuery, useTheme } from '@mui/material'; +import { Delete, Edit } from '@mui/icons-material'; import { sortTypes } from 'utils/sortTypes'; -import { - IProjectAccessOutput, - IProjectAccessUser, +import useProjectAccess, { + ENTITY_TYPE, + IProjectAccess, } from 'hooks/api/getters/useProjectAccess/useProjectAccess'; -import { ProjectRoleCell } from './ProjectRoleCell/ProjectRoleCell'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useSearch } from 'hooks/useSearch'; +import { useSearchParams } from 'react-router-dom'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Search } from 'component/common/Search/Search'; +import { ProjectAccessAssign } from 'component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign'; +import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; +import useToast from 'hooks/useToast'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ProjectGroupView } from '../ProjectGroupView/ProjectGroupView'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { IUser } from 'interfaces/user'; +import { IGroup } from 'interfaces/group'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; -const initialState = { - sortBy: [{ id: 'name' }], -}; +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(4), + height: theme.spacing(4), + margin: 'auto', + backgroundColor: theme.palette.secondary.light, + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallBody, + fontWeight: theme.fontWeight.bold, +})); -interface IProjectAccessTableProps { - access: IProjectAccessOutput; - projectId: string; - handleRoleChange: ( - userId: number - ) => (event: SelectChangeEvent) => Promise; - handleRemoveAccess: (user: IProjectAccessUser) => void; -} +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; -export const ProjectAccessTable: VFC = ({ - access, - projectId, - handleRoleChange, - handleRemoveAccess, -}) => { - const data = access.users; +const defaultSort: SortingRule = { id: 'added' }; + +const { value: storedParams, setValue: setStoredParams } = createLocalStorage( + 'ProjectAccess:v1', + defaultSort +); + +export const ProjectAccessTable: VFC = () => { + const projectId = useRequiredPathParam('projectId'); + + const { uiConfig } = useUiConfig(); + const { flags } = uiConfig; + const entityType = flags.UG ? 'user / group' : 'user'; + + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const { setToastData } = useToast(); + + const { access, refetchProjectAccess } = useProjectAccess(projectId); + const { removeUserFromRole, removeGroupFromRole } = useProjectApi(); + const [assignOpen, setAssignOpen] = useState(false); + const [removeOpen, setRemoveOpen] = useState(false); + const [groupOpen, setGroupOpen] = useState(false); + const [selectedRow, setSelectedRow] = useState(); + + useEffect(() => { + if (!assignOpen && !groupOpen) { + setSelectedRow(undefined); + } + }, [assignOpen, groupOpen]); + + const roles = useMemo( + () => access.roles || [], + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(access.roles)] + ); + + const mappedData: IProjectAccess[] = useMemo(() => { + const users = access.users || []; + const groups = access.groups || []; + return [ + ...users.map(user => ({ + entity: user, + type: ENTITY_TYPE.USER, + })), + ...groups.map(group => ({ + entity: group, + type: ENTITY_TYPE.GROUP, + })), + ]; + }, [access]); const columns = useMemo( () => [ { Header: 'Avatar', accessor: 'imageUrl', - disableSortBy: true, - width: 80, - Cell: ({ value }: { value: string }) => ( - + Cell: ({ row: { original: row } }: any) => ( + + + {row.entity.users?.length} + + ), - align: 'center', + maxWidth: 85, + disableSortBy: true, }, { id: 'name', Header: 'Name', - accessor: (row: any) => row.name || '', + accessor: (row: IProjectAccess) => row.entity.name || '', + Cell: ({ value, row: { original: row } }: any) => ( + { + setSelectedRow(row); + setGroupOpen(true); + }} + title={value} + subtitle={`${row.entity.users?.length} users`} + /> + } + elseShow={} + /> + ), + minWidth: 100, + searchable: true, }, { id: 'username', Header: 'Username', - accessor: 'email', - Cell: ({ row: { original: user } }: any) => ( - {user.email || user.username} - ), + accessor: (row: IProjectAccess) => { + if (row.type === ENTITY_TYPE.USER) { + const userRow = row.entity as IUser; + return userRow.username || userRow.email; + } + return ''; + }, + Cell: HighlightCell, + minWidth: 100, + searchable: true, }, { Header: 'Role', - accessor: 'roleId', - Cell: ({ - value, - row: { original: user }, - }: { - value: number; - row: { original: IProjectAccessUser }; - }) => ( - + accessor: (row: IProjectAccess) => + roles.find(({ id }) => id === row.entity.roleId)?.name, + minWidth: 120, + filterName: 'role', + }, + { + id: 'added', + Header: 'Added', + accessor: (row: IProjectAccess) => { + const userRow = row.entity as IUser | IGroup; + return userRow.addedAt || ''; + }, + Cell: ({ value }: { value: Date }) => ( + ), + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Last login', + accessor: (row: IProjectAccess) => { + if (row.type === ENTITY_TYPE.USER) { + const userRow = row.entity as IUser; + return userRow.seenAt || ''; + } + const userGroup = row.entity as IGroup; + return userGroup.users + .map(({ seenAt }) => seenAt) + .sort() + .reverse()[0]; + }, + Cell: ({ value }: { value: Date }) => ( + + ), + sortType: 'date', + maxWidth: 150, }, { id: 'actions', Header: 'Actions', disableSortBy: true, align: 'center', - width: 80, - Cell: ({ row: { original: user } }: any) => ( + maxWidth: 200, + Cell: ({ row: { original: row } }: any) => ( handleRemoveAccess(user)} - disabled={access.users.length === 1} + onClick={() => { + setSelectedRow(row); + setAssignOpen(true); + }} + disabled={mappedData.length === 1} tooltipProps={{ title: - access.users.length === 1 + mappedData.length === 1 + ? 'Cannot edit access. A project must have at least one owner' + : 'Edit access', + }} + > + + + { + setSelectedRow(row); + setRemoveOpen(true); + }} + disabled={mappedData.length === 1} + tooltipProps={{ + title: + mappedData.length === 1 ? 'Cannot remove access. A project must have at least one owner' : 'Remove access', }} @@ -114,50 +245,214 @@ export const ProjectAccessTable: VFC = ({ ), }, ], - [ - access.roles, - access.users.length, - handleRemoveAccess, - handleRoleChange, - projectId, - ] + [roles, mappedData.length, projectId] ); - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = - useTable( + const [searchParams, setSearchParams] = useSearchParams(); + const [initialState] = useState(() => ({ + sortBy: [ { - columns: columns as any[], // TODO: fix after `react-table` v8 update - data, - initialState, - sortTypes, - autoResetGlobalFilter: false, - autoResetSortBy: false, - disableSortRemove: true, - defaultColumn: { - Cell: TextCell, - }, + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, }, - useSortBy - ); + ], + globalFilter: searchParams.get('search') || '', + })); + const [searchValue, setSearchValue] = useState(initialState.globalFilter); + + const { data, getSearchText, getSearchContext } = useSearch( + columns, + searchValue, + mappedData ?? [] + ); + + const { + headerGroups, + rows, + prepareRow, + state: { sortBy }, + } = useTable( + { + columns: columns as any[], + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout + ); + + useEffect(() => { + const tableState: PageQueryType = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [sortBy, searchValue, setSearchParams]); + + const removeAccess = async (userOrGroup?: IProjectAccess) => { + if (!userOrGroup) return; + const { id, roleId } = userOrGroup.entity; + let name = userOrGroup.entity.name; + if (userOrGroup.type === ENTITY_TYPE.USER) { + const user = userOrGroup.entity as IUser; + name = name || user.email || user.username || ''; + } + + try { + if (userOrGroup.type === ENTITY_TYPE.USER) { + await removeUserFromRole(projectId, roleId, id); + } else { + await removeGroupFromRole(projectId, roleId, id); + } + refetchProjectAccess(); + setToastData({ + type: 'success', + title: `${ + name || `The ${entityType}` + } has been removed from project`, + }); + } catch (err: any) { + setToastData({ + type: 'error', + title: + err.message || + `Server problems when removing ${entityType}.`, + }); + } + setRemoveOpen(false); + setSelectedRow(undefined); + }; return ( - - {/* @ts-expect-error -- react-table */} - - - {rows.map(row => { - prepareRow(row); - return ( - - {row.cells.map(cell => ( - - {cell.render('Cell')} - - ))} - - ); - })} - -
    + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No access found matching “ + {searchValue} + ” + + } + elseShow={ + + No access available. Get started by assigning a{' '} + {entityType}. + + } + /> + } + /> + + removeAccess(selectedRow)} + onClose={() => { + setSelectedRow(undefined); + setRemoveOpen(false); + }} + title={`Really remove ${entityType} from this project?`} + /> + id === selectedRow?.entity.roleId) + ?.name + }`} + onEdit={() => { + setAssignOpen(true); + console.log('Assign Open true'); + }} + onRemove={() => { + setGroupOpen(false); + setRemoveOpen(true); + }} + /> + ); }; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.styles.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.styles.tsx deleted file mode 100644 index 9b5cb9c454..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.styles.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(theme => ({ - cell: { - padding: theme.spacing(0, 1.5), - display: 'flex', - alignItems: 'center', - }, -})); diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.tsx deleted file mode 100644 index 939f3592ed..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectRoleCell/ProjectRoleCell.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { VFC } from 'react'; -import { Box, MenuItem, SelectChangeEvent } from '@mui/material'; -import { IProjectAccessUser } from 'hooks/api/getters/useProjectAccess/useProjectAccess'; -import { IProjectRole } from 'interfaces/role'; -import { ProjectRoleSelect } from '../../ProjectRoleSelect/ProjectRoleSelect'; -import { useStyles } from './ProjectRoleCell.styles'; - -interface IProjectRoleCellProps { - value: number; - user: IProjectAccessUser; - roles: IProjectRole[]; - onChange: (event: SelectChangeEvent) => Promise; -} - -export const ProjectRoleCell: VFC = ({ - value, - user, - roles, - onChange, -}) => { - const { classes } = useStyles(); - return ( - - - - Choose role - - - - ); -}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx b/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx new file mode 100644 index 0000000000..7fab7cfa8b --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx @@ -0,0 +1,270 @@ +import { Delete, Edit } from '@mui/icons-material'; +import { Avatar, styled, useMediaQuery, useTheme } from '@mui/material'; +import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { Search } from 'component/common/Search/Search'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; +import { useSearch } from 'hooks/useSearch'; +import { IGroup, IGroupUser } from 'interfaces/group'; +import { VFC, useState } from 'react'; +import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(4), + height: theme.spacing(4), + margin: 'auto', +})); + +const StyledPageContent = styled(PageContent)(({ theme }) => ({ + height: '100vh', + padding: theme.spacing(7.5, 6), + '& .header': { + padding: theme.spacing(0, 0, 2, 0), + }, + '& .body': { + padding: theme.spacing(3, 0, 0, 0), + }, +})); + +const StyledTitle = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span': { + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const defaultSort: SortingRule = { id: 'role', desc: true }; + +const columns = [ + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + maxWidth: 85, + disableSortBy: true, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: IGroupUser) => row.name || '', + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + id: 'username', + Header: 'Username', + accessor: (row: IGroupUser) => row.username || row.email, + Cell: HighlightCell, + minWidth: 100, + searchable: true, + }, + { + Header: 'User type', + accessor: 'role', + Cell: GroupUserRoleCell, + maxWidth: 150, + filterName: 'type', + }, + { + Header: 'Joined', + accessor: 'joinedAt', + Cell: DateCell, + sortType: 'date', + maxWidth: 150, + }, + { + Header: 'Last login', + accessor: (row: IGroupUser) => row.seenAt || '', + Cell: ({ row: { original: user } }: any) => ( + + ), + sortType: 'date', + maxWidth: 150, + }, +]; + +interface IProjectGroupViewProps { + open: boolean; + setOpen: React.Dispatch>; + group: IGroup; + projectId: string; + subtitle: string; + onEdit: () => void; + onRemove: () => void; +} + +export const ProjectGroupView: VFC = ({ + open, + setOpen, + group, + projectId, + subtitle, + onEdit, + onRemove, +}) => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const [initialState] = useState(() => ({ + sortBy: [ + { + id: defaultSort.id, + desc: defaultSort.desc, + }, + ], + })); + const [searchValue, setSearchValue] = useState(''); + + const { data, getSearchText, getSearchContext } = useSearch( + columns, + searchValue, + group?.users ?? [] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns: columns as any[], + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + { + setOpen(false); + }} + label={group?.name || 'Group'} + > + + {group?.name} ( + {rows.length < data.length + ? `${rows.length} of ${data.length}` + : data.length} + ){subtitle} + + } + actions={ + <> + + + + + } + /> + + + + + + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No users found matching “ + {searchValue} + ” in this group. + + } + elseShow={ + + This group is empty. Get started by adding a + user to the group. + + } + /> + } + /> + + + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx b/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx deleted file mode 100644 index e88f652813..0000000000 --- a/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - FormControl, - InputLabel, - Select, - MenuItem, - SelectChangeEvent, -} from '@mui/material'; -import React from 'react'; -import { IProjectRole } from 'interfaces/role'; - -import { useStyles } from '../ProjectAccess.styles'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; - -interface IProjectRoleSelect { - roles: IProjectRole[]; - labelId?: string; - id: string; - placeholder?: string; - onChange: (evt: SelectChangeEvent) => void; - value: any; -} - -export const ProjectRoleSelect: React.FC = ({ - roles, - onChange, - labelId, - id, - value, - placeholder, - children, -}) => { - const { classes: styles } = useStyles(); - return ( - - ( - - Role - - )} - /> - - - - ); -}; diff --git a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx index a7988a1c23..659fc2b071 100644 --- a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx +++ b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Box } from '@mui/material'; +import { Box, styled } from '@mui/material'; import { Extension } from '@mui/icons-material'; import { Table, @@ -26,11 +26,11 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC import { sortTypes } from 'utils/sortTypes'; import { useTable, useGlobalFilter, useSortBy } from 'react-table'; import { AddStrategyButton } from './AddStrategyButton/AddStrategyButton'; -import { StatusBadge } from 'component/common/StatusBadge/StatusBadge'; import { StrategySwitch } from './StrategySwitch/StrategySwitch'; import { StrategyEditButton } from './StrategyEditButton/StrategyEditButton'; import { StrategyDeleteButton } from './StrategyDeleteButton/StrategyDeleteButton'; import { Search } from 'component/common/Search/Search'; +import { Badge } from 'component/common/Badge/Badge'; interface IDialogueMetaData { show: boolean; @@ -38,6 +38,11 @@ interface IDialogueMetaData { onConfirm: () => void; } +const StyledBadge = styled(Badge)(({ theme }) => ({ + marginLeft: theme.spacing(1), + display: 'inline-block', +})); + export const StrategiesList = () => { const navigate = useNavigate(); const [dialogueMetaData, setDialogueMetaData] = useState( @@ -191,9 +196,9 @@ export const StrategiesList = () => { ( - + Predefined - + )} /> diff --git a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap index b78697d268..d07f9d5a4b 100644 --- a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap +++ b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap @@ -10,7 +10,7 @@ exports[`renders an empty list correctly 1`] = ` className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root" >
    { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + + const createGroup = async (payload: ICreateGroupPayload) => { + const path = `api/admin/groups`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify(payload), + }); + try { + const response = await makeRequest(req.caller, req.id); + return await response.json(); + } catch (e) { + throw e; + } + }; + + const updateGroup = async ( + groupId: number, + payload: ICreateGroupPayload + ) => { + const path = `api/admin/groups/${groupId}`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify(payload), + }); + try { + await makeRequest(req.caller, req.id); + } catch (e) { + throw e; + } + }; + + const removeGroup = async (groupId: number) => { + const path = `api/admin/groups/${groupId}`; + const req = createRequest(path, { + method: 'DELETE', + }); + try { + await makeRequest(req.caller, req.id); + } catch (e) { + throw e; + } + }; + + return { + createGroup, + updateGroup, + removeGroup, + errors, + loading, + }; +}; diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 87e6485a76..ca13284ea9 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -6,6 +6,11 @@ interface ICreatePayload { description: string; } +interface IAccessesPayload { + users: { id: number }[]; + groups: { id: number }[]; +} + const useProjectApi = () => { const { makeRequest, createRequest, errors, loading } = useAPI({ propagateErrors: true, @@ -124,6 +129,26 @@ const useProjectApi = () => { } }; + const addAccessToProject = async ( + projectId: string, + roleId: number, + accesses: IAccessesPayload + ) => { + const path = `api/admin/projects/${projectId}/role/${roleId}/access`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify(accesses), + }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + const removeUserFromRole = async ( projectId: string, roleId: number, @@ -141,6 +166,23 @@ const useProjectApi = () => { } }; + const removeGroupFromRole = async ( + projectId: string, + roleId: number, + groupId: number + ) => { + const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`; + const req = createRequest(path, { method: 'DELETE' }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + const searchProjectUser = async (query: string): Promise => { const path = `api/admin/user-admin/search?q=${query}`; @@ -166,6 +208,17 @@ const useProjectApi = () => { return makeRequest(req.caller, req.id); }; + const changeGroupRole = ( + projectId: string, + roleId: number, + groupId: number + ) => { + const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`; + const req = createRequest(path, { method: 'PUT' }); + + return makeRequest(req.caller, req.id); + }; + return { createProject, validateId, @@ -174,8 +227,11 @@ const useProjectApi = () => { addEnvironmentToProject, removeEnvironmentFromProject, addUserToRole, + addAccessToProject, removeUserFromRole, + removeGroupFromRole, changeUserRole, + changeGroupRole, errors, loading, searchProjectUser, diff --git a/frontend/src/hooks/api/getters/useGroup/useGroup.ts b/frontend/src/hooks/api/getters/useGroup/useGroup.ts new file mode 100644 index 0000000000..822548a1a6 --- /dev/null +++ b/frontend/src/hooks/api/getters/useGroup/useGroup.ts @@ -0,0 +1,42 @@ +import useSWR from 'swr'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { IGroup } from 'interfaces/group'; + +export interface IUseGroupOutput { + group?: IGroup; + refetchGroup: () => void; + loading: boolean; + error?: Error; +} + +export const mapGroupUsers = (users: any[]) => + users.map(user => ({ + ...user.user, + joinedAt: new Date(user.joinedAt), + role: user.role, + })); + +export const useGroup = (groupId: number): IUseGroupOutput => { + const { data, error, mutate } = useSWR( + formatApiPath(`api/admin/groups/${groupId}`), + fetcher + ); + + return useMemo( + () => ({ + group: data && { ...data, users: mapGroupUsers(data?.users ?? []) }, + loading: !error && !data, + refetchGroup: () => mutate(), + error, + }), + [data, error, mutate] + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Group')) + .then(res => res.json()); +}; diff --git a/frontend/src/hooks/api/getters/useGroups/useGroups.ts b/frontend/src/hooks/api/getters/useGroups/useGroups.ts new file mode 100644 index 0000000000..f6cfdf4bbd --- /dev/null +++ b/frontend/src/hooks/api/getters/useGroups/useGroups.ts @@ -0,0 +1,40 @@ +import useSWR from 'swr'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { IGroup } from 'interfaces/group'; +import { mapGroupUsers } from 'hooks/api/getters/useGroup/useGroup'; + +export interface IUseGroupsOutput { + groups?: IGroup[]; + refetchGroups: () => void; + loading: boolean; + error?: Error; +} + +export const useGroups = (): IUseGroupsOutput => { + const { data, error, mutate } = useSWR( + formatApiPath(`api/admin/groups`), + fetcher + ); + + return useMemo( + () => ({ + groups: + data?.groups.map((group: any) => ({ + ...group, + users: mapGroupUsers(group.users ?? []), + })) ?? [], + loading: !error && !data, + refetchGroups: () => mutate(), + error, + }), + [data, error, mutate] + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Groups')) + .then(res => res.json()); +}; diff --git a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts index 3c57cb0593..a3ceea8a32 100644 --- a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts +++ b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts @@ -3,19 +3,30 @@ import { useState, useEffect } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import { IProjectRole } from 'interfaces/role'; +import { IGroup } from 'interfaces/group'; +import { IUser } from 'interfaces/user'; -export interface IProjectAccessUser { - id: number; - imageUrl: string; - isAPI: boolean; +export enum ENTITY_TYPE { + USER = 'USERS', + GROUP = 'GROUPS', +} + +export interface IProjectAccess { + entity: IProjectAccessUser | IProjectAccessGroup; + type: ENTITY_TYPE; +} + +export interface IProjectAccessUser extends IUser { + roleId: number; +} + +export interface IProjectAccessGroup extends IGroup { roleId: number; - username?: string; - name?: string; - email?: string; } export interface IProjectAccessOutput { users: IProjectAccessUser[]; + groups: IProjectAccessGroup[]; roles: IProjectRole[]; } @@ -23,7 +34,7 @@ const useProjectAccess = ( projectId: string, options: SWRConfiguration = {} ) => { - const path = formatApiPath(`api/admin/projects/${projectId}/users`); + const path = formatApiPath(`api/admin/projects/${projectId}/access`); const fetcher = () => { return fetch(path, { method: 'GET', @@ -50,8 +61,21 @@ const useProjectAccess = ( setLoading(!error && !data); }, [data, error]); + // TODO: Remove this and replace `mockData` back for `data` @79. This mocks what a group looks like when returned along with the access. + // const { groups } = useGroups(); + // const mockData = useMemo( + // () => ({ + // ...data, + // groups: groups?.map(group => ({ + // ...group, + // roleId: 4, + // })) as IProjectAccessGroup[], + // }), + // [data, groups] + // ); + return { - access: data ? data : { roles: [], users: [] }, + access: data ? data : { roles: [], users: [], groups: [] }, error, loading, refetchProjectAccess, diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts index 51d90d5dff..d7d03519c4 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts @@ -15,6 +15,7 @@ export const defaultValue: IUiConfig = { SE: false, T: false, UNLEASH_CLOUD: false, + UG: false, }, links: [ { diff --git a/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts b/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts index 9ab1725009..7f5c665ef2 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts +++ b/frontend/src/hooks/api/getters/useUiConfig/useUiConfig.ts @@ -22,7 +22,11 @@ const useUiConfig = (): IUseUIConfigOutput => { }, [data]); const uiConfig: IUiConfig = useMemo(() => { - return { ...defaultValue, ...data }; + return { + ...defaultValue, + ...data, + flags: { ...defaultValue.flags, ...data?.flags }, + }; }, [data]); return { diff --git a/frontend/src/interfaces/group.ts b/frontend/src/interfaces/group.ts new file mode 100644 index 0000000000..687b58ce5b --- /dev/null +++ b/frontend/src/interfaces/group.ts @@ -0,0 +1,28 @@ +import { IUser } from './user'; + +export enum Role { + Owner = 'Owner', + Member = 'Member', +} + +export interface IGroup { + id: number; + name: string; + description: string; + createdAt: Date; + users: IGroupUser[]; + projects: string[]; + addedAt?: string; +} + +export interface IGroupUser extends IUser { + role: Role; + joinedAt?: Date; +} + +export interface IGroupUserModel { + user: { + id: number; + }; + role: Role; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 4ca783a355..ce930ae4e4 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -35,6 +35,7 @@ export interface IFlags { SE?: boolean; T?: boolean; UNLEASH_CLOUD?: boolean; + UG?: boolean; } export interface IVersionInfo { diff --git a/frontend/src/interfaces/user.ts b/frontend/src/interfaces/user.ts index 4357a5b830..4cea5ffda3 100644 --- a/frontend/src/interfaces/user.ts +++ b/frontend/src/interfaces/user.ts @@ -12,6 +12,7 @@ export interface IUser { username?: string; isAPI: boolean; paid?: boolean; + addedAt?: string; } export interface IPermission { diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index 9c7e0a476f..ce9eca54d9 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -13,6 +13,7 @@ export default createTheme({ }, boxShadows: { main: '0px 2px 4px rgba(129, 122, 254, 0.2)', + card: '0px 2px 10px rgba(28, 25, 78, 0.12)', elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)', }, typography: { @@ -55,9 +56,10 @@ export default createTheme({ dark: colors.purple[900], }, secondary: { + light: colors.purple[50], main: colors.purple[800], - light: colors.purple[700], dark: colors.purple[900], + border: colors.purple[300], }, info: { light: colors.blue[50], @@ -83,6 +85,12 @@ export default createTheme({ dark: colors.red[800], border: colors.red[300], }, + neutral: { + light: colors.grey[100], + main: colors.grey[700], + dark: colors.grey[800], + border: colors.grey[500], + }, divider: colors.grey[300], dividerAlternative: colors.grey[400], tableHeaderHover: colors.grey[400], @@ -109,10 +117,6 @@ export default createTheme({ inactive: colors.orange[200], abandoned: colors.red[200], }, - statusBadge: { - success: colors.green[100], - warning: colors.orange[200], - }, inactiveIcon: colors.grey[600], }, components: { diff --git a/frontend/src/themes/themeTypes.ts b/frontend/src/themes/themeTypes.ts index 3ea9f36bf1..1be1b05800 100644 --- a/frontend/src/themes/themeTypes.ts +++ b/frontend/src/themes/themeTypes.ts @@ -24,11 +24,16 @@ declare module '@mui/material/styles' { */ boxShadows: { main: string; + card: string; elevated: string; }; } interface CustomPalette { + /** + * Generic neutral palette color. + */ + neutral: PaletteColorOptions; /** * Colors for event log output. */ @@ -50,13 +55,6 @@ declare module '@mui/material/styles' { abandoned: string; }; dividerAlternative: string; - /** - * Background colors for status badges. - */ - statusBadge: { - success: string; - warning: string; - }; /** * For table header hover effect. */ diff --git a/frontend/src/utils/testIds.ts b/frontend/src/utils/testIds.ts index 8cf7338426..b0dfb8e10a 100644 --- a/frontend/src/utils/testIds.ts +++ b/frontend/src/utils/testIds.ts @@ -9,6 +9,11 @@ export const CF_TYPE_ID = 'CF_TYPE_ID'; export const CF_DESC_ID = 'CF_DESC_ID'; export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID'; +/* CREATE GROUP */ +export const UG_NAME_ID = 'UG_NAME_ID'; +export const UG_DESC_ID = 'UG_DESC_ID'; +export const UG_CREATE_BTN_ID = 'UG_CREATE_BTN_ID'; + /* SEGMENT */ export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID'; export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';