1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-29 01:15:48 +02:00
unleash.unleash/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx
Thomas Heartman bbe389d19e
chore: re-order project settings menu (#8626)
This PR re-orders the project settings menu according to the new design.
It also renames pages as specified. It does *not* add the new
"applications and sdks" link because we don't have that page yet.

I have not put this change behind a flag, because it's not a new feature
and doesn't really change the user experience. However, I'd be happy to
flag it if you think that's better.


![image](https://github.com/user-attachments/assets/42dc3348-e873-49b2-9bd7-8c3b3f4a2532)


The header for the user access tab has also been updated:

![image](https://github.com/user-attachments/assets/7c61da17-2b28-4f39-a9d4-83d9ec1903cd)
2024-11-01 09:29:25 +00:00

530 lines
20 KiB
TypeScript

import { useEffect, useMemo, useState, type VFC } from 'react';
import {
type SortingRule,
useFlexLayout,
useSortBy,
useTable,
} from 'react-table';
import { VirtualizedTable, TablePlaceholder } from 'component/common/Table';
import { styled, useMediaQuery, useTheme } from '@mui/material';
import Add from '@mui/icons-material/Add';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import { sortTypes } from 'utils/sortTypes';
import useProjectAccess, {
ENTITY_TYPE,
type IProjectAccess,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import {
PROJECT_USER_ACCESS_WRITE,
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 { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import {
Link,
Route,
Routes,
useNavigate,
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 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 type { IUser } from 'interfaces/user';
import type { IGroup } from 'interfaces/group';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { ProjectAccessCreate } from 'component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate';
import { ProjectAccessEditUser } from 'component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser';
import { ProjectAccessEditGroup } from 'component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup';
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
import {
PA_ASSIGN_BUTTON_ID,
PA_EDIT_BUTTON_ID,
PA_REMOVE_BUTTON_ID,
} from 'utils/testIds';
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
>;
const defaultSort: SortingRule<string> = { id: 'added', desc: true };
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
'ProjectAccess:v1',
defaultSort,
);
const StyledUserAvatars = styled('div')(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
flexWrap: 'wrap',
marginLeft: theme.spacing(1),
}));
const StyledEmptyAvatar = styled(UserAvatar)(({ theme }) => ({
marginRight: theme.spacing(-3.5),
}));
const StyledGroupAvatar = styled(UserAvatar)(({ theme }) => ({
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
}));
const hiddenColumnsSmall = ['imageUrl', 'role', 'added', 'lastLogin'];
const hiddenColumnsMedium = ['lastLogin', 'added'];
export const ProjectAccessTable: VFC = () => {
const projectId = useRequiredPathParam('projectId');
const { uiConfig } = useUiConfig();
const { flags } = uiConfig;
const entityType = flags.UG ? 'user / group' : 'user';
const navigate = useNavigate();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { setToastData } = useToast();
const { access, refetchProjectAccess } = useProjectAccess(projectId);
const { removeUserAccess, removeGroupAccess } = useProjectApi();
const [removeOpen, setRemoveOpen] = useState(false);
const [groupOpen, setGroupOpen] = useState(false);
const [selectedRow, setSelectedRow] = useState<IProjectAccess>();
const roleText = (roles: number[]): string =>
roles.length > 1
? `${roles.length} roles`
: access?.roles.find(({ id }) => id === roles[0])?.name || '';
const columns = useMemo(
() => [
{
Header: 'Avatar',
accessor: 'imageUrl',
Cell: ({ row: { original: row } }: any) => (
<StyledUserAvatars>
<ConditionallyRender
condition={row.type === ENTITY_TYPE.GROUP}
show={<StyledEmptyAvatar />}
/>
<StyledGroupAvatar user={row.entity}>
{row.entity.users?.length}
</StyledGroupAvatar>
</StyledUserAvatars>
),
maxWidth: 85,
disableSortBy: true,
},
{
id: 'name',
Header: '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}
subtitle={
row.entity?.email || row.entity?.username
}
/>
}
/>
),
minWidth: 100,
searchable: true,
},
{
id: 'role',
Header: 'Role',
accessor: (row: IProjectAccess) => roleText(row.entity.roles),
Cell: ({
value,
row: { original: row },
}: {
row: { original: IProjectAccess };
value: string;
}) => <RoleCell value={value} roles={row.entity.roles} />,
maxWidth: 125,
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' />
),
maxWidth: 130,
},
{
id: 'lastLogin',
Header: 'Last login',
accessor: (row: IProjectAccess) => {
if (row.type !== ENTITY_TYPE.GROUP) {
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' />
),
maxWidth: 130,
},
{
id: 'actions',
Header: 'Actions',
disableSortBy: true,
align: 'center',
maxWidth: 150,
Cell: ({
row: { original: row },
}: {
row: { original: IProjectAccess };
}) => (
<ActionCell>
<PermissionIconButton
data-testid={PA_EDIT_BUTTON_ID}
component={Link}
permission={[
UPDATE_PROJECT,
PROJECT_USER_ACCESS_WRITE,
]}
projectId={projectId}
to={`edit/${
row.type === ENTITY_TYPE.GROUP
? 'group'
: 'user'
}/${row.entity.id}`}
disabled={access?.rows.length === 1}
tooltipProps={{
title:
access?.rows.length === 1
? 'Cannot edit access. A project must have at least one owner'
: 'Edit access',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-testid={PA_REMOVE_BUTTON_ID}
permission={[
UPDATE_PROJECT,
PROJECT_USER_ACCESS_WRITE,
]}
projectId={projectId}
onClick={() => {
setSelectedRow(row);
setRemoveOpen(true);
}}
disabled={access?.rows.length === 1}
tooltipProps={{
title:
access?.rows.length === 1
? 'Cannot remove access. A project must have at least one owner'
: 'Remove access',
}}
>
<Delete />
</PermissionIconButton>
</ActionCell>
),
},
// Always hidden -- for search
{
accessor: (row: IProjectAccess) =>
row.type !== ENTITY_TYPE.GROUP
? (row.entity as IUser)?.username || ''
: '',
Header: 'Username',
searchable: true,
},
// Always hidden -- for search
{
accessor: (row: IProjectAccess) =>
row.type !== ENTITY_TYPE.GROUP
? (row.entity as IUser)?.email || ''
: '',
Header: 'Email',
searchable: true,
},
],
[access, projectId],
);
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: ['Username', 'Email'],
globalFilter: searchParams.get('search') || '',
}));
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
const { data, getSearchText, getSearchContext } = useSearch(
columns,
searchValue,
access?.rows ?? [],
);
const {
headerGroups,
rows,
prepareRow,
setHiddenColumns,
state: { sortBy },
} = useTable(
{
columns: columns as any[],
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy,
useFlexLayout,
);
useConditionallyHiddenColumns(
[
{
condition: isSmallScreen,
columns: hiddenColumnsSmall,
},
{
condition: isMediumScreen,
columns: hiddenColumnsMedium,
},
],
setHiddenColumns,
columns,
);
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 } = userOrGroup.entity;
let name = userOrGroup.entity.name;
if (userOrGroup.type !== ENTITY_TYPE.GROUP) {
const user = userOrGroup.entity as IUser;
name = name || user.email || user.username || '';
}
try {
if (userOrGroup.type !== ENTITY_TYPE.GROUP) {
await removeUserAccess(projectId, id);
} else {
await removeGroupAccess(projectId, 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);
};
return (
<PageContent
header={
<PageHeader
secondary
title={`User 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 />
</>
}
/>
<ResponsiveButton
onClick={() => navigate('create')}
maxWidth='700px'
Icon={Add}
permission={[
UPDATE_PROJECT,
PROJECT_USER_ACCESS_WRITE,
]}
projectId={projectId}
data-testid={PA_ASSIGN_BUTTON_ID}
>
Assign {entityType}
</ResponsiveButton>
</>
}
>
<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 &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No access available. Get started by assigning a{' '}
{entityType}.
</TablePlaceholder>
}
/>
}
/>
<Routes>
<Route path='create' element={<ProjectAccessCreate />} />
<Route
path='edit/group/:groupId'
element={<ProjectAccessEditGroup />}
/>
<Route
path='edit/user/:userId'
element={<ProjectAccessEditUser />}
/>
</Routes>
<Dialogue
open={removeOpen}
onClick={() => removeAccess(selectedRow)}
onClose={() => {
setRemoveOpen(false);
}}
title={`Really remove ${entityType} from this project?`}
/>
<ProjectGroupView
open={groupOpen}
setOpen={setGroupOpen}
group={selectedRow?.entity as IGroup}
projectId={projectId}
subtitle={
<>
{selectedRow && selectedRow.entity.roles.length > 1
? 'Roles:'
: 'Role:'}
<RoleCell
value={roleText(selectedRow?.entity.roles || [])}
roles={selectedRow?.entity.roles || []}
/>
</>
}
onEdit={() => {
navigate(`edit/group/${selectedRow?.entity.id}`);
}}
onRemove={() => {
setGroupOpen(false);
setRemoveOpen(true);
}}
/>
</PageContent>
);
};