1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

fix: remove group owner concept (#1210)

* fix: remove group owner concept

* fix: adapt e2e tests accordingly

* refactor users select to match improvement

* refactor: add user -> edit users

* feat: add edit users to group card actions

* add a few more UI improvements

* fix: edit group users icon

* improve loading behaviour

* fix group users refresh on card view

* improvement: create group form validation

* fix edit group, some refactoring

* fix: e2e tests, minor bugs

* fix: infinite re-renders due to useHiddenColumns useEffect array dependency

* fix re-rendering on useHiddenColumns for some tables

* refactor: validations into functions / variables
This commit is contained in:
Nuno Góis 2022-08-11 15:34:17 +01:00 committed by GitHub
parent d3e853cf7f
commit 3200fee963
27 changed files with 345 additions and 651 deletions

View File

@ -39,23 +39,6 @@ describe('groups', () => {
}
});
it('gives an error if a group does not have an owner', () => {
cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
cy.intercept('POST', '/api/admin/groups').as('createGroup');
cy.get("[data-testid='UG_NAME_ID']").type(groupName);
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
cy.get("[data-testid='UG_USERS_ID']").click();
cy.contains(`unleash-e2e-user1-${randomId}`).click();
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
cy.get("[data-testid='TOAST_TEXT']").contains(
'Group needs to have at least one Owner'
);
});
it('can create a group', () => {
cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
@ -65,9 +48,6 @@ describe('groups', () => {
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
cy.get("[data-testid='UG_USERS_ID']").click();
cy.contains(`unleash-e2e-user1-${randomId}`).click();
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
cy.contains('Owner').click();
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
cy.wait('@createGroup');
@ -80,16 +60,8 @@ describe('groups', () => {
cy.intercept('POST', '/api/admin/groups').as('createGroup');
cy.get("[data-testid='UG_NAME_ID']").type(groupName);
cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
cy.get("[data-testid='UG_USERS_ID']").click();
cy.contains(`unleash-e2e-user1-${randomId}`).click();
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
cy.get("[data-testid='UG_USERS_TABLE_ROLE_ID']").click();
cy.contains('Owner').click();
cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
cy.get("[data-testid='TOAST_TEXT']").contains(
'Group name already exists'
cy.get("[data-testid='INPUT_ERROR_TEXT'").contains(
'A group with that name already exists.'
);
});
@ -108,34 +80,15 @@ describe('groups', () => {
it('can add user to a group', () => {
cy.contains(groupName).click();
cy.get("[data-testid='UG_ADD_USER_BTN_ID']").click();
cy.get("[data-testid='UG_EDIT_USERS_BTN_ID']").click();
cy.get("[data-testid='UG_USERS_ID']").click();
cy.contains(`unleash-e2e-user2-${randomId}`).click();
cy.get("[data-testid='UG_USERS_ADD_ID']").click();
cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
cy.contains(`unleash-e2e-user1-${randomId}`);
cy.contains(`unleash-e2e-user2-${randomId}`);
cy.get("td span:contains('Owner')").should('have.length', 1);
cy.get("td span:contains('Member')").should('have.length', 1);
});
it('can edit user role in a group', () => {
cy.contains(groupName).click();
cy.get(`[data-testid='UG_EDIT_USER_BTN_ID-${userIds[1]}']`).click();
cy.get("[data-testid='UG_USERS_ROLE_ID']").click();
cy.get("li[data-value='Owner']").click();
cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
cy.contains(`unleash-e2e-user1-${randomId}`);
cy.contains(`unleash-e2e-user2-${randomId}`);
cy.get("td span:contains('Owner')").should('have.length', 2);
cy.contains('Member').should('not.exist');
});
it('can remove user from a group', () => {

View File

@ -27,10 +27,14 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
import { Search } from 'component/common/Search/Search';
import useHiddenColumns from 'hooks/useHiddenColumns';
const hiddenColumnsSmall = ['Icon', 'createdAt'];
const hiddenColumnsFlagE = ['projects', 'environment'];
export const ApiTokenTable = () => {
const { tokens, loading } = useApiTokens();
const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
const { uiConfig } = useUiConfig();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const {
getTableProps,
@ -53,16 +57,8 @@ export const ApiTokenTable = () => {
useSortBy
);
useHiddenColumns(
setHiddenColumns,
['Icon', 'createdAt'],
useMediaQuery(theme.breakpoints.down('md'))
);
useHiddenColumns(
setHiddenColumns,
['projects', 'environment'],
!uiConfig.flags.E
);
useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
useHiddenColumns(setHiddenColumns, hiddenColumnsFlagE, !uiConfig.flags.E);
return (
<PageContent

View File

@ -10,6 +10,7 @@ import { UG_CREATE_BTN_ID } from 'utils/testIds';
import { Button } from '@mui/material';
import { CREATE } from 'constants/misc';
import { GO_BACK } from 'constants/navigate';
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
export const CreateGroup = () => {
const { setToastData, setToastApiError } = useToast();
@ -26,14 +27,18 @@ export const CreateGroup = () => {
getGroupPayload,
clearErrors,
errors,
setErrors,
} = useGroupForm();
const { groups } = useGroups();
const { createGroup, loading } = useGroupApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
if (!isValid) return;
const payload = getGroupPayload();
try {
const group = await createGroup(payload);
@ -62,6 +67,19 @@ export const CreateGroup = () => {
navigate(GO_BACK);
};
const isNameEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
!groups?.filter(group => group.name === name).length;
const isValid = isNameEmpty(name) && isNameUnique(name);
const onSetName = (name: string) => {
clearErrors();
if (!isNameUnique(name)) {
setErrors({ name: 'A group with that name already exists.' });
}
setName(name);
};
return (
<FormTemplate
loading={loading}
@ -75,19 +93,19 @@ export const CreateGroup = () => {
name={name}
description={description}
users={users}
setName={setName}
setName={onSetName}
setDescription={setDescription}
setUsers={setUsers}
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
mode={CREATE}
clearErrors={clearErrors}
>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
data-testid={UG_CREATE_BTN_ID}
>
Create group

View File

@ -12,10 +12,12 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
import { UG_SAVE_BTN_ID } from 'utils/testIds';
import { GO_BACK } from 'constants/navigate';
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
export const EditGroup = () => {
const groupId = Number(useRequiredPathParam('groupId'));
const { group, refetchGroup } = useGroup(groupId);
const { refetchGroups } = useGroups();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const navigate = useNavigate();
@ -30,8 +32,10 @@ export const EditGroup = () => {
getGroupPayload,
clearErrors,
errors,
setErrors,
} = useGroupForm(group?.name, group?.description, group?.users);
const { groups } = useGroups();
const { updateGroup, loading } = useGroupApi();
const handleSubmit = async (e: Event) => {
@ -42,6 +46,7 @@ export const EditGroup = () => {
try {
await updateGroup(groupId, payload);
refetchGroup();
refetchGroups();
navigate(GO_BACK);
setToastData({
title: 'Group updated successfully',
@ -65,6 +70,20 @@ export const EditGroup = () => {
navigate(GO_BACK);
};
const isNameEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
!groups?.filter(group => group.name === name && group.id !== groupId)
.length;
const isValid = isNameEmpty(name) && isNameUnique(name);
const onSetName = (name: string) => {
clearErrors();
if (!isNameUnique(name)) {
setErrors({ name: 'A group with that name already exists.' });
}
setName(name);
};
return (
<FormTemplate
loading={loading}
@ -78,19 +97,19 @@ export const EditGroup = () => {
name={name}
description={description}
users={users}
setName={setName}
setName={onSetName}
setDescription={setDescription}
setUsers={setUsers}
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
mode={EDIT}
clearErrors={clearErrors}
>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
data-testid={UG_SAVE_BTN_ID}
>
Save

View File

@ -1,183 +0,0 @@
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';
import { UG_SAVE_BTN_ID, UG_USERS_ROLE_ID } from 'utils/testIds';
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
data-testid={UG_USERS_ROLE_ID}
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
data-testid={UG_SAVE_BTN_ID}
type="submit"
variant="contained"
color="primary"
>
Save
</Button>
<StyledCancelButton
onClick={() => {
setOpen(false);
}}
>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -5,12 +5,14 @@ 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 { IGroup } from 'interfaces/group';
import { FC, FormEvent, useEffect } 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';
import { UG_SAVE_BTN_ID } from 'utils/testIds';
import { useGroupForm } from 'component/admin/groups/hooks/useGroupForm';
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -33,63 +35,43 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
interface IAddGroupUserProps {
interface IEditGroupUsersProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
group: IGroup;
}
export const AddGroupUser: FC<IAddGroupUserProps> = ({
export const EditGroupUsers: FC<IEditGroupUsersProps> = ({
open,
setOpen,
group,
}) => {
const { refetchGroup } = useGroup(group.id);
const { refetchGroups } = useGroups();
const { updateGroup, loading } = useGroupApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [users, setUsers] = useState<IGroupUser[]>(group.users);
const { users, setUsers, getGroupPayload } = useGroupForm(
group.name,
group.description,
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]);
}, [group.users, open, setUsers]);
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);
await updateGroup(group.id, getGroupPayload());
refetchGroup();
refetchGroups();
setOpen(false);
setToastData({
title: message,
title: 'Group users saved successfully',
type: 'success',
});
} catch (error: unknown) {
@ -103,7 +85,7 @@ export const AddGroupUser: FC<IAddGroupUserProps> = ({
}/api/admin/groups/${group.id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
--data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
};
return (
@ -112,12 +94,12 @@ export const AddGroupUser: FC<IAddGroupUserProps> = ({
onClose={() => {
setOpen(false);
}}
label="Add user"
label="Edit users"
>
<FormTemplate
loading={loading}
modal
title="Add user"
title="Edit users"
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"
@ -126,14 +108,14 @@ export const AddGroupUser: FC<IAddGroupUserProps> = ({
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
Add users to this group
Edit users in this group
</StyledInputDescription>
<GroupFormUsersSelect
users={users}
setUsers={setUsers}
/>
<GroupFormUsersTable
users={newUsers}
users={users}
setUsers={setUsers}
/>
</div>

View File

@ -17,13 +17,12 @@ 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 { IGroupUser } 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 { Add, Delete, Edit } from '@mui/icons-material';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
@ -31,16 +30,14 @@ import { MainHeader } from 'component/common/MainHeader/MainHeader';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { AddGroupUser } from './AddGroupUser/AddGroupUser';
import { EditGroupUser } from './EditGroupUser/EditGroupUser';
import { EditGroupUsers } from './EditGroupUsers/EditGroupUsers';
import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import {
UG_EDIT_BTN_ID,
UG_DELETE_BTN_ID,
UG_ADD_USER_BTN_ID,
UG_EDIT_USER_BTN_ID,
UG_EDIT_USERS_BTN_ID,
UG_REMOVE_USER_BTN_ID,
} from 'utils/testIds';
@ -55,14 +52,13 @@ const StyledDelete = styled(Delete)(({ theme }) => ({
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 defaultSort: SortingRule<string> = { id: 'joinedAt' };
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
'Group:v1',
@ -75,8 +71,7 @@ export const Group: VFC = () => {
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 [editUsersOpen, setEditUsersOpen] = useState(false);
const [removeUserOpen, setRemoveUserOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<IGroupUser>();
@ -109,13 +104,6 @@ export const Group: VFC = () => {
minWidth: 100,
searchable: true,
},
{
Header: 'User type',
accessor: 'role',
Cell: GroupUserRoleCell,
maxWidth: 150,
filterName: 'type',
},
{
Header: 'Joined',
accessor: 'joinedAt',
@ -138,20 +126,6 @@ export const Group: VFC = () => {
align: 'center',
Cell: ({ row: { original: rowUser } }: any) => (
<ActionCell>
<Tooltip title="Edit user" arrow describeChild>
<span>
<IconButton
data-testid={`${UG_EDIT_USER_BTN_ID}-${rowUser.id}`}
disabled={group?.users.length === 1}
onClick={() => {
setSelectedUser(rowUser);
setEditUserOpen(true);
}}
>
<Edit />
</IconButton>
</span>
</Tooltip>
<Tooltip
title="Remove user from group"
arrow
@ -160,7 +134,6 @@ export const Group: VFC = () => {
<span>
<IconButton
data-testid={`${UG_REMOVE_USER_BTN_ID}-${rowUser.id}`}
disabled={group?.users.length === 1}
onClick={() => {
setSelectedUser(rowUser);
setRemoveUserOpen(true);
@ -176,7 +149,7 @@ export const Group: VFC = () => {
disableSortBy: true,
},
],
[setSelectedUser, setRemoveUserOpen, group?.users.length]
[setSelectedUser, setRemoveUserOpen]
);
const [searchParams, setSearchParams] = useSearchParams();
@ -312,15 +285,15 @@ export const Group: VFC = () => {
}
/>
<ResponsiveButton
data-testid={UG_ADD_USER_BTN_ID}
data-testid={UG_EDIT_USERS_BTN_ID}
onClick={() => {
setAddUserOpen(true);
setEditUsersOpen(true);
}}
maxWidth="700px"
Icon={Add}
permission={ADMIN}
>
Add user
Edit users
</ResponsiveButton>
</>
}
@ -374,15 +347,9 @@ export const Group: VFC = () => {
setOpen={setRemoveOpen}
group={group!}
/>
<AddGroupUser
open={addUserOpen}
setOpen={setAddUserOpen}
group={group!}
/>
<EditGroupUser
open={editUserOpen}
setOpen={setEditUserOpen}
user={selectedUser}
<EditGroupUsers
open={editUsersOpen}
setOpen={setEditUsersOpen}
group={group!}
/>
<RemoveGroupUser

View File

@ -30,9 +30,8 @@ export const RemoveGroupUser: FC<IRemoveGroupUserProps> = ({
...group,
users: group.users
.filter(({ id }) => id !== user?.id)
.map(({ id, role }) => ({
.map(({ id }) => ({
user: { id },
role,
})),
};
await updateGroup(group.id, groupPayload);

View File

@ -42,14 +42,13 @@ interface IGroupForm {
name: string;
description: string;
users: IGroupUser[];
setName: React.Dispatch<React.SetStateAction<string>>;
setName: (name: string) => void;
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> = ({
@ -63,7 +62,6 @@ export const GroupForm: FC<IGroupForm> = ({
handleCancel,
errors,
mode,
clearErrors,
children,
}) => (
<StyledForm onSubmit={handleSubmit}>
@ -77,7 +75,6 @@ export const GroupForm: FC<IGroupForm> = ({
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}

View File

@ -1,17 +1,11 @@
import {
Autocomplete,
Button,
Checkbox,
styled,
TextField,
} from '@mui/material';
import { Autocomplete, 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 { VFC } from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { IGroupUser, Role } from 'interfaces/group';
import { UG_USERS_ADD_ID, UG_USERS_ID } from 'utils/testIds';
import { IGroupUser } from 'interfaces/group';
import { UG_USERS_ID } from 'utils/testIds';
const StyledOption = styled('div')(({ theme }) => ({
display: 'flex',
@ -21,6 +15,10 @@ const StyledOption = styled('div')(({ theme }) => ({
},
}));
const StyledTags = styled('div')(({ theme }) => ({
paddingLeft: theme.spacing(1),
}));
const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({
display: 'flex',
marginBottom: theme.spacing(3),
@ -50,6 +48,14 @@ const renderOption = (
</li>
);
const renderTags = (value: IGroupUser[]) => (
<StyledTags>
{value.length > 1
? `${value.length} users selected`
: value[0].name || value[0].username || value[0].email}
</StyledTags>
);
interface IGroupFormUsersSelectProps {
users: IGroupUser[];
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
@ -60,26 +66,6 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
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>
@ -87,9 +73,10 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
data-testid={UG_USERS_ID}
size="small"
multiple
limitTags={10}
limitTags={1}
openOnFocus
disableCloseOnSelect
value={selectedUsers}
value={users}
onChange={(event, newValue, reason) => {
if (
event.type === 'keydown' &&
@ -98,9 +85,9 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
) {
return;
}
setSelectedUsers(newValue);
setUsers(newValue);
}}
options={[...usersOptions].sort((a, b) => {
options={[...usersAll].sort((a, b) => {
const aName = a.name || a.username || '';
const bName = b.name || b.username || '';
return aName.localeCompare(bName);
@ -108,20 +95,15 @@ export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
renderOption={(props, option, { selected }) =>
renderOption(props, option as IUser, selected)
}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option: IUser) =>
option.email || option.name || option.username || ''
}
renderInput={params => (
<TextField {...params} label="Select users" />
)}
renderTags={value => renderTags(value)}
/>
<Button
variant="outlined"
onClick={onAdd}
data-testid={UG_USERS_ADD_ID}
>
Add
</Button>
</StyledGroupFormUsersSelect>
);
};

View File

@ -3,7 +3,6 @@ import { IconButton, Tooltip, useMediaQuery } 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';
@ -14,6 +13,8 @@ import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import theme from 'themes/theme';
import useHiddenColumns from 'hooks/useHiddenColumns';
const hiddenColumnsSmall = ['imageUrl', 'name'];
interface IGroupFormUsersTableProps {
users: IGroupUser[];
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
@ -23,6 +24,8 @@ export const GroupFormUsersTable: VFC<IGroupFormUsersTableProps> = ({
users,
setUsers,
}) => {
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const columns = useMemo(
() => [
{
@ -52,30 +55,6 @@ export const GroupFormUsersTable: VFC<IGroupFormUsersTableProps> = ({
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',
@ -121,11 +100,7 @@ export const GroupFormUsersTable: VFC<IGroupFormUsersTableProps> = ({
useFlexLayout
);
useHiddenColumns(
setHiddenColumns,
['imageUrl', 'name'],
useMediaQuery(theme.breakpoints.down('md'))
);
useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
return (
<ConditionallyRender

View File

@ -1,58 +0,0 @@
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';
import { Badge } from 'component/common/Badge/Badge';
import { StarRounded } from '@mui/icons-material';
import { UG_USERS_TABLE_ROLE_ID } from 'utils/testIds';
const StyledPopupStar = styled(StarRounded)(({ theme }) => ({
color: theme.palette.warning.main,
}));
interface IGroupUserRoleCellProps {
value?: string;
onChange?: (role: Role) => void;
}
export const GroupUserRoleCell = ({
value = Role.Member,
onChange,
}: IGroupUserRoleCellProps) => {
const renderBadge = () => (
<ConditionallyRender
condition={value === Role.Member}
show={<Badge>{capitalize(value)}</Badge>}
elseShow={
<Badge color="success" icon={<StyledPopupStar />}>
{capitalize(value)}
</Badge>
}
/>
);
return (
<TextCell>
<ConditionallyRender
condition={Boolean(onChange)}
show={
<Select
data-testid={UG_USERS_TABLE_ROLE_ID}
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>
);
};

View File

@ -8,6 +8,7 @@ import { GroupCardActions } from './GroupCardActions/GroupCardActions';
import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
import { useState } from 'react';
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
import { EditGroupUsers } from 'component/admin/groups/Group/EditGroupUsers/EditGroupUsers';
const StyledLink = styled(Link)(({ theme }) => ({
textDecoration: 'none',
@ -82,6 +83,7 @@ interface IGroupCardProps {
}
export const GroupCard = ({ group }: IGroupCardProps) => {
const [editUsersOpen, setEditUsersOpen] = useState(false);
const [removeOpen, setRemoveOpen] = useState(false);
const navigate = useNavigate();
return (
@ -93,6 +95,7 @@ export const GroupCard = ({ group }: IGroupCardProps) => {
<StyledHeaderActions>
<GroupCardActions
groupId={group.id}
onEditUsers={() => setEditUsersOpen(true)}
onRemove={() => setRemoveOpen(true)}
/>
</StyledHeaderActions>
@ -147,6 +150,11 @@ export const GroupCard = ({ group }: IGroupCardProps) => {
</StyledBottomRow>
</StyledGroupCard>
</StyledLink>
<EditGroupUsers
open={editUsersOpen}
setOpen={setEditUsersOpen}
group={group}
/>
<RemoveGroup
open={removeOpen}
setOpen={setRemoveOpen}

View File

@ -10,7 +10,7 @@ import {
Tooltip,
Typography,
} from '@mui/material';
import { Delete, Edit, MoreVert } from '@mui/icons-material';
import { Delete, Edit, GroupRounded, MoreVert } from '@mui/icons-material';
import { Link } from 'react-router-dom';
const StyledActions = styled('div')(({ theme }) => ({
@ -25,11 +25,13 @@ const StyledPopover = styled(Popover)(({ theme }) => ({
interface IGroupCardActions {
groupId: number;
onEditUsers: () => void;
onRemove: () => void;
}
export const GroupCardActions: FC<IGroupCardActions> = ({
groupId,
onEditUsers,
onRemove,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -86,6 +88,21 @@ export const GroupCardActions: FC<IGroupCardActions> = ({
<Typography variant="body2">Edit group</Typography>
</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
onEditUsers();
handleClose();
}}
>
<ListItemIcon>
<GroupRounded />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
Edit group users
</Typography>
</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
onRemove();

View File

@ -1,6 +1,6 @@
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IGroupUser, Role } from 'interfaces/group';
import { IGroupUser } from 'interfaces/group';
import React, { useMemo, useState } from 'react';
import { GroupPopover } from './GroupPopover/GroupPopover';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
@ -26,7 +26,10 @@ interface IGroupCardAvatarsProps {
export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
const shownUsers = useMemo(
() => users.sort((a, b) => (a.role < b.role ? 1 : -1)).slice(0, 9),
() =>
users
.sort((a, b) => b?.joinedAt!.getTime() - a?.joinedAt!.getTime())
.slice(0, 9),
[users]
);
@ -49,7 +52,6 @@ export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
<StyledAvatar
key={user.id}
user={user}
star={user.role === Role.Owner}
onMouseEnter={event => {
onPopoverOpen(event);
setPopupUser(user);

View File

@ -1,8 +1,5 @@
import { Popover, styled } from '@mui/material';
import { IGroupUser, Role } from 'interfaces/group';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Badge } from 'component/common/Badge/Badge';
import { StarRounded } from '@mui/icons-material';
import { IGroupUser } from 'interfaces/group';
const StyledPopover = styled(Popover)(({ theme }) => ({
pointerEvents: 'none',
@ -11,10 +8,6 @@ const StyledPopover = styled(Popover)(({ theme }) => ({
},
}));
const StyledPopupStar = styled(StarRounded)(({ theme }) => ({
color: theme.palette.warning.main,
}));
const StyledName = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
@ -50,16 +43,6 @@ export const GroupPopover = ({
horizontal: 'left',
}}
>
<ConditionallyRender
condition={user?.role === Role.Member}
show={<Badge>{user?.role}</Badge>}
elseShow={
<Badge color="success" icon={<StyledPopupStar />}>
{user?.role}
</Badge>
}
/>
<StyledName>{user?.name || user?.username}</StyledName>
<div>{user?.email}</div>
</StyledPopover>

View File

@ -121,7 +121,7 @@ export const GroupsList: VFC = () => {
</Grid>
</SearchHighlightProvider>
<ConditionallyRender
condition={data.length === 0}
condition={!loading && data.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import useQueryParams from 'hooks/useQueryParams';
import { IGroupUser, Role } from 'interfaces/group';
import { IGroupUser } from 'interfaces/group';
export const useGroupForm = (
initialName = '',
@ -14,30 +14,12 @@ export const useGroupForm = (
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 }) => ({
users: users.map(({ id }) => ({
user: { id },
role: role || Role.Member,
})),
};
};
@ -56,5 +38,6 @@ export const useGroupForm = (
getGroupPayload,
clearErrors,
errors,
setErrors,
};
};

View File

@ -1,15 +1,7 @@
import {
Avatar,
AvatarProps,
Badge,
styled,
SxProps,
Theme,
} from '@mui/material';
import { Avatar, AvatarProps, styled, SxProps, Theme } from '@mui/material';
import { IUser } from 'interfaces/user';
import { FC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StarRounded } from '@mui/icons-material';
const StyledAvatar = styled(Avatar)(({ theme }) => ({
width: theme.spacing(3.5),
@ -21,17 +13,8 @@ const StyledAvatar = styled(Avatar)(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
const StyledStar = styled(StarRounded)(({ theme }) => ({
color: theme.palette.warning.main,
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadiusExtraLarge,
fontSize: theme.fontSizes.smallBody,
marginLeft: theme.spacing(-1),
}));
interface IUserAvatarProps extends AvatarProps {
user?: IUser;
star?: boolean;
src?: string;
title?: string;
onMouseEnter?: (event: any) => void;
@ -42,7 +25,6 @@ interface IUserAvatarProps extends AvatarProps {
export const UserAvatar: FC<IUserAvatarProps> = ({
user,
star,
src,
title,
onMouseEnter,
@ -74,7 +56,7 @@ export const UserAvatar: FC<IUserAvatarProps> = ({
}
}
const avatar = (
return (
<StyledAvatar
className={className}
sx={sx}
@ -93,23 +75,4 @@ export const UserAvatar: FC<IUserAvatarProps> = ({
/>
</StyledAvatar>
);
return (
<ConditionallyRender
condition={Boolean(star)}
show={
<Badge
overlap="circular"
anchorOrigin={{
vertical: 'top',
horizontal: 'left',
}}
badgeContent={<StyledStar />}
>
{avatar}
</Badge>
}
elseShow={avatar}
/>
);
};

View File

@ -170,7 +170,7 @@ export const ProjectAccessAssign = ({
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!role) return;
if (!isValid) return;
try {
if (!edit) {
@ -263,6 +263,8 @@ export const ProjectAccessAssign = ({
</li>
);
const isValid = selectedOptions.length > 0 && role;
return (
<SidebarModal
open
@ -287,6 +289,7 @@ export const ProjectAccessAssign = ({
<Autocomplete
size="small"
multiple
openOnFocus
limitTags={10}
disableCloseOnSelect
disabled={edit}
@ -339,6 +342,7 @@ export const ProjectAccessAssign = ({
<StyledAutocompleteWrapper>
<Autocomplete
size="small"
openOnFocus
value={role}
onChange={(_, newValue) => setRole(newValue)}
options={roles}
@ -360,6 +364,7 @@ export const ProjectAccessAssign = ({
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
>
Assign {entityType}
</Button>

View File

@ -1,13 +1,18 @@
import { styled } from '@mui/material';
import { useMemo, VFC } from 'react';
import { styled, SxProps, Theme } from '@mui/material';
import { ForwardedRef, forwardRef, useMemo, VFC } from 'react';
import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledDescription = styled('div')(({ theme }) => ({
const StyledDescription = styled('div', {
shouldForwardProp: prop =>
prop !== 'roleId' && prop !== 'popover' && prop !== 'sx',
})<IProjectRoleDescriptionStyleProps>(({ theme, popover }) => ({
width: '100%',
maxWidth: theme.spacing(50),
padding: theme.spacing(3),
backgroundColor: theme.palette.neutral.light,
backgroundColor: popover
? theme.palette.background.paper
: theme.palette.neutral.light,
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
borderRadius: theme.shape.borderRadiusMedium,
@ -32,74 +37,60 @@ const StyledDescriptionSubHeader = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1),
}));
interface IProjectRoleDescriptionProps {
interface IProjectRoleDescriptionStyleProps {
popover?: boolean;
className?: string;
sx?: SxProps<Theme>;
}
interface IProjectRoleDescriptionProps
extends IProjectRoleDescriptionStyleProps {
roleId: number;
}
export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = ({
roleId,
}) => {
const { role } = useProjectRole(roleId.toString());
export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
forwardRef(
(
{ roleId, className, sx, ...props }: IProjectRoleDescriptionProps,
ref: ForwardedRef<HTMLDivElement>
) => {
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]);
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]);
const projectPermissions = useMemo(() => {
return role.permissions?.filter(
(permission: any) => !permission.environment
);
}, [role]);
const projectPermissions = useMemo(() => {
return role.permissions?.filter(
(permission: any) => !permission.environment
);
}, [role]);
return (
<StyledDescription>
<ConditionallyRender
condition={Boolean(projectPermissions?.length)}
show={
<>
<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>
return (
<StyledDescription
className={className}
sx={sx}
{...props}
ref={ref}
>
<ConditionallyRender
condition={Boolean(projectPermissions?.length)}
show={
<>
<StyledDescriptionHeader>
Project permissions
</StyledDescriptionHeader>
<StyledDescriptionBlock>
{role.permissions
.filter(
?.filter(
(permission: any) =>
permission.environment ===
environment
!permission.environment
)
.map(
(permission: any) =>
@ -110,11 +101,45 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = ({
<p key={permission}>{permission}</p>
))}
</StyledDescriptionBlock>
</div>
))}
</>
}
/>
</StyledDescription>
</>
}
/>
<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>
);
}
);
};

View File

@ -0,0 +1,70 @@
import { Link, Popover, styled } from '@mui/material';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import React from 'react';
import { VFC } from 'react';
import { ProjectRoleDescription } from 'component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription';
const StyledLink = styled(Link)(() => ({
textDecoration: 'none',
'&:hover, &:focus': {
textDecoration: 'underline',
},
}));
const StyledPopover = styled(Popover)(() => ({
pointerEvents: 'none',
}));
interface IProjectAccessRoleCellProps {
roleId: number;
value?: string;
emptyText?: string;
}
export const ProjectAccessRoleCell: VFC<IProjectAccessRoleCellProps> = ({
roleId,
value,
emptyText,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const onPopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const onPopoverClose = () => {
setAnchorEl(null);
};
if (!value) return <TextCell>{emptyText}</TextCell>;
return (
<>
<TextCell>
<StyledLink
onMouseEnter={event => {
onPopoverOpen(event);
}}
onMouseLeave={onPopoverClose}
>
{value}
</StyledLink>
</TextCell>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onPopoverClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<ProjectRoleDescription roleId={roleId} popover />
</StyledPopover>
</>
);
};

View File

@ -43,6 +43,7 @@ import { ProjectAccessCreate } from 'component/project/ProjectAccess/ProjectAcce
import { ProjectAccessEditUser } from 'component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser';
import { ProjectAccessEditGroup } from 'component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup';
import useHiddenColumns from 'hooks/useHiddenColumns';
import { ProjectAccessRoleCell } from './ProjectAccessRoleCell/ProjectAccessRoleCell';
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
@ -69,6 +70,14 @@ const StyledGroupAvatar = styled(UserAvatar)(({ theme }) => ({
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
}));
const hiddenColumnsSmall = [
'imageUrl',
'username',
'role',
'added',
'lastLogin',
];
export const ProjectAccessTable: VFC = () => {
const projectId = useRequiredPathParam('projectId');
@ -149,6 +158,12 @@ export const ProjectAccessTable: VFC = () => {
accessor: (row: IProjectAccess) =>
access?.roles.find(({ id }) => id === row.entity.roleId)
?.name,
Cell: ({ value, row: { original: row } }: any) => (
<ProjectAccessRoleCell
roleId={row.entity.roleId}
value={value}
/>
),
minWidth: 120,
filterName: 'role',
},
@ -281,11 +296,7 @@ export const ProjectAccessTable: VFC = () => {
useFlexLayout
);
useHiddenColumns(
setHiddenColumns,
['imageUrl', 'username', 'role', 'added', 'lastLogin'],
isSmallScreen
);
useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
useEffect(() => {
const tableState: PageQueryType = {};
@ -335,6 +346,7 @@ export const ProjectAccessTable: VFC = () => {
}
setRemoveOpen(false);
};
return (
<PageContent
header={

View File

@ -1,6 +1,5 @@
import { Delete, Edit } from '@mui/icons-material';
import { 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';
@ -47,7 +46,7 @@ const StyledTitle = styled('div')(({ theme }) => ({
},
}));
const defaultSort: SortingRule<string> = { id: 'role', desc: true };
const defaultSort: SortingRule<string> = { id: 'joinedAt' };
const columns = [
{
@ -77,13 +76,6 @@ const columns = [
minWidth: 100,
searchable: true,
},
{
Header: 'User type',
accessor: 'role',
Cell: GroupUserRoleCell,
maxWidth: 150,
filterName: 'type',
},
{
id: 'joined',
Header: 'Joined',
@ -104,6 +96,8 @@ const columns = [
},
];
const hiddenColumnsSmall = ['imageUrl', 'name', 'joined', 'lastLogin'];
interface IProjectGroupViewProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -156,11 +150,7 @@ export const ProjectGroupView: VFC<IProjectGroupViewProps> = ({
useFlexLayout
);
useHiddenColumns(
setHiddenColumns,
['imageUrl', 'name', 'joined', 'lastLogin'],
useMediaQuery(theme.breakpoints.down('md'))
);
useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
return (
<SidebarModal

View File

@ -15,7 +15,6 @@ export const mapGroupUsers = (users: any[]) =>
users.map(user => ({
...user.user,
joinedAt: new Date(user.joinedAt),
role: user.role,
}));
export const useGroup = (groupId: number): IUseGroupOutput => {

View File

@ -1,10 +1,5 @@
import { IUser } from './user';
export enum Role {
Owner = 'Owner',
Member = 'Member',
}
export interface IGroup {
id: number;
name: string;
@ -17,7 +12,6 @@ export interface IGroup {
}
export interface IGroupUser extends IUser {
role: Role;
joinedAt?: Date;
}
@ -25,5 +19,4 @@ export interface IGroupUserModel {
user: {
id: number;
};
role: Role;
}

View File

@ -15,15 +15,12 @@ export const UG_NAME_ID = 'UG_NAME_ID';
export const UG_DESC_ID = 'UG_DESC_ID';
export const UG_USERS_ID = 'UG_USERS_ID';
export const UG_USERS_ADD_ID = 'UG_USERS_ADD_ID';
export const UG_USERS_TABLE_ROLE_ID = 'UG_USERS_TABLE_ROLE_ID';
export const UG_CREATE_BTN_ID = 'UG_CREATE_BTN_ID';
export const UG_SAVE_BTN_ID = 'UG_SAVE_BTN_ID';
export const UG_EDIT_BTN_ID = 'UG_EDIT_BTN_ID';
export const UG_DELETE_BTN_ID = 'UG_DELETE_BTN_ID';
export const UG_ADD_USER_BTN_ID = 'UG_ADD_USER_BTN_ID';
export const UG_EDIT_USER_BTN_ID = 'UG_EDIT_USER_BTN_ID';
export const UG_EDIT_USERS_BTN_ID = 'UG_EDIT_USERS_BTN_ID';
export const UG_REMOVE_USER_BTN_ID = 'UG_REMOVE_USER_BTN_ID';
export const UG_USERS_ROLE_ID = 'UG_USERS_ROLE_ID';
/* SEGMENT */
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';