mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-15 01:16:22 +02:00
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 <jaanus@getunleash.ai> Co-authored-by: sighphyre <liquidwicked64@gmail.com>
This commit is contained in:
parent
e5b2f907e4
commit
df6208e309
@ -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<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||
</StyledAlert>
|
||||
}
|
||||
/>
|
||||
<StyledPlanBadge>Current plan</StyledPlanBadge>
|
||||
<Badge color="success">Current plan</Badge>
|
||||
<Grid container>
|
||||
<GridRow sx={theme => ({ marginBottom: theme.spacing(3) })}>
|
||||
<GridCol>
|
||||
|
@ -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 (
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
title="Create group"
|
||||
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||
documentationLinkLabel="Groups documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<GroupForm
|
||||
name={name}
|
||||
description={description}
|
||||
users={users}
|
||||
setName={setName}
|
||||
setDescription={setDescription}
|
||||
setUsers={setUsers}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
mode={CREATE}
|
||||
clearErrors={clearErrors}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
data-testid={UG_CREATE_BTN_ID}
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
</GroupForm>
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
94
frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
Normal file
94
frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
Normal file
@ -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 (
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
title="Edit group"
|
||||
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||
documentationLinkLabel="Groups documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<GroupForm
|
||||
name={name}
|
||||
description={description}
|
||||
users={users}
|
||||
setName={setName}
|
||||
setDescription={setDescription}
|
||||
setUsers={setUsers}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
mode={EDIT}
|
||||
clearErrors={clearErrors}
|
||||
>
|
||||
<Button type="submit" variant="contained" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</GroupForm>
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<boolean>>;
|
||||
group: IGroup;
|
||||
}
|
||||
|
||||
export const AddGroupUser: FC<IAddGroupUserProps> = ({
|
||||
open,
|
||||
setOpen,
|
||||
group,
|
||||
}) => {
|
||||
const { refetchGroup } = useGroup(group.id);
|
||||
const { updateGroup, loading } = useGroupApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [users, setUsers] = useState<IGroupUser[]>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label="Add user"
|
||||
>
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title="Add user"
|
||||
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||
documentationLinkLabel="Groups documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledInputDescription>
|
||||
Add users to this group
|
||||
</StyledInputDescription>
|
||||
<GroupFormUsersSelect
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
<GroupFormUsersTable
|
||||
users={newUsers}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<StyledCancelButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<boolean>>;
|
||||
user?: IGroupUser;
|
||||
group: IGroup;
|
||||
}
|
||||
|
||||
export const EditGroupUser: FC<IEditGroupUserProps> = ({
|
||||
open,
|
||||
setOpen,
|
||||
user,
|
||||
group,
|
||||
}) => {
|
||||
const { refetchGroup } = useGroup(group.id);
|
||||
const { updateGroup, loading } = useGroupApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [role, setRole] = useState<Role>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<SidebarModal
|
||||
open={open && Boolean(user)}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label="Edit user"
|
||||
>
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title="Edit user"
|
||||
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||
documentationLinkLabel="Groups documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledUser>
|
||||
<span>{user?.name || user?.username}</span>
|
||||
<span>{user?.email}</span>
|
||||
</StyledUser>
|
||||
<StyledInputDescription>
|
||||
Assign the role the user should have in this group
|
||||
</StyledInputDescription>
|
||||
<StyledSelect
|
||||
size="small"
|
||||
value={role}
|
||||
onChange={event =>
|
||||
setRole(event.target.value as Role)
|
||||
}
|
||||
>
|
||||
{Object.values(Role).map(role => (
|
||||
<MenuItem key={role} value={role}>
|
||||
{role}
|
||||
</MenuItem>
|
||||
))}
|
||||
</StyledSelect>
|
||||
</div>
|
||||
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<StyledCancelButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
402
frontend/src/component/admin/groups/Group/Group.tsx
Normal file
402
frontend/src/component/admin/groups/Group/Group.tsx
Normal file
@ -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<string> = { 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<IGroupUser>();
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Avatar',
|
||||
accessor: 'imageUrl',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TextCell>
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={user.imageUrl}
|
||||
title={`${
|
||||
user.name || user.email || user.username
|
||||
} (id: ${user.id})`}
|
||||
/>
|
||||
</TextCell>
|
||||
),
|
||||
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) => (
|
||||
<TimeAgoCell value={user.seenAt} emptyText="Never logged" />
|
||||
),
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original: rowUser } }: any) => (
|
||||
<ActionCell>
|
||||
<Tooltip
|
||||
title="Edit user"
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
describeChild
|
||||
enterDelay={1000}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSelectedUser(rowUser);
|
||||
setEditUserOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title="Remove user from group"
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
describeChild
|
||||
enterDelay={1000}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSelectedUser(rowUser);
|
||||
setRemoveUserOpen(true);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ActionCell>
|
||||
),
|
||||
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 (
|
||||
<ConditionallyRender
|
||||
condition={Boolean(group)}
|
||||
show={
|
||||
<>
|
||||
<MainHeader
|
||||
title={group?.name}
|
||||
description={group?.description}
|
||||
actions={
|
||||
<>
|
||||
<PermissionIconButton
|
||||
to={`/admin/groups/${groupId}/edit`}
|
||||
component={Link}
|
||||
data-loading
|
||||
permission={ADMIN}
|
||||
tooltipProps={{
|
||||
title: 'Edit group',
|
||||
}}
|
||||
>
|
||||
<StyledEdit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
data-loading
|
||||
onClick={() => setRemoveOpen(true)}
|
||||
permission={ADMIN}
|
||||
tooltipProps={{
|
||||
title: 'Remove group',
|
||||
}}
|
||||
>
|
||||
<StyledDelete />
|
||||
</PermissionIconButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
secondary
|
||||
title={`Users (${
|
||||
rows.length < data.length
|
||||
? `${rows.length} of ${data.length}`
|
||||
: data.length
|
||||
})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={
|
||||
searchValue
|
||||
}
|
||||
onChange={
|
||||
setSearchValue
|
||||
}
|
||||
hasFilters
|
||||
getSearchContext={
|
||||
getSearchContext
|
||||
}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setAddUserOpen(true);
|
||||
}}
|
||||
>
|
||||
Add user
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider
|
||||
value={getSearchText(searchValue)}
|
||||
>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No users found matching “
|
||||
{searchValue}
|
||||
” in this group.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
This group is empty. Get started by
|
||||
adding a user to the group.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RemoveGroup
|
||||
open={removeOpen}
|
||||
setOpen={setRemoveOpen}
|
||||
group={group!}
|
||||
/>
|
||||
<AddGroupUser
|
||||
open={addUserOpen}
|
||||
setOpen={setAddUserOpen}
|
||||
group={group!}
|
||||
/>
|
||||
<EditGroupUser
|
||||
open={editUserOpen}
|
||||
setOpen={setEditUserOpen}
|
||||
user={selectedUser!}
|
||||
group={group!}
|
||||
/>
|
||||
<RemoveGroupUser
|
||||
open={removeUserOpen}
|
||||
setOpen={setRemoveUserOpen}
|
||||
user={selectedUser!}
|
||||
group={group!}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<boolean>>;
|
||||
user?: IGroupUser;
|
||||
group: IGroup;
|
||||
}
|
||||
|
||||
export const RemoveGroupUser: FC<IRemoveGroupUserProps> = ({
|
||||
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 (
|
||||
<Dialogue
|
||||
open={open && Boolean(user)}
|
||||
primaryButtonText="Remove"
|
||||
secondaryButtonText="Cancel"
|
||||
onClick={onRemoveClick}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Remove user from group"
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you wish to remove{' '}
|
||||
<strong>{user?.name || user?.username || user?.email}</strong>{' '}
|
||||
from <strong>{group.name}</strong>? Removing the user from this
|
||||
group may also remove their access from projects this group is
|
||||
assigned to.
|
||||
</Typography>
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
120
frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
Normal file
120
frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
Normal file
@ -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<React.SetStateAction<string>>;
|
||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||
handleSubmit: (e: any) => void;
|
||||
handleCancel: () => void;
|
||||
errors: { [key: string]: string };
|
||||
mode: 'Create' | 'Edit';
|
||||
clearErrors: () => void;
|
||||
}
|
||||
|
||||
export const GroupForm: FC<IGroupForm> = ({
|
||||
name,
|
||||
description,
|
||||
users,
|
||||
setName,
|
||||
setDescription,
|
||||
setUsers,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
errors,
|
||||
mode,
|
||||
clearErrors,
|
||||
children,
|
||||
}) => (
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledInputDescription>
|
||||
What would you like to call your group?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
autoFocus
|
||||
label="Name"
|
||||
id="group-name"
|
||||
error={Boolean(errors.name)}
|
||||
errorText={errors.name}
|
||||
onFocus={() => clearErrors()}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
data-testid={UG_NAME_ID}
|
||||
/>
|
||||
<StyledInputDescription>
|
||||
How would you describe your group?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
multiline
|
||||
rows={4}
|
||||
label="Description"
|
||||
placeholder="A short description of the group"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
data-testid={UG_DESC_ID}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={mode === 'Create'}
|
||||
show={
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
Add users to this group
|
||||
</StyledInputDescription>
|
||||
<GroupFormUsersSelect
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
<GroupFormUsersTable
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StyledButtonContainer>
|
||||
{children}
|
||||
<StyledCancelButton onClick={handleCancel}>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
);
|
@ -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<HTMLLIElement>,
|
||||
option: IUser,
|
||||
selected: boolean
|
||||
) => (
|
||||
<li {...props}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
<StyledOption>
|
||||
<span>{option.name || option.username}</span>
|
||||
<span>{option.email}</span>
|
||||
</StyledOption>
|
||||
</li>
|
||||
);
|
||||
|
||||
interface IGroupFormUsersSelectProps {
|
||||
users: IGroupUser[];
|
||||
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||
}
|
||||
|
||||
export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
|
||||
users,
|
||||
setUsers,
|
||||
}) => {
|
||||
const { users: usersAll } = useUsers();
|
||||
const [selectedUsers, setSelectedUsers] = useState<IUser[]>([]);
|
||||
|
||||
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 (
|
||||
<StyledGroupFormUsersSelect>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
multiple
|
||||
limitTags={10}
|
||||
disableCloseOnSelect
|
||||
value={selectedUsers}
|
||||
onChange={(event, newValue, reason) => {
|
||||
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 => (
|
||||
<TextField {...params} label="Select users" />
|
||||
)}
|
||||
/>
|
||||
<Button variant="outlined" onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</StyledGroupFormUsersSelect>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<IGroupUser[]>>;
|
||||
}
|
||||
|
||||
export const GroupFormUsersTable: VFC<IGroupFormUsersTableProps> = ({
|
||||
users,
|
||||
setUsers,
|
||||
}) => {
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Avatar',
|
||||
accessor: 'imageUrl',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TextCell>
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={user.imageUrl}
|
||||
title={`${
|
||||
user.name || user.email || user.username
|
||||
} (id: ${user.id})`}
|
||||
/>
|
||||
</TextCell>
|
||||
),
|
||||
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) => (
|
||||
<GroupUserRoleCell
|
||||
value={rowUser.role}
|
||||
onChange={role =>
|
||||
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) => (
|
||||
<ActionCell>
|
||||
<Tooltip
|
||||
title="Remove user from group"
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
describeChild
|
||||
enterDelay={1000}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setUsers((users: IGroupUser[]) =>
|
||||
users.filter(
|
||||
user => user.id !== rowUser.id
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ActionCell>
|
||||
),
|
||||
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 (
|
||||
<ConditionallyRender
|
||||
condition={rows.length > 0}
|
||||
show={
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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 = () => (
|
||||
<ConditionallyRender
|
||||
condition={value === Role.Member}
|
||||
show={<StyledBadge>{capitalize(value)}</StyledBadge>}
|
||||
elseShow={<StyledOwnerBadge>{capitalize(value)}</StyledOwnerBadge>}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(onChange)}
|
||||
show={
|
||||
<Select
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={event =>
|
||||
onChange!(event.target.value as Role)
|
||||
}
|
||||
>
|
||||
{Object.values(Role).map(role => (
|
||||
<MenuItem key={role} value={role}>
|
||||
{role}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
}
|
||||
elseShow={() => renderBadge()}
|
||||
/>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
11
frontend/src/component/admin/groups/GroupsAdmin.tsx
Normal file
11
frontend/src/component/admin/groups/GroupsAdmin.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { GroupsList } from './GroupsList/GroupsList';
|
||||
import AdminMenu from '../menu/AdminMenu';
|
||||
|
||||
export const GroupsAdmin = () => {
|
||||
return (
|
||||
<div>
|
||||
<AdminMenu />
|
||||
<GroupsList />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<>
|
||||
<StyledLink key={group.id} to={`/admin/groups/${group.id}`}>
|
||||
<StyledGroupCard>
|
||||
<StyledRow>
|
||||
<StyledHeaderTitle>{group.name}</StyledHeaderTitle>
|
||||
<StyledHeaderActions>
|
||||
<GroupCardActions
|
||||
groupId={group.id}
|
||||
onRemove={() => setRemoveOpen(true)}
|
||||
/>
|
||||
</StyledHeaderActions>
|
||||
</StyledRow>
|
||||
<StyledDescription>{group.description}</StyledDescription>
|
||||
<StyledRow>
|
||||
<ConditionallyRender
|
||||
condition={group.users?.length > 0}
|
||||
show={<GroupCardAvatars users={group.users} />}
|
||||
elseShow={
|
||||
<StyledCounterDescription>
|
||||
This group has no users.
|
||||
</StyledCounterDescription>
|
||||
}
|
||||
/>
|
||||
<ProjectBadgeContainer>
|
||||
<ConditionallyRender
|
||||
condition={group.projects.length > 0}
|
||||
show={group.projects.map(project => (
|
||||
<Badge
|
||||
color="secondary"
|
||||
sx={{ marginRight: 0.5 }}
|
||||
>
|
||||
{project}
|
||||
</Badge>
|
||||
))}
|
||||
elseShow={
|
||||
<Tooltip
|
||||
title="This project is not used in any project"
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
describeChild
|
||||
enterDelay={1000}
|
||||
>
|
||||
<Badge>Not used</Badge>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</ProjectBadgeContainer>
|
||||
</StyledRow>
|
||||
</StyledGroupCard>
|
||||
</StyledLink>
|
||||
<RemoveGroup
|
||||
open={removeOpen}
|
||||
setOpen={setRemoveOpen}
|
||||
group={group!}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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<IGroupCardActions> = ({
|
||||
groupId,
|
||||
onRemove,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const id = `feature-${groupId}-actions`;
|
||||
const menuId = `${id}-menu`;
|
||||
|
||||
return (
|
||||
<StyledActions
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title="Group actions"
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
describeChild
|
||||
enterDelay={1000}
|
||||
>
|
||||
<IconButton
|
||||
id={id}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<StyledPopover
|
||||
id={menuId}
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
disableScrollLock={true}
|
||||
>
|
||||
<MenuList aria-labelledby={id}>
|
||||
<MenuItem
|
||||
onClick={handleClose}
|
||||
component={Link}
|
||||
to={`/admin/groups/${groupId}/edit`}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Edit />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Typography variant="body2">Edit group</Typography>
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Delete />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Typography variant="body2">
|
||||
Remove group
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</StyledPopover>
|
||||
</StyledActions>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<StyledAvatars>
|
||||
{shownUsers.map(user => (
|
||||
<ConditionallyRender
|
||||
key={user.id}
|
||||
condition={user.role === Role.Member}
|
||||
show={
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={user.imageUrl}
|
||||
title={`${
|
||||
user.name || user.email || user.username
|
||||
} (id: ${user.id})`}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
badgeContent={<StyledStar />}
|
||||
>
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={user.imageUrl}
|
||||
title={`${
|
||||
user.name || user.email || user.username
|
||||
} (id: ${user.id})`}
|
||||
/>
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<ConditionallyRender
|
||||
condition={users.length > 9}
|
||||
show={
|
||||
<StyledAvatarMore>
|
||||
+{users.length - shownUsers.length}
|
||||
</StyledAvatarMore>
|
||||
}
|
||||
/>
|
||||
</StyledAvatars>
|
||||
);
|
||||
};
|
137
frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
Normal file
137
frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
Normal file
@ -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<Record<'search', string>>;
|
||||
|
||||
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 (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Groups (${data.length})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
to="/admin/groups/create-group"
|
||||
component={Link}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
New group
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={searchValue}>
|
||||
<Grid container spacing={2}>
|
||||
{data.map(group => (
|
||||
<Grid key={group.id} item xs={12} md={6}>
|
||||
<GroupCard group={group} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={data.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No groups found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No groups available. Get started by adding a new
|
||||
group.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<boolean>>;
|
||||
group: IGroup;
|
||||
}
|
||||
|
||||
export const RemoveGroup: FC<IRemoveGroupProps> = ({
|
||||
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 (
|
||||
<Dialogue
|
||||
open={open}
|
||||
primaryButtonText="Remove"
|
||||
secondaryButtonText="Cancel"
|
||||
onClick={onRemoveClick}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Remove group"
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you wish to remove <strong>{group.name}</strong>?
|
||||
If this group is currently assigned to one or more projects then
|
||||
users belonging to this group may lose access to those projects.
|
||||
</Typography>
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
60
frontend/src/component/admin/groups/hooks/useGroupForm.ts
Normal file
60
frontend/src/component/admin/groups/hooks/useGroupForm.ts
Normal file
@ -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<IGroupUser[]>(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,
|
||||
};
|
||||
};
|
@ -51,6 +51,19 @@ function AdminMenu() {
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
{flags.UG && (
|
||||
<Tab
|
||||
value="/admin/groups"
|
||||
label={
|
||||
<NavLink
|
||||
to="/admin/groups"
|
||||
style={createNavLinkStyle}
|
||||
>
|
||||
<span>Groups</span>
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{flags.RE && (
|
||||
<Tab
|
||||
value="/admin/roles"
|
||||
|
85
frontend/src/component/common/Badge/Badge.tsx
Normal file
85
frontend/src/component/common/Badge/Badge.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { styled, SxProps, Theme } from '@mui/material';
|
||||
import {
|
||||
cloneElement,
|
||||
FC,
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
|
||||
|
||||
type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
|
||||
|
||||
interface IBadgeProps {
|
||||
color?: Color;
|
||||
icon?: ReactElement;
|
||||
className?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface IBadgeIconProps {
|
||||
color?: Color;
|
||||
}
|
||||
|
||||
const StyledBadge = styled('div')<IBadgeProps>(
|
||||
({ 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')<IBadgeIconProps>(
|
||||
({ theme, color = 'neutral' }) => ({
|
||||
display: 'flex',
|
||||
color: theme.palette[color].main,
|
||||
marginRight: theme.spacing(0.5),
|
||||
})
|
||||
);
|
||||
|
||||
export const Badge: FC<IBadgeProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
color = 'neutral',
|
||||
icon,
|
||||
className,
|
||||
sx,
|
||||
children,
|
||||
...props
|
||||
}: IBadgeProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => (
|
||||
<StyledBadge
|
||||
color={color}
|
||||
className={className}
|
||||
sx={sx}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon)}
|
||||
show={
|
||||
<StyledBadgeIcon color={color}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon?.props.sx)}
|
||||
show={icon}
|
||||
elseShow={() =>
|
||||
cloneElement(icon!, {
|
||||
sx: { fontSize: '16px' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</StyledBadgeIcon>
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</StyledBadge>
|
||||
)
|
||||
);
|
55
frontend/src/component/common/MainHeader/MainHeader.tsx
Normal file
55
frontend/src/component/common/MainHeader/MainHeader.tsx
Normal file
@ -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 (
|
||||
<StyledMainHeader>
|
||||
<StyledTitleHeader>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<StyledActions>{actions}</StyledActions>
|
||||
</StyledTitleHeader>
|
||||
Description:<StyledDescription>{description}</StyledDescription>
|
||||
</StyledMainHeader>
|
||||
);
|
||||
};
|
@ -47,12 +47,13 @@ export const PageContent: FC<IPageContentProps> = ({
|
||||
}) => {
|
||||
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,
|
||||
|
@ -33,6 +33,7 @@ interface IPageHeaderProps {
|
||||
loading?: boolean;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
const PageHeaderComponent: FC<IPageHeaderProps> & {
|
||||
@ -45,12 +46,13 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
|
||||
variant,
|
||||
loading,
|
||||
className = '',
|
||||
secondary,
|
||||
children,
|
||||
}) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const headerClasses = classnames({ skeleton: loading });
|
||||
|
||||
usePageTitle(title);
|
||||
usePageTitle(secondary ? '' : title);
|
||||
|
||||
return (
|
||||
<div className={styles.headerContainer}>
|
||||
@ -60,7 +62,7 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
|
||||
data-loading
|
||||
>
|
||||
<Typography
|
||||
variant={variant || 'h1'}
|
||||
variant={variant || secondary ? 'h2' : 'h1'}
|
||||
className={classnames(styles.headerTitle, className)}
|
||||
>
|
||||
{titleElement || title}
|
||||
|
@ -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 (
|
||||
<StyledStatusBadge sx={{ background }} className={className}>
|
||||
{children}
|
||||
</StyledStatusBadge>
|
||||
);
|
||||
};
|
@ -109,6 +109,7 @@ export const CellSortable: FC<ICellSortableProps> = ({
|
||||
show={
|
||||
<Tooltip title={title} arrow>
|
||||
<button
|
||||
type="button"
|
||||
className={classnames(
|
||||
isSorted && styles.sortedButton,
|
||||
styles.sortButton,
|
||||
|
@ -10,12 +10,14 @@ import classnames from 'classnames';
|
||||
interface ILinkCellProps {
|
||||
title?: string;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const LinkCell: FC<ILinkCellProps> = ({
|
||||
title,
|
||||
to,
|
||||
onClick,
|
||||
subtitle,
|
||||
children,
|
||||
}) => {
|
||||
@ -63,6 +65,14 @@ export const LinkCell: FC<ILinkCellProps> = ({
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : onClick ? (
|
||||
<Link
|
||||
onClick={onClick}
|
||||
underline="hover"
|
||||
className={classnames(styles.wrapper, styles.link)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={styles.wrapper}>{content}</span>
|
||||
);
|
||||
|
@ -4,3 +4,4 @@ export const E = 'E';
|
||||
export const EEA = 'EEA';
|
||||
export const RE = 'RE';
|
||||
export const SE = 'SE';
|
||||
export const UG = 'UG';
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { IEnvironment } from 'interfaces/environments';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
interface IEnvironmentNameCellProps {
|
||||
environment: IEnvironment;
|
||||
@ -19,11 +24,11 @@ export const EnvironmentNameCell = ({
|
||||
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
||||
<ConditionallyRender
|
||||
condition={!environment.enabled}
|
||||
show={<StatusBadge severity="warning">Disabled</StatusBadge>}
|
||||
show={<StyledBadge color="warning">Disabled</StyledBadge>}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={environment.protected}
|
||||
show={<StatusBadge severity="success">Predefined</StatusBadge>}
|
||||
show={<StyledBadge color="success">Predefined</StyledBadge>}
|
||||
/>
|
||||
</TextCell>
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
||||
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
|
||||
import { IFeatureEnvironment } from 'interfaces/featureToggle';
|
||||
@ -15,8 +14,8 @@ import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetri
|
||||
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
|
||||
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
|
||||
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
|
||||
interface IFeatureOverviewEnvironmentProps {
|
||||
env: IFeatureEnvironment;
|
||||
@ -87,12 +86,12 @@ const FeatureOverviewEnvironment = ({
|
||||
<ConditionallyRender
|
||||
condition={!env.enabled}
|
||||
show={
|
||||
<StatusBadge
|
||||
severity="warning"
|
||||
<Badge
|
||||
color="warning"
|
||||
className={styles.disabledIndicatorPos}
|
||||
>
|
||||
Disabled
|
||||
</StatusBadge>
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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<IProjectAccess> = ({ projectName }) => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss } = useUiConfig();
|
||||
usePageTitle(`Project access – ${projectName}`);
|
||||
@ -48,5 +49,5 @@ export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <ProjectAccessPage />;
|
||||
return <ProjectAccessTable />;
|
||||
};
|
||||
|
@ -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<IProjectAccessUser | undefined>();
|
||||
const [role, setRole] = useState<IProjectRole | undefined>();
|
||||
const [options, setOptions] = useState<IProjectAccessUser[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<Grid container spacing={3} alignItems="flex-end">
|
||||
<Grid item>
|
||||
<Autocomplete
|
||||
id="add-user-component"
|
||||
style={{ width: 300 }}
|
||||
noOptionsText="No users found."
|
||||
onChange={handleSelectUser}
|
||||
onBlur={() => handleBlur()}
|
||||
value={user || ''}
|
||||
freeSolo
|
||||
isOptionEqualToValue={() => true}
|
||||
filterOptions={o => o}
|
||||
getOptionLabel={getOptionLabel}
|
||||
options={options}
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="User"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
name="search"
|
||||
onChange={handleQueryUpdate}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={loading}
|
||||
show={
|
||||
<CircularProgress
|
||||
color="inherit"
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ProjectRoleSelect
|
||||
labelId="add-user-select-role-label"
|
||||
id="add-user-select-role"
|
||||
placeholder="Project role"
|
||||
value={role?.id || -1}
|
||||
onChange={handleRoleChange}
|
||||
roles={roles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!user}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Add user
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<boolean>>;
|
||||
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<IAccessOption[]>([]);
|
||||
const [role, setRole] = useState<IProjectRole | null>(
|
||||
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<HTMLFormElement>) => {
|
||||
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<HTMLLIElement>,
|
||||
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 (
|
||||
<li {...props}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={option.type === ENTITY_TYPE.GROUP}
|
||||
show={
|
||||
<StyledGroupOption>
|
||||
<span>{optionGroup?.name}</span>
|
||||
<span>{optionGroup?.users.length} users</span>
|
||||
</StyledGroupOption>
|
||||
}
|
||||
elseShow={
|
||||
<StyledUserOption>
|
||||
<span>
|
||||
{optionUser?.name || optionUser?.username}
|
||||
</span>
|
||||
<span>{optionUser?.email}</span>
|
||||
</StyledUserOption>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRoleOption = (
|
||||
props: React.HTMLAttributes<HTMLLIElement>,
|
||||
option: IProjectRole
|
||||
) => (
|
||||
<li {...props}>
|
||||
<StyledRoleOption>
|
||||
<span>{option.name}</span>
|
||||
<span>{option.description}</span>
|
||||
</StyledRoleOption>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
|
||||
>
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
|
||||
description="Custom project roles allow you to fine-tune access rights and permissions within your projects."
|
||||
documentationLink="https://docs.getunleash.io/how-to/how-to-create-and-assign-custom-project-roles"
|
||||
documentationLinkLabel="Project access documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledInputDescription>
|
||||
Select the {entityType}
|
||||
</StyledInputDescription>
|
||||
<StyledAutocompleteWrapper>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
multiple
|
||||
limitTags={10}
|
||||
disableCloseOnSelect
|
||||
disabled={edit}
|
||||
value={selectedOptions}
|
||||
onChange={(event, newValue, reason) => {
|
||||
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 => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={capitalize(entityType)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledAutocompleteWrapper>
|
||||
<StyledInputDescription>
|
||||
Select the role to assign for this project
|
||||
</StyledInputDescription>
|
||||
<StyledAutocompleteWrapper>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
value={role}
|
||||
onChange={(_, newValue) => setRole(newValue)}
|
||||
options={roles}
|
||||
renderOption={renderRoleOption}
|
||||
getOptionLabel={option => option.name}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Role" />
|
||||
)}
|
||||
/>
|
||||
</StyledAutocompleteWrapper>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(role?.id)}
|
||||
show={<ProjectRoleDescription roleId={role?.id!} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Assign {entityType}
|
||||
</Button>
|
||||
<StyledCancelButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -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<IProjectRoleDescriptionProps> = ({
|
||||
roleId,
|
||||
}) => {
|
||||
const { role } = useProjectRole(roleId.toString());
|
||||
|
||||
const environments = useMemo(() => {
|
||||
const environments = new Set<string>();
|
||||
role.permissions
|
||||
?.filter((permission: any) => permission.environment !== '')
|
||||
.forEach((permission: any) => {
|
||||
environments.add(permission.environment);
|
||||
});
|
||||
return [...environments].sort();
|
||||
}, [role]);
|
||||
|
||||
return (
|
||||
<StyledDescription>
|
||||
<StyledDescriptionHeader>
|
||||
Project permissions
|
||||
</StyledDescriptionHeader>
|
||||
<StyledDescriptionBlock>
|
||||
{role.permissions
|
||||
?.filter((permission: any) => permission.environment === '')
|
||||
.map((permission: any) => permission.displayName)
|
||||
.sort()
|
||||
.map((permission: any) => (
|
||||
<p key={permission}>{permission}</p>
|
||||
))}
|
||||
</StyledDescriptionBlock>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(environments.length)}
|
||||
show={
|
||||
<>
|
||||
<StyledDescriptionHeader>
|
||||
Environment permissions
|
||||
</StyledDescriptionHeader>
|
||||
{environments.map((environment: any) => (
|
||||
<div key={environment}>
|
||||
<StyledDescriptionSubHeader>
|
||||
{environment}
|
||||
</StyledDescriptionSubHeader>
|
||||
<StyledDescriptionBlock>
|
||||
{role.permissions
|
||||
.filter(
|
||||
(permission: any) =>
|
||||
permission.environment ===
|
||||
environment
|
||||
)
|
||||
.map(
|
||||
(permission: any) =>
|
||||
permission.displayName
|
||||
)
|
||||
.sort()
|
||||
.map((permission: any) => (
|
||||
<p key={permission}>{permission}</p>
|
||||
))}
|
||||
</StyledDescriptionBlock>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</StyledDescription>
|
||||
);
|
||||
};
|
@ -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<IProjectAccessUser | undefined>();
|
||||
|
||||
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 (
|
||||
<PageContent
|
||||
header={<PageHeader titleElement="Project roles" />}
|
||||
className={styles.pageContent}
|
||||
>
|
||||
<ProjectAccessAddUser roles={access?.roles} />
|
||||
<div className={styles.divider} />
|
||||
<ProjectAccessTable
|
||||
access={access}
|
||||
handleRoleChange={handleRoleChange}
|
||||
handleRemoveAccess={handleRemoveAccess}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<ConfirmDialogue
|
||||
open={showDelDialogue}
|
||||
onClick={removeAccess(user)}
|
||||
onClose={() => {
|
||||
setUser(undefined);
|
||||
setShowDelDialogue(false);
|
||||
}}
|
||||
title="Really remove user from this project"
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -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<void>;
|
||||
handleRemoveAccess: (user: IProjectAccessUser) => void;
|
||||
}
|
||||
export type PageQueryType = Partial<
|
||||
Record<'sort' | 'order' | 'search', string>
|
||||
>;
|
||||
|
||||
export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
|
||||
access,
|
||||
projectId,
|
||||
handleRoleChange,
|
||||
handleRemoveAccess,
|
||||
}) => {
|
||||
const data = access.users;
|
||||
const defaultSort: SortingRule<string> = { 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<IProjectAccess>();
|
||||
|
||||
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 }) => (
|
||||
<Avatar
|
||||
alt="Gravatar"
|
||||
src={value}
|
||||
sx={{ width: 32, height: 32, mx: 'auto' }}
|
||||
/>
|
||||
Cell: ({ row: { original: row } }: any) => (
|
||||
<TextCell>
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={row.entity.imageUrl}
|
||||
title={`${
|
||||
row.entity.name ||
|
||||
row.entity.email ||
|
||||
row.entity.username
|
||||
} (id: ${row.entity.id})`}
|
||||
>
|
||||
{row.entity.users?.length}
|
||||
</StyledAvatar>
|
||||
</TextCell>
|
||||
),
|
||||
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) => (
|
||||
<ConditionallyRender
|
||||
condition={row.type === ENTITY_TYPE.GROUP}
|
||||
show={
|
||||
<LinkCell
|
||||
onClick={() => {
|
||||
setSelectedRow(row);
|
||||
setGroupOpen(true);
|
||||
}}
|
||||
title={value}
|
||||
subtitle={`${row.entity.users?.length} users`}
|
||||
/>
|
||||
}
|
||||
elseShow={<HighlightCell value={value} />}
|
||||
/>
|
||||
),
|
||||
minWidth: 100,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
id: 'username',
|
||||
Header: 'Username',
|
||||
accessor: 'email',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TextCell>{user.email || user.username}</TextCell>
|
||||
),
|
||||
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 };
|
||||
}) => (
|
||||
<ProjectRoleCell
|
||||
value={value}
|
||||
user={user}
|
||||
roles={access.roles}
|
||||
onChange={handleRoleChange(user.id)}
|
||||
/>
|
||||
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 }) => (
|
||||
<TimeAgoCell value={value} emptyText="Never logged" />
|
||||
),
|
||||
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 }) => (
|
||||
<TimeAgoCell value={value} emptyText="Never logged" />
|
||||
),
|
||||
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) => (
|
||||
<ActionCell>
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
onClick={() => {
|
||||
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<IProjectAccessTableProps> = ({
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
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 (
|
||||
<Table {...getTableProps()} rowHeight="compact">
|
||||
{/* @ts-expect-error -- react-table */}
|
||||
<SortableTableHeader headerGroups={headerGroups} />
|
||||
<TableBody {...getTableBodyProps()}>
|
||||
{rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<TableRow hover {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<TableCell {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
secondary
|
||||
title={`Access (${
|
||||
rows.length < data.length
|
||||
? `${rows.length} of ${data.length}`
|
||||
: data.length
|
||||
})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setAssignOpen(true)}
|
||||
>
|
||||
Assign {entityType}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No access found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No access available. Get started by assigning a{' '}
|
||||
{entityType}.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ProjectAccessAssign
|
||||
open={assignOpen}
|
||||
setOpen={setAssignOpen}
|
||||
selected={selectedRow}
|
||||
accesses={mappedData}
|
||||
roles={roles}
|
||||
entityType={entityType}
|
||||
/>
|
||||
<Dialogue
|
||||
open={removeOpen}
|
||||
onClick={() => removeAccess(selectedRow)}
|
||||
onClose={() => {
|
||||
setSelectedRow(undefined);
|
||||
setRemoveOpen(false);
|
||||
}}
|
||||
title={`Really remove ${entityType} from this project?`}
|
||||
/>
|
||||
<ProjectGroupView
|
||||
open={groupOpen}
|
||||
setOpen={setGroupOpen}
|
||||
group={selectedRow?.entity as IGroup}
|
||||
projectId={projectId}
|
||||
subtitle={`Role: ${
|
||||
roles.find(({ id }) => id === selectedRow?.entity.roleId)
|
||||
?.name
|
||||
}`}
|
||||
onEdit={() => {
|
||||
setAssignOpen(true);
|
||||
console.log('Assign Open true');
|
||||
}}
|
||||
onRemove={() => {
|
||||
setGroupOpen(false);
|
||||
setRemoveOpen(true);
|
||||
}}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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<void>;
|
||||
}
|
||||
|
||||
export const ProjectRoleCell: VFC<IProjectRoleCellProps> = ({
|
||||
value,
|
||||
user,
|
||||
roles,
|
||||
onChange,
|
||||
}) => {
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<Box className={classes.cell}>
|
||||
<ProjectRoleSelect
|
||||
id={`role-${user.id}-select`}
|
||||
key={user.id}
|
||||
placeholder="Choose role"
|
||||
onChange={onChange}
|
||||
roles={roles}
|
||||
value={value || -1}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
Choose role
|
||||
</MenuItem>
|
||||
</ProjectRoleSelect>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -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<string> = { id: 'role', desc: true };
|
||||
|
||||
const columns = [
|
||||
{
|
||||
Header: 'Avatar',
|
||||
accessor: 'imageUrl',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TextCell>
|
||||
<StyledAvatar
|
||||
data-loading
|
||||
alt="Gravatar"
|
||||
src={user.imageUrl}
|
||||
title={`${user.name || user.email || user.username} (id: ${
|
||||
user.id
|
||||
})`}
|
||||
/>
|
||||
</TextCell>
|
||||
),
|
||||
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) => (
|
||||
<TimeAgoCell value={user.seenAt} emptyText="Never logged" />
|
||||
),
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
},
|
||||
];
|
||||
|
||||
interface IProjectGroupViewProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
group: IGroup;
|
||||
projectId: string;
|
||||
subtitle: string;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const ProjectGroupView: VFC<IProjectGroupViewProps> = ({
|
||||
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 (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label={group?.name || 'Group'}
|
||||
>
|
||||
<StyledPageContent
|
||||
header={
|
||||
<PageHeader
|
||||
secondary
|
||||
titleElement={
|
||||
<StyledTitle>
|
||||
{group?.name} (
|
||||
{rows.length < data.length
|
||||
? `${rows.length} of ${data.length}`
|
||||
: data.length}
|
||||
)<span>{subtitle}</span>
|
||||
</StyledTitle>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={
|
||||
getSearchContext
|
||||
}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
tooltipProps={{
|
||||
title: 'Edit group access',
|
||||
}}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
tooltipProps={{
|
||||
title: 'Remove group access',
|
||||
}}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No users found matching “
|
||||
{searchValue}
|
||||
” in this group.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
This group is empty. Get started by adding a
|
||||
user to the group.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledPageContent>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -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<IProjectRoleSelect> = ({
|
||||
roles,
|
||||
onChange,
|
||||
labelId,
|
||||
id,
|
||||
value,
|
||||
placeholder,
|
||||
children,
|
||||
}) => {
|
||||
const { classes: styles } = useStyles();
|
||||
return (
|
||||
<FormControl variant="outlined" size="small">
|
||||
<ConditionallyRender
|
||||
condition={Boolean(labelId)}
|
||||
show={() => (
|
||||
<InputLabel
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
id={labelId}
|
||||
>
|
||||
Role
|
||||
</InputLabel>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
labelId={labelId}
|
||||
id={id}
|
||||
classes={{ select: styles.projectRoleSelect }}
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
renderValue={roleId => {
|
||||
const role = roles?.find(role => {
|
||||
return String(role.id) === String(roleId);
|
||||
});
|
||||
return role?.name || '';
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{roles?.map(role => (
|
||||
<MenuItem
|
||||
key={role.id}
|
||||
value={role.id}
|
||||
classes={{
|
||||
root: styles.menuItem,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className={styles.roleName}>{role.name}</span>
|
||||
<p>
|
||||
{role.description ||
|
||||
'No role description available.'}
|
||||
</p>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
@ -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<IDialogueMetaData>(
|
||||
@ -191,9 +196,9 @@ export const StrategiesList = () => {
|
||||
<ConditionallyRender
|
||||
condition={!editable}
|
||||
show={() => (
|
||||
<StatusBadge severity="success">
|
||||
<StyledBadge color="success">
|
||||
Predefined
|
||||
</StatusBadge>
|
||||
</StyledBadge>
|
||||
)}
|
||||
/>
|
||||
</LinkCell>
|
||||
|
@ -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"
|
||||
>
|
||||
<div
|
||||
className="tss-1ywhhai-headerContainer"
|
||||
className="header tss-1ywhhai-headerContainer"
|
||||
>
|
||||
<div
|
||||
className="tss-1ylehva-headerContainer"
|
||||
@ -109,7 +109,7 @@ exports[`renders an empty list correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="tss-54jt3w-bodyContainer"
|
||||
className="body tss-54jt3w-bodyContainer"
|
||||
>
|
||||
<table
|
||||
className="MuiTable-root tss-rjdss1-table mui-133vm37-MuiTable-root"
|
||||
@ -162,6 +162,7 @@ exports[`renders an empty list correctly 1`] = `
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
|
64
frontend/src/hooks/api/actions/useGroupApi/useGroupApi.ts
Normal file
64
frontend/src/hooks/api/actions/useGroupApi/useGroupApi.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import useAPI from '../useApi/useApi';
|
||||
import { IGroupUserModel } from 'interfaces/group';
|
||||
|
||||
interface ICreateGroupPayload {
|
||||
name: string;
|
||||
description: string;
|
||||
users: IGroupUserModel[];
|
||||
}
|
||||
|
||||
export const useGroupApi = () => {
|
||||
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,
|
||||
};
|
||||
};
|
@ -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<Response> => {
|
||||
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,
|
||||
|
42
frontend/src/hooks/api/getters/useGroup/useGroup.ts
Normal file
42
frontend/src/hooks/api/getters/useGroup/useGroup.ts
Normal file
@ -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());
|
||||
};
|
40
frontend/src/hooks/api/getters/useGroups/useGroups.ts
Normal file
40
frontend/src/hooks/api/getters/useGroups/useGroups.ts
Normal file
@ -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());
|
||||
};
|
@ -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,
|
||||
|
@ -15,6 +15,7 @@ export const defaultValue: IUiConfig = {
|
||||
SE: false,
|
||||
T: false,
|
||||
UNLEASH_CLOUD: false,
|
||||
UG: false,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
|
@ -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 {
|
||||
|
28
frontend/src/interfaces/group.ts
Normal file
28
frontend/src/interfaces/group.ts
Normal file
@ -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;
|
||||
}
|
@ -35,6 +35,7 @@ export interface IFlags {
|
||||
SE?: boolean;
|
||||
T?: boolean;
|
||||
UNLEASH_CLOUD?: boolean;
|
||||
UG?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -12,6 +12,7 @@ export interface IUser {
|
||||
username?: string;
|
||||
isAPI: boolean;
|
||||
paid?: boolean;
|
||||
addedAt?: string;
|
||||
}
|
||||
|
||||
export interface IPermission {
|
||||
|
@ -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: {
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user