1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

Feat: Contexts and Project access tables (#1028)

* feat: new contexts table

* improve context list actions

* refactor: disabled icon colors

* fix: update snapshots

* fix: icons

* fix: context fields typo

* feat: new project access table

* fix: header cell styles
This commit is contained in:
Tymoteusz Czech 2022-05-26 10:37:33 +02:00 committed by GitHub
parent 7093b49962
commit 9ac962da45
12 changed files with 267 additions and 197 deletions

View File

@ -37,6 +37,7 @@ const PermissionIconButton = ({
children,
environmentId,
tooltipProps,
disabled,
...rest
}: IButtonProps | ILinkProps) => {
const { hasAccess } = useContext(AccessContext);
@ -57,7 +58,11 @@ const PermissionIconButton = ({
arrow
>
<div>
<IconButton {...rest} disabled={!access} size="large">
<IconButton
{...rest}
disabled={!access || disabled}
size="large"
>
{children}
</IconButton>
</div>

View File

@ -111,7 +111,8 @@ export const CellSortable: FC<ICellSortableProps> = ({
<button
className={classnames(
isSorted && styles.sortedButton,
styles.sortButton
styles.sortButton,
alignClass
)}
onClick={onSortClick}
>

View File

@ -1,4 +1,4 @@
import { useMemo, useState, VFC } from 'react';
import { useContext, useMemo, useState, VFC } from 'react';
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
import {
Table,
@ -12,6 +12,7 @@ import {
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { UPDATE_CONTEXT_FIELD } from 'component/providers/AccessProvider/permissions';
import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi';
@ -24,6 +25,7 @@ import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ContextActionsCell } from './ContextActionsCell/ContextActionsCell';
import { Adjust } from '@mui/icons-material';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import AccessContext from 'contexts/AccessContext';
const ContextList: VFC = () => {
const [showDelDialogue, setShowDelDialogue] = useState(false);
@ -31,6 +33,7 @@ const ContextList: VFC = () => {
const { context, refetchUnleashContext, loading } = useUnleashContext();
const { removeContext } = useContextsApi();
const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const data = useMemo(() => {
if (loading) {
@ -66,8 +69,8 @@ const ContextList: VFC = () => {
}: any) => (
<LinkCell
title={name}
to={`/context/edit/${name}`}
subtitle={description}
data-loading
/>
),
sortType: 'alphanumeric',

View File

@ -6,10 +6,11 @@ export const useStyles = makeStyles()(theme => ({
},
divider: {
height: '1px',
width: '106.65%',
marginLeft: '-2rem',
backgroundColor: '#efefef',
marginTop: '2rem',
position: 'relative',
left: 0,
right: 0,
backgroundColor: theme.palette.divider,
margin: theme.spacing(4, -4, 3),
},
inputLabel: { backgroundColor: '#fff' },
roleName: {

View File

@ -1,12 +1,10 @@
/* eslint-disable react/jsx-no-target-blank */
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Alert, SelectChangeEvent } from '@mui/material';
import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser';
import { PageContent } from 'component/common/PageContent/PageContent';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useStyles } from './ProjectAccess.styles';
import usePagination from 'hooks/usePagination';
import PaginateUI from 'component/common/PaginateUI/PaginateUI';
import useToast from 'hooks/useToast';
import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue';
import useProjectAccess, {
@ -14,8 +12,8 @@ import useProjectAccess, {
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ProjectAccessList } from './ProjectAccessList/ProjectAccessList';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectAccessTable } from './ProjectAccessTable/ProjectAccessTable';
export const ProjectAccess = () => {
const projectId = useRequiredPathParam('projectId');
@ -23,28 +21,11 @@ export const ProjectAccess = () => {
const { access, refetchProjectAccess } = useProjectAccess(projectId);
const { setToastData } = useToast();
const { isOss } = useUiConfig();
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(access.users, 10);
const { removeUserFromRole, changeUserRole } = useProjectApi();
const [showDelDialogue, setShowDelDialogue] = useState(false);
const [user, setUser] = useState<IProjectAccessUser | undefined>();
if (isOss()) {
return (
<PageContent header={<PageHeader title="Project Access" />}>
<Alert severity="error">
Controlling access to projects requires a paid version of
Unleash. Check out{' '}
<a href="https://www.getunleash.io" target="_blank">
getunleash.io
</a>{' '}
to find out more.
</Alert>
</PageContent>
);
}
const handleRoleChange =
const handleRoleChange = useCallback(
(userId: number) => async (evt: SelectChangeEvent) => {
const roleId = Number(evt.target.value);
try {
@ -61,7 +42,24 @@ export const ProjectAccess = () => {
title: err.message || 'Server problems when adding users.',
});
}
};
},
[changeUserRole, projectId, refetchProjectAccess, setToastData]
);
if (isOss()) {
return (
<PageContent header={<PageHeader title="Project Access" />}>
<Alert severity="error">
Controlling access to projects requires a paid version of
Unleash. Check out{' '}
<a href="https://www.getunleash.io" target="_blank">
getunleash.io
</a>{' '}
to find out more.
</Alert>
</PageContent>
);
}
const handleRemoveAccess = (user: IProjectAccessUser) => {
setUser(user);
@ -96,21 +94,13 @@ export const ProjectAccess = () => {
<ProjectAccessAddUser roles={access?.roles} />
<div className={styles.divider}></div>
<ProjectAccessList
<ProjectAccessTable
access={access}
handleRoleChange={handleRoleChange}
handleRemoveAccess={handleRemoveAccess}
page={page}
access={access}
>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
style={{ bottom: '-21px' }}
/>
</ProjectAccessList>
projectId={projectId}
/>
<ConfirmDialogue
open={showDelDialogue}

View File

@ -1,57 +0,0 @@
import { List, SelectChangeEvent } from '@mui/material';
import {
IProjectAccessOutput,
IProjectAccessUser,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { ProjectAccessListItem } from './ProjectAccessListItem/ProjectAccessListItem';
import React from 'react';
interface IProjectAccesListProps {
page: IProjectAccessUser[];
handleRoleChange: (userId: number) => (evt: SelectChangeEvent) => void;
handleRemoveAccess: (user: IProjectAccessUser) => void;
access: IProjectAccessOutput;
}
export const ProjectAccessList: React.FC<IProjectAccesListProps> = ({
page,
access,
handleRoleChange,
handleRemoveAccess,
children,
}) => {
const sortUsers = (users: IProjectAccessUser[]): IProjectAccessUser[] => {
/* This should be done on the API side in the future,
we should expect the list of users to come in the
same order each time and not jump around on the screen*/
return users.sort(
(userA: IProjectAccessUser, userB: IProjectAccessUser) => {
if (!userA.name) {
return -1;
} else if (!userB.name) {
return 1;
}
return userA.name.localeCompare(userB.name);
}
);
};
return (
<List>
{sortUsers(page).map(user => {
return (
<ProjectAccessListItem
key={user.id}
user={user}
access={access}
handleRoleChange={handleRoleChange}
handleRemoveAccess={handleRemoveAccess}
/>
);
})}
{children}
</List>
);
};

View File

@ -1,11 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(() => ({
iconButton: {
marginLeft: '0.5rem',
},
actionList: {
display: 'flex',
alignItems: 'center',
},
}));

View File

@ -1,78 +0,0 @@
import {
Avatar,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
MenuItem,
SelectChangeEvent,
} from '@mui/material';
import { Delete } from '@mui/icons-material';
import {
IProjectAccessOutput,
IProjectAccessUser,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { ProjectRoleSelect } from 'component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect';
import { useStyles } from '../ProjectAccessListItem/ProjectAccessListItem.styles';
import React from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
interface IProjectAccessListItemProps {
user: IProjectAccessUser;
handleRoleChange: (userId: number) => (evt: SelectChangeEvent) => void;
handleRemoveAccess: (user: IProjectAccessUser) => void;
access: IProjectAccessOutput;
}
export const ProjectAccessListItem = ({
user,
access,
handleRoleChange,
handleRemoveAccess,
}: IProjectAccessListItemProps) => {
const projectId = useRequiredPathParam('projectId');
const { classes: styles } = useStyles();
const labelId = `checkbox-list-secondary-label-${user.id}`;
return (
<ListItem key={user.id} button>
<ListItemAvatar>
<Avatar alt="Gravatar" src={user.imageUrl} />
</ListItemAvatar>
<ListItemText
id={labelId}
primary={user.name}
secondary={user.email || user.username}
/>
<ListItemSecondaryAction className={styles.actionList}>
<ProjectRoleSelect
labelId={`role-${user.id}-select-label`}
id={`role-${user.id}-select`}
key={user.id}
placeholder="Choose role"
onChange={handleRoleChange(user.id)}
roles={access.roles}
value={user.roleId || -1}
>
<MenuItem value="" disabled>
Choose role
</MenuItem>
</ProjectRoleSelect>
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
className={styles.iconButton}
edge="end"
onClick={() => handleRemoveAccess(user)}
disabled={access.users.length === 1}
tooltipProps={{ title: 'Remove access' }}
>
<Delete />
</PermissionIconButton>
</ListItemSecondaryAction>
</ListItem>
);
};

View File

@ -0,0 +1,164 @@
import { useMemo, useState, VFC } from 'react';
import { useSortBy, useTable } from 'react-table';
import {
Table,
TableBody,
TableRow,
TableCell,
SortableTableHeader,
} from 'component/common/Table';
import { Avatar, Box, SelectChangeEvent } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { sortTypes } from 'utils/sortTypes';
import {
IProjectAccessOutput,
IProjectAccessUser,
} 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';
interface IProjectAccessTableProps {
access: IProjectAccessOutput;
projectId: string;
handleRoleChange: (
userId: number
) => (event: SelectChangeEvent) => Promise<void>;
handleRemoveAccess: (user: IProjectAccessUser) => void;
}
export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
access,
projectId,
handleRoleChange,
handleRemoveAccess,
}) => {
const [initialState] = useState({});
const data = access.users;
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' }}
/>
),
align: 'center',
},
{
Header: 'Name',
accessor: 'name',
},
{
Header: 'Username',
id: 'username',
accessor: 'email',
Cell: ({ row: { original: user } }: any) => (
<TextCell>{user.email || user.username}</TextCell>
),
},
{
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)}
/>
),
},
{
Header: 'Actions',
id: 'actions',
disableSortBy: true,
align: 'center',
width: 80,
Cell: ({ row: { original: user } }: any) => (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
}}
>
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
edge="end"
onClick={() => handleRemoveAccess(user)}
disabled={access.users.length === 1}
tooltipProps={{
title:
access.users.length === 1
? 'Cannot remove access. A project must have at least one owner'
: 'Remove access',
}}
>
<Delete />
</PermissionIconButton>
</Box>
),
},
],
[
access.roles,
access.users.length,
handleRemoveAccess,
handleRoleChange,
projectId,
]
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy
);
return (
<Table {...getTableProps()}>
{/* @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>
);
};

View File

@ -0,0 +1,7 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
cell: {
padding: theme.spacing(1, 1.5),
},
}));

View File

@ -0,0 +1,38 @@
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>
);
};

View File

@ -9,10 +9,11 @@ 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;
labelId?: string;
id: string;
placeholder?: string;
onChange: (evt: SelectChangeEvent) => void;
@ -31,12 +32,18 @@ export const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({
const { classes: styles } = useStyles();
return (
<FormControl variant="outlined" size="small">
<InputLabel
style={{ backgroundColor: '#fff' }}
id="add-user-select-role-label"
>
Role
</InputLabel>
<ConditionallyRender
condition={Boolean(labelId)}
show={() => (
<InputLabel
style={{ backgroundColor: '#fff' }}
id={labelId}
>
Role
</InputLabel>
)}
/>
<Select
labelId={labelId}
id={id}