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

feat: upgrade users table (#1040)

* feat: upgrade users table

* fix misc ui/ux bugs

* refactor: address PR comments

* fix: searching by `undefined`

* fix: searching for undefined on invoices, table placeholder centering

* refactor: abstract users list actions into new component

* refactor: move styled components to top of files
This commit is contained in:
Nuno Góis 2022-05-31 07:59:09 +01:00 committed by GitHub
parent 63a30695ce
commit 570e9f88be
41 changed files with 621 additions and 1352 deletions

View File

@ -4,6 +4,14 @@ import { ReportProblemOutlined, Check } from '@mui/icons-material';
import { styled } from '@mui/material';
import { IReportTableRow } from 'component/Reporting/ReportTable/ReportTable';
const StyledText = styled('span')(({ theme }) => ({
display: 'flex',
gap: '1ch',
alignItems: 'center',
textAlign: 'right',
'& svg': { color: theme.palette.inactiveIcon },
}));
interface IReportStatusCellProps {
row: {
original: IReportTableRow;
@ -33,11 +41,3 @@ export const ReportStatusCell: VFC<IReportStatusCellProps> = ({
</TextCell>
);
};
const StyledText = styled('span')(({ theme }) => ({
display: 'flex',
gap: '1ch',
alignItems: 'center',
textAlign: 'right',
'& svg': { color: theme.palette.inactiveIcon },
}));

View File

@ -3,6 +3,7 @@ import {
TableSearch,
SortableTableHeader,
TableCell,
TablePlaceholder,
} from 'component/common/Table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
@ -24,6 +25,7 @@ import {
import { formatExpiredAt } from 'component/Reporting/ReportExpiredCell/formatExpiredAt';
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
import theme from 'themes/theme';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IReportTableProps {
projectId: string;
@ -52,7 +54,7 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
const initialState = useMemo(
() => ({
hiddenColumns: ['description'],
hiddenColumns: [],
sortBy: [{ id: 'name' }],
}),
[]
@ -83,9 +85,7 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
useEffect(() => {
if (isSmallScreen) {
setHiddenColumns(['createdAt', 'expiredAt', 'description']);
} else {
setHiddenColumns(['description']);
setHiddenColumns(['createdAt', 'expiredAt']);
}
}, [setHiddenColumns, isSmallScreen]);
@ -101,6 +101,8 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
/>
);
console.log(rows);
return (
<PageContent header={header}>
<SearchHighlightProvider value={globalFilter}>
@ -122,6 +124,27 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
</TableBody>
</Table>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No features found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No features available. Get started by adding a
new feature toggle.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};
@ -149,12 +172,14 @@ const COLUMNS = [
sortType: 'date',
align: 'center',
Cell: FeatureSeenCell,
disableGlobalFilter: true,
},
{
Header: 'Type',
accessor: 'type',
align: 'center',
Cell: FeatureTypeCell,
disableGlobalFilter: true,
},
{
Header: 'Feature toggle name',
@ -168,11 +193,13 @@ const COLUMNS = [
accessor: 'createdAt',
sortType: 'date',
Cell: DateCell,
disableGlobalFilter: true,
},
{
Header: 'Expired',
accessor: 'expiredAt',
Cell: ReportExpiredCell,
disableGlobalFilter: true,
},
{
Header: 'Status',
@ -185,8 +212,6 @@ const COLUMNS = [
accessor: 'stale',
sortType: 'boolean',
Cell: FeatureStaleCell,
},
{
accessor: 'description',
disableGlobalFilter: true,
},
];

View File

@ -4,6 +4,30 @@ import { BillingInformationButton } from './BillingInformationButton/BillingInfo
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
const StyledInfoBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(4),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.secondaryContainer,
}));
const StyledTitle = styled(Typography)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(2.5)} 0`,
borderColor: theme.palette.dividerAlternative,
}));
interface IBillingInformationProps {
instanceStatus: IInstanceStatus;
}
@ -43,28 +67,3 @@ export const BillingInformation: FC<IBillingInformationProps> = ({
</Grid>
);
};
const StyledInfoBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(4),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.secondaryContainer,
}));
const StyledTitle = styled(Typography)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(2.5)} 0`,
borderColor: theme.palette.dividerAlternative,
}));

View File

@ -16,6 +16,11 @@ Billing information:%0D%0A%0D%0A
-- Thank you for signing up. We will upgrade your trial as quick as possible and we will grant you access to the application again. --`;
const StyledButton = styled(Button)(({ theme }) => ({
width: '100%',
marginBottom: theme.spacing(1.5),
}));
interface IBillingInformationButtonProps {
update?: boolean;
}
@ -29,8 +34,3 @@ export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({
</StyledButton>
);
};
const StyledButton = styled(Button)(({ theme }) => ({
width: '100%',
marginBottom: theme.spacing(1.5),
}));

View File

@ -2,7 +2,7 @@ import { FC } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import CheckIcon from '@mui/icons-material/Check';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
IInstanceStatus,
@ -15,6 +15,66 @@ import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
import { STRIPE } from 'component/admin/billing/flags';
const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.elevated,
[theme.breakpoints.up('md')]: {
padding: theme.spacing(6.5),
},
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
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,
color: theme.palette.primary.main,
fontWeight: 800,
}));
const StyledTrialSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1.5),
fontWeight: theme.fontWeight.bold,
}));
const StyledPriceSpan = styled('span')(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.fontSizes.mainHeader,
fontWeight: theme.fontWeight.bold,
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
marginBottom: theme.spacing(3),
marginTop: theme.spacing(-1.5),
[theme.breakpoints.up('md')]: {
marginTop: theme.spacing(-4.5),
},
}));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));
interface IBillingPlanProps {
instanceStatus: IInstanceStatus;
}
@ -194,63 +254,3 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
</Grid>
);
};
const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.elevated,
[theme.breakpoints.up('md')]: {
padding: theme.spacing(6.5),
},
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
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,
color: theme.palette.primary.main,
fontWeight: 800,
}));
const StyledTrialSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1.5),
fontWeight: theme.fontWeight.bold,
}));
const StyledPriceSpan = styled('span')(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.fontSizes.mainHeader,
fontWeight: theme.fontWeight.bold,
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
marginBottom: theme.spacing(3),
marginTop: theme.spacing(-1.5),
[theme.breakpoints.up('md')]: {
marginTop: theme.spacing(-4.5),
},
}));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));

View File

@ -1,11 +1,11 @@
import { styled } from '@mui/material';
import { FC } from 'react';
export const GridColLink: FC = ({ children }) => {
return <StyledSpan>({children})</StyledSpan>;
};
const StyledSpan = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
marginLeft: theme.spacing(1),
}));
export const GridColLink: FC = ({ children }) => {
return <StyledSpan>({children})</StyledSpan>;
};

View File

@ -16,6 +16,11 @@ import { Box, IconButton, styled, Typography } from '@mui/material';
import FileDownload from '@mui/icons-material/FileDownload';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
const StyledTitle = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(6),
marginBottom: theme.spacing(2.5),
fontSize: theme.fontSizes.mainHeader,
}));
interface IBillingHistoryProps {
data: Record<string, any>[];
isLoading?: boolean;
@ -29,18 +34,19 @@ const columns = [
{
Header: 'Status',
accessor: 'status',
disableGlobalFilter: true,
},
{
Header: 'Due date',
accessor: 'dueDate',
Cell: DateCell,
sortType: 'date',
disableGlobalFilter: true,
},
{
Header: 'Download',
accessor: 'invoicePDF',
align: 'center',
disableSortBy: true,
Cell: ({ value }: { value: string }) => (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
@ -52,6 +58,8 @@ const columns = [
</Box>
),
width: 100,
disableGlobalFilter: true,
disableSortBy: true,
},
];
@ -110,9 +118,3 @@ export const BillingHistory: VFC<IBillingHistoryProps> = ({
</PageContent>
);
};
const StyledTitle = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(6),
marginBottom: theme.spacing(2.5),
fontSize: theme.fontSizes.mainHeader,
}));

View File

@ -73,6 +73,7 @@ const ProjectRoleList = () => {
icon={<SupervisedUserCircle color="disabled" />}
/>
),
disableGlobalFilter: true,
},
{
Header: 'Project role',
@ -133,6 +134,7 @@ const ProjectRoleList = () => {
</Box>
),
width: 100,
disableGlobalFilter: true,
disableSortBy: true,
},
],

View File

@ -1,18 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
userListBody: {
padding: theme.spacing(4),
paddingBottom: '4rem',
minHeight: '50vh',
position: 'relative',
},
tableActions: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
'&>button': {
flexShrink: 0,
},
},
}));

View File

@ -10,7 +10,7 @@ import {
} from '@mui/material';
import { useStyles } from './UserForm.styles';
import React from 'react';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { EDIT } from 'constants/misc';
import useUiBootstrap from 'hooks/api/getters/useUiBootstrap/useUiBootstrap';

View File

@ -1,72 +1,22 @@
import { useContext, useState } from 'react';
import { useContext } from 'react';
import UsersList from './UsersList/UsersList';
import AdminMenu from '../menu/AdminMenu';
import { PageContent } from 'component/common/PageContent/PageContent';
import AccessContext from 'contexts/AccessContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { Button } from '@mui/material';
import { TableActions } from 'component/common/Table/TableActions/TableActions';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { useStyles } from './UserAdmin.styles';
import { useNavigate } from 'react-router-dom';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
const UsersAdmin = () => {
const [search, setSearch] = useState('');
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const { classes: styles } = useStyles();
return (
<div>
<AdminMenu />
<PageContent
bodyClass={styles.userListBody}
header={
<PageHeader
title="Users"
actions={
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<div className={styles.tableActions}>
<TableActions
initialSearchValue={search}
onSearch={search =>
setSearch(search)
}
/>
<Button
sx={{
ml: 1.5,
}}
variant="contained"
color="primary"
onClick={() =>
navigate('/admin/create-user')
}
>
New user
</Button>
</div>
}
elseShow={
<small>
PS! Only admins can add/remove users.
</small>
}
/>
}
/>
}
>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UsersList search={search} />}
elseShow={<AdminAlert />}
/>
</PageContent>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UsersList />}
elseShow={<AdminAlert />}
/>
</div>
);
};

View File

@ -1,70 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
tableRow: {
'& > td': {
padding: '4px 16px',
borderColor: theme.palette.grey[300],
},
'&:hover': {
backgroundColor: theme.palette.grey[100],
},
},
tableCellHeader: {
'& > th': {
backgroundColor: theme.palette.grey[200],
fontWeight: 'normal',
border: 0,
'&:first-of-type': {
'&, & > button': {
borderTopLeftRadius: theme.spacing(1),
borderBottomLeftRadius: theme.spacing(1),
},
},
'&:last-of-type': {
'&, & > button': {
borderTopRightRadius: theme.spacing(1),
borderBottomRightRadius: theme.spacing(1),
},
},
},
},
errorMessage: {
textAlign: 'center',
marginTop: '20vh',
},
leftTableCell: {
textAlign: 'left',
},
shrinkTableCell: {
whiteSpace: 'nowrap',
width: '0.1%',
},
avatar: {
width: '32px',
height: '32px',
margin: 'auto',
},
firstColumnSM: {
[theme.breakpoints.down('md')]: {
borderTopLeftRadius: '8px',
borderBottomLeftRadius: '8px',
},
},
firstColumnXS: {
[theme.breakpoints.down('sm')]: {
borderTopLeftRadius: '8px',
borderBottomLeftRadius: '8px',
},
},
hideSM: {
[theme.breakpoints.down('md')]: {
display: 'none',
},
},
hideXS: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
},
}));

View File

@ -1,173 +0,0 @@
import {
Avatar,
IconButton,
TableCell,
TableRow,
Tooltip,
Typography,
} from '@mui/material';
import classnames from 'classnames';
import { Delete, Edit, Lock, MonetizationOn } from '@mui/icons-material';
import { SyntheticEvent, useContext } from 'react';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AccessContext from 'contexts/AccessContext';
import { IUser } from 'interfaces/user';
import { useNavigate } from 'react-router-dom';
import { ILocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMD } from 'utils/formatDate';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useStyles } from './UserListItem.styles';
import TimeAgo from 'react-timeago';
interface IUserListItemProps {
user: IUser;
renderRole: (roleId: number) => string;
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
locationSettings: ILocationSettings;
search: string;
isBillingUsers?: boolean;
}
const UserListItem = ({
user,
renderRole,
openDelDialog,
openPwDialog,
locationSettings,
search,
isBillingUsers,
}: IUserListItemProps) => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const { classes: styles } = useStyles();
const renderTimeAgo = (date: string) => (
<Tooltip
title={`Last login: ${formatDateYMD(
date,
locationSettings.locale
)}`}
arrow
>
<Typography noWrap variant="body2" data-loading>
<TimeAgo date={new Date(date)} live={false} title={''} />
</Typography>
</Tooltip>
);
return (
<TableRow key={user.id} className={styles.tableRow}>
<ConditionallyRender
condition={Boolean(isBillingUsers)}
show={
<TableCell align="center" className={styles.hideSM}>
<ConditionallyRender
condition={Boolean(user.paid)}
show={
<Tooltip title="Paid user" arrow>
<MonetizationOn
sx={theme => ({
color: theme.palette.primary.light,
fontSize: '1.75rem',
})}
/>
</Tooltip>
}
elseShow={<span data-loading>Free</span>}
/>
</TableCell>
}
/>
<TableCell className={styles.hideSM}>
<span data-loading>
{formatDateYMD(user.createdAt, locationSettings.locale)}
</span>
</TableCell>
<TableCell align="center" className={styles.hideXS}>
<Avatar
data-loading
alt="Gravatar"
src={user.imageUrl}
className={styles.avatar}
title={`${user.name || user.email || user.username} (id: ${
user.id
})`}
/>
</TableCell>
<TableCell className={styles.leftTableCell}>
<Typography variant="body2" data-loading>
<Highlighter search={search}>{user.name}</Highlighter>
</Typography>
</TableCell>
<TableCell
className={classnames(styles.leftTableCell, styles.hideSM)}
>
<Typography variant="body2" data-loading>
<Highlighter search={search}>
{user.username || user.email}
</Highlighter>
</Typography>
</TableCell>
<TableCell className={styles.hideXS}>
<Typography variant="body2" data-loading>
{renderRole(user.rootRole)}
</Typography>
</TableCell>
<TableCell className={styles.hideXS}>
<ConditionallyRender
condition={Boolean(user.seenAt)}
show={() => renderTimeAgo(user.seenAt!)}
elseShow={
<Typography noWrap variant="body2" data-loading>
Never logged
</Typography>
}
/>
</TableCell>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<TableCell
align="center"
className={styles.shrinkTableCell}
>
<Tooltip title="Edit user" arrow>
<IconButton
data-loading
onClick={() =>
navigate(`/admin/users/${user.id}/edit`)
}
size="large"
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title="Change password" arrow>
<IconButton
data-loading
onClick={openPwDialog(user)}
size="large"
>
<Lock />
</IconButton>
</Tooltip>
<Tooltip title="Remove user" arrow>
<IconButton
data-loading
onClick={openDelDialog(user)}
size="large"
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
}
elseShow={<TableCell />}
/>
</TableRow>
);
};
export default UserListItem;

View File

@ -0,0 +1,29 @@
import { MonetizationOn } from '@mui/icons-material';
import { styled, Tooltip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
const StyledMonetizationOn = styled(MonetizationOn)(({ theme }) => ({
color: theme.palette.primary.light,
fontSize: '1.75rem',
}));
interface IUserTypeCellProps {
value: boolean;
}
export const UserTypeCell = ({ value }: IUserTypeCellProps) => {
return (
<TextCell>
<ConditionallyRender
condition={value}
show={
<Tooltip title="Paid user" arrow>
<StyledMonetizationOn />
</Tooltip>
}
elseShow="Free"
/>
</TextCell>
);
};

View File

@ -0,0 +1,57 @@
import { Delete, Edit, Lock } from '@mui/icons-material';
import { Box, styled } from '@mui/material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { VFC } from 'react';
const StyledBox = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
}));
interface IUsersActionsCellProps {
onEdit: (event: React.SyntheticEvent) => void;
onChangePassword: (event: React.SyntheticEvent) => void;
onDelete: (event: React.SyntheticEvent) => void;
}
export const UsersActionsCell: VFC<IUsersActionsCellProps> = ({
onEdit,
onChangePassword,
onDelete,
}) => {
return (
<StyledBox>
<PermissionIconButton
data-loading
onClick={onEdit}
permission={ADMIN}
tooltipProps={{
title: 'Edit user',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={onChangePassword}
permission={ADMIN}
tooltipProps={{
title: 'Change password',
}}
>
<Lock />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={onDelete}
permission={ADMIN}
tooltipProps={{
title: 'Remove user',
}}
>
<Delete />
</PermissionIconButton>
</StyledBox>
);
};

View File

@ -1,49 +1,52 @@
/* eslint-disable no-alert */
import React, { useContext, useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@mui/material';
import classnames from 'classnames';
TablePlaceholder,
TableSearch,
} from 'component/common/Table';
import ChangePassword from './ChangePassword/ChangePassword';
import DeleteUser from './DeleteUser/DeleteUser';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
import UserListItem from './UserListItem/UserListItem';
import loadingData from './loadingData';
import useLoading from 'hooks/useLoading';
import usePagination from 'hooks/usePagination';
import PaginateUI from 'component/common/PaginateUI/PaginateUI';
import { IUser } from 'interfaces/user';
import IRole from 'interfaces/role';
import useToast from 'hooks/useToast';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useUsersFilter } from 'hooks/useUsersFilter';
import { useUsersSort } from 'hooks/useUsersSort';
import { TableCellSortable } from 'component/common/Table/TableCellSortable/TableCellSortable';
import { useStyles } from './UserListItem/UserListItem.styles';
import { useUsersPlan } from 'hooks/useUsersPlan';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Avatar, Button, styled, useMediaQuery } from '@mui/material';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { UserTypeCell } from './UserTypeCell/UserTypeCell';
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { useNavigate } from 'react-router-dom';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import theme from 'themes/theme';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { UsersActionsCell } from './UsersActionsCell/UsersActionsCell';
interface IUsersListProps {
search: string;
}
const StyledAvatar = styled(Avatar)(({ theme }) => ({
width: theme.spacing(4),
height: theme.spacing(4),
margin: 'auto',
}));
const UsersList = ({ search }: IUsersListProps) => {
const { classes: styles } = useStyles();
const UsersList = () => {
const navigate = useNavigate();
const { users, roles, refetch, loading } = useUsers();
const { setToastData, setToastApiError } = useToast();
const { removeUser, changePassword, userLoading, userApiErrors } =
useAdminUsersApi();
const { hasAccess } = useContext(AccessContext);
const { locationSettings } = useLocationSettings();
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
open: false,
});
@ -52,28 +55,10 @@ const UsersList = ({ search }: IUsersListProps) => {
const [emailSent, setEmailSent] = useState(false);
const [inviteLink, setInviteLink] = useState('');
const [delUser, setDelUser] = useState<IUser>();
const ref = useLoading(loading);
const { planUsers, isBillingUsers } = useUsersPlan(users);
const { filtered, setFilter } = useUsersFilter(planUsers);
const { sorted, sort, setSort } = useUsersSort(filtered);
const filterUsersByQueryPage = (user: IUser) => {
const fieldsToSearch = [
user.name ?? '',
user.username ?? user.email ?? '',
];
return fieldsToSearch.some(field => {
return field.toLowerCase().includes(search.toLowerCase());
});
};
// Filter users and reset pagination page when search is triggered
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
usePagination(sorted, 50, filterUsersByQueryPage);
useEffect(() => {
setFilter(filter => ({ ...filter, query: search }));
}, [search, setFilter]);
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const closeDelDialog = () => {
setDelDialog(false);
@ -116,136 +101,208 @@ const UsersList = ({ search }: IUsersListProps) => {
setInviteLink('');
};
const renderRole = (roleId: number) => {
const role = roles.find((r: IRole) => r.id === roleId);
return role ? role.name : '';
};
const columns = useMemo(
() => [
{
id: 'type',
Header: 'Type',
accessor: 'paid',
Cell: ({ row: { original: user } }: any) => (
<UserTypeCell value={isBillingUsers && user.paid} />
),
disableGlobalFilter: true,
sortType: 'boolean',
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
disableGlobalFilter: true,
sortType: 'date',
},
{
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>
),
disableGlobalFilter: true,
disableSortBy: true,
},
{
Header: 'Name',
accessor: (row: any) => row.name || '',
width: '40%',
Cell: HighlightCell,
},
{
id: 'username',
Header: 'Username',
accessor: (row: any) => row.username || row.email,
width: '40%',
Cell: HighlightCell,
},
{
id: 'role',
Header: 'Role',
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
disableGlobalFilter: true,
},
{
id: 'last-login',
Header: 'Last login',
accessor: (row: any) => row.seenAt || '',
Cell: ({ row: { original: user } }: any) => (
<TimeAgoCell value={user.seenAt} emptyText="Never logged" />
),
disableGlobalFilter: true,
sortType: 'date',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: user } }: any) => (
<UsersActionsCell
onEdit={() => {
navigate(`/admin/users/${user.id}/edit`);
}}
onChangePassword={openPwDialog(user)}
onDelete={openDelDialog(user)}
/>
),
width: 100,
disableGlobalFilter: true,
disableSortBy: true,
},
],
[roles, navigate, isBillingUsers]
);
const renderUsers = () => {
if (loading) {
return loadingData.map(user => (
<UserListItem
key={user.id}
user={user}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
locationSettings={locationSettings}
renderRole={renderRole}
search={search}
/>
));
const initialState = useMemo(() => {
return {
sortBy: [{ id: 'createdAt', desc: false }],
hiddenColumns: isBillingUsers ? [] : ['type'],
};
}, [isBillingUsers]);
const data = isBillingUsers ? planUsers : users;
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
setHiddenColumns,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
defaultColumn: {
Cell: TextCell,
},
},
useGlobalFilter,
useSortBy
);
useEffect(() => {
const hiddenColumns = [];
if (!isBillingUsers || isSmallScreen) {
hiddenColumns.push('type');
}
return page.map(user => {
return (
<UserListItem
key={user.id}
user={user}
openPwDialog={openPwDialog}
openDelDialog={openDelDialog}
locationSettings={locationSettings}
renderRole={renderRole}
search={search}
isBillingUsers={isBillingUsers}
/>
);
});
};
if (!users) return null;
if (isSmallScreen) {
hiddenColumns.push(...['createdAt', 'username']);
}
if (isExtraSmallScreen) {
hiddenColumns.push(...['imageUrl', 'role', 'last-login']);
}
setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, isExtraSmallScreen, isSmallScreen, isBillingUsers]);
return (
<div ref={ref}>
<Table>
<TableHead>
<TableRow className={styles.tableCellHeader}>
<ConditionallyRender
condition={isBillingUsers}
show={
<TableCell
align="center"
className={classnames(styles.hideSM)}
>
Type
</TableCell>
}
/>
<TableCellSortable
className={classnames(
styles.hideSM,
styles.shrinkTableCell
)}
name="created"
sort={sort}
setSort={setSort}
>
Created
</TableCellSortable>
<TableCell
align="center"
className={classnames(
styles.hideXS,
styles.firstColumnSM
)}
>
Avatar
</TableCell>
<TableCellSortable
name="name"
sort={sort}
className={classnames(styles.firstColumnXS)}
setSort={setSort}
>
Name
</TableCellSortable>
<TableCell className={styles.hideSM}>
Username
</TableCell>
<TableCellSortable
className={classnames(
styles.hideXS,
styles.shrinkTableCell
)}
name="role"
sort={sort}
setSort={setSort}
>
Role
</TableCellSortable>
<TableCellSortable
className={classnames(
styles.hideXS,
styles.shrinkTableCell
)}
name="last-seen"
sort={sort}
setSort={setSort}
>
Last login
</TableCellSortable>
<TableCell align="center">
{hasAccess(ADMIN) ? 'Actions' : ''}
</TableCell>
</TableRow>
</TableHead>
<TableBody>{renderUsers()}</TableBody>
<PaginateUI
pages={pages}
pageIndex={pageIndex}
setPageIndex={setPageIndex}
nextPage={nextPage}
prevPage={prevPage}
<PageContent
isLoading={loading}
header={
<PageHeader
title="Users"
actions={
<>
<TableSearch
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
<PageHeader.Divider />
<Button
variant="contained"
color="primary"
onClick={() => navigate('/admin/create-user')}
>
New user
</Button>
</>
}
/>
</Table>
}
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<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>
</SearchHighlightProvider>
<ConditionallyRender
condition={!pages.length && search.length > 0}
condition={rows.length === 0}
show={
<p className={styles.errorMessage}>
There are no results for "{search}"
</p>
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No users found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No users available. Get started by adding one.
</TablePlaceholder>
}
/>
}
/>
<br />
<ConfirmUserAdded
open={showConfirm}
@ -278,7 +335,7 @@ const UsersList = ({ search }: IUsersListProps) => {
/>
}
/>
</div>
</PageContent>
);
};

View File

@ -1,77 +0,0 @@
import { IUser } from 'interfaces/user';
const loadingData: IUser[] = [
{
id: 1,
username: 'admin',
email: 'some-email@email.com',
name: 'admin',
permissions: ['ADMIN'],
imageUrl:
'https://gravatar.com/avatar/21232f297a57a5a743894a0e4a801fc3?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T12:09:55.923Z',
rootRole: 1,
inviteLink: '',
isAPI: false,
},
{
id: 16,
name: 'test',
email: 'test@test.no',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/879fdbb54e4a6cdba456fcb11abe5971?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T15:54:02.765Z',
rootRole: 2,
inviteLink: '',
isAPI: false,
},
{
id: 3,
name: 'Testesen',
email: 'test@test.com',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/6c15d63f08137733ec0828cd0a3a5dc4?size=42&default=retro',
seenAt: '2021-04-21T14:34:31.515Z',
loginAttempts: 0,
createdAt: '2021-04-21T12:33:17.712Z',
rootRole: 1,
inviteLink: '',
isAPI: false,
},
{
id: 4,
name: 'test',
email: 'test@test.io',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/879fdbb54e4a6cdba456fcb11abe5971?size=42&default=retro',
seenAt: null,
loginAttempts: 0,
createdAt: '2021-04-21T15:54:02.765Z',
rootRole: 2,
inviteLink: '',
isAPI: false,
},
{
id: 5,
name: 'Testesen',
email: 'test@test.uk',
permissions: [],
imageUrl:
'https://gravatar.com/avatar/6c15d63f08137733ec0828cd0a3a5dc4?size=42&default=retro',
seenAt: '2021-04-21T14:34:31.515Z',
loginAttempts: 0,
createdAt: '2021-04-21T12:33:17.712Z',
rootRole: 1,
inviteLink: '',
isAPI: false,
},
];
export default loadingData;

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import useUiBootstrap from 'hooks/api/getters/useUiBootstrap/useUiBootstrap';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
const useCreateUserForm = (
initialName = '',

View File

@ -1,6 +1,11 @@
import { Grid, styled, SxProps, Theme } from '@mui/material';
import { FC } from 'react';
const StyledGrid = styled(Grid)(({ theme }) => ({
flexWrap: 'nowrap',
gap: theme.spacing(1),
}));
export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
return (
<StyledGrid
@ -14,8 +19,3 @@ export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
</StyledGrid>
);
};
const StyledGrid = styled(Grid)(({ theme }) => ({
flexWrap: 'nowrap',
gap: theme.spacing(1),
}));

View File

@ -9,6 +9,48 @@ import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { calculateTrialDaysRemaining } from 'utils/billing';
const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette.warning.border,
background: theme.palette.warning.light,
color: theme.palette.warning.dark,
}));
const StyledInfoBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette.info.border,
background: theme.palette.info.light,
color: theme.palette.info.dark,
}));
const StyledButton = styled(Button)(({ theme }) => ({
whiteSpace: 'nowrap',
minWidth: '8rem',
marginLeft: theme.spacing(2),
}));
const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({
color: theme.palette.warning.main,
}));
const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
color: theme.palette.info.main,
}));
interface IInstanceStatusBarProps {
instanceStatus: IInstanceStatus;
}
@ -85,45 +127,3 @@ const UpgradeButton = () => {
</StyledButton>
);
};
const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette.warning.border,
background: theme.palette.warning.light,
color: theme.palette.warning.dark,
}));
const StyledInfoBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette.info.border,
background: theme.palette.info.light,
color: theme.palette.info.dark,
}));
const StyledButton = styled(Button)(({ theme }) => ({
whiteSpace: 'nowrap',
minWidth: '8rem',
marginLeft: theme.spacing(2),
}));
const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({
color: theme.palette.warning.main,
}));
const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
color: theme.palette.info.main,
}));

View File

@ -1,6 +1,17 @@
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;
@ -21,14 +32,3 @@ export const StatusBadge = ({
</StyledStatusBadge>
);
};
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,
}));

View File

@ -1,73 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
tableActions: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
'&>button': {
padding: theme.spacing(1),
flexShrink: 0,
},
paddingRight: theme.spacing(1),
},
fieldWidth: {
width: '45px',
'& .search-icon': {
marginRight: 0,
},
'& .input-container, .clear-container': {
width: 0,
},
'& input::placeholder': {
color: 'transparent',
transition: 'color 0.6s',
},
'& input:focus-within::placeholder': {
color: theme.palette.text.primary,
},
},
fieldWidthEnter: {
width: '250px',
transition: 'width 0.6s',
'& .search-icon': {
marginRight: '8px',
},
'& .input-container': {
width: '100%',
transition: 'width 0.6s',
},
'& .clear-container': {
width: '30px',
transition: 'width 0.6s',
},
'& .search-container': {
borderColor: theme.palette.grey[300],
},
},
fieldWidthLeave: {
width: '45px',
transition: 'width 0.6s',
'& .search-icon': {
marginRight: 0,
transition: 'margin-right 0.6s',
},
'& .input-container, .clear-container': {
width: 0,
transition: 'width 0.6s',
},
'& .search-container': {
borderColor: 'transparent',
},
},
verticalSeparator: {
height: '100%',
backgroundColor: theme.palette.grey[500],
width: '1px',
display: 'inline-block',
marginLeft: theme.spacing(2),
marginRight: theme.spacing(4),
padding: '10px 0',
verticalAlign: 'middle',
},
}));

View File

@ -1,86 +0,0 @@
import { FC, useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import { Search } from '@mui/icons-material';
import { useAsyncDebounce } from 'react-table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
import { TableSearchField } from './TableSearchField/TableSearchField';
import { useStyles } from './TableActions.styles';
interface ITableActionsProps {
initialSearchValue?: string;
onSearch?: (value: string) => void;
searchTip?: string;
isSeparated?: boolean;
}
/**
* @deprecated Use <PageHeader actions={} /> instead
*/
export const TableActions: FC<ITableActionsProps> = ({
initialSearchValue: search,
onSearch = () => {},
searchTip = 'Search',
children,
isSeparated,
}) => {
const [searchExpanded, setSearchExpanded] = useState(Boolean(search));
const [searchInputState, setSearchInputState] = useState(search);
const [animating, setAnimating] = useState(false);
const debouncedOnSearch = useAsyncDebounce(onSearch, 200);
const { classes: styles } = useStyles();
const onBlur = (clear = false) => {
if (!searchInputState || clear) {
setSearchExpanded(false);
}
};
const onSearchChange = (value: string) => {
debouncedOnSearch(value);
setSearchInputState(value);
};
return (
<div className={styles.tableActions}>
<ConditionallyRender
condition={Boolean(onSearch)}
show={
<>
<AnimateOnMount
mounted={searchExpanded}
start={styles.fieldWidth}
enter={styles.fieldWidthEnter}
leave={styles.fieldWidthLeave}
onStart={() => setAnimating(true)}
onEnd={() => setAnimating(false)}
>
<TableSearchField
value={searchInputState!}
onChange={onSearchChange}
placeholder={`${searchTip}...`}
onBlur={onBlur}
/>
</AnimateOnMount>
<ConditionallyRender
condition={!searchExpanded && !animating}
show={
<Tooltip title={searchTip} arrow>
<IconButton
aria-label={searchTip}
onClick={() => setSearchExpanded(true)}
size="large"
>
<Search />
</IconButton>
</Tooltip>
}
/>
</>
}
/>
{children}
</div>
);
};

View File

@ -1,42 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '1rem',
},
search: {
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: theme.shape.borderRadiusExtraLarge,
padding: '3px 5px 3px 12px',
maxWidth: '450px',
[theme.breakpoints.down('sm')]: {
width: '100%',
},
'&.search-container:focus-within': {
borderColor: theme.palette.primary.light,
boxShadow: theme.boxShadows.main,
},
},
searchIcon: {
marginRight: 8,
color: theme.palette.inactiveIcon,
},
clearContainer: {
width: '30px',
'& > button': {
padding: '7px',
},
},
clearIcon: {
fontSize: '18px',
},
inputRoot: {
width: '100%',
},
}));

View File

@ -1,74 +0,0 @@
import { IconButton, InputBase, Tooltip } from '@mui/material';
import { Search, Close } from '@mui/icons-material';
import classnames from 'classnames';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from './TableSearchField.styles';
interface ITableSearchFieldProps {
value?: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
onBlur?: (clear?: boolean) => void;
}
export const TableSearchField = ({
value = '',
onChange,
className,
placeholder,
onBlur,
}: ITableSearchFieldProps) => {
const { classes: styles } = useStyles();
const placeholderText = placeholder ?? 'Search...';
return (
<div className={styles.container}>
<div
className={classnames(
styles.search,
className,
'search-container'
)}
>
<Search
className={classnames(styles.searchIcon, 'search-icon')}
/>
<InputBase
autoFocus
placeholder={placeholderText}
classes={{
root: classnames(styles.inputRoot, 'input-container'),
}}
inputProps={{ 'aria-label': placeholderText }}
value={value}
onChange={e => onChange(e.target.value)}
onBlur={() => onBlur?.()}
/>
<div
className={classnames(
styles.clearContainer,
'clear-container'
)}
>
<ConditionallyRender
condition={Boolean(value)}
show={
<Tooltip title="Clear search query" arrow>
<IconButton
size="small"
onClick={() => {
onChange('');
onBlur?.(true);
}}
>
<Close className={styles.clearIcon} />
</IconButton>
</Tooltip>
}
/>
</div>
</div>
</div>
);
};

View File

@ -1,40 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
tableCellHeaderSortable: {
padding: 0,
cursor: 'pointer',
'& > svg': {
fontSize: 18,
verticalAlign: 'middle',
color: theme.palette.grey[700],
marginLeft: '4px',
},
'&.sorted': {
fontWeight: 'bold',
'& > svg': {
color: theme.palette.grey[900],
},
},
},
sortButton: {
all: 'unset',
padding: theme.spacing(2),
width: '100%',
'&:focus-visible, &:active': {
outline: 'revert',
},
display: 'flex',
alignItems: 'center',
'&:hover': {
backgroundColor: theme.palette.grey[400],
'& > svg': {
color: theme.palette.grey[900],
},
},
},
icon: {
marginLeft: theme.spacing(0.5),
fontSize: 18,
},
}));

View File

@ -1,96 +0,0 @@
import React, { ReactNode, useContext } from 'react';
import { TableCell } from '@mui/material';
import classnames from 'classnames';
import {
UnfoldMoreOutlined,
KeyboardArrowDown,
KeyboardArrowUp,
} from '@mui/icons-material';
import { IUsersSort, UsersSortType } from 'hooks/useUsersSort';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from 'component/common/Table/TableCellSortable/TableCellSortable.styles';
import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext';
// Add others as needed, e.g. UsersSortType | FeaturesSortType
type SortType = UsersSortType;
type Sort = IUsersSort;
interface ITableCellSortableProps {
className?: string;
name: SortType;
sort: Sort;
setSort: React.Dispatch<React.SetStateAction<Sort>>;
children: ReactNode;
}
/**
* @deprecated No longer in use. See `SortableTableHeader`. Remove when Users table is refactored.
*/
export const TableCellSortable = ({
className,
name,
sort,
setSort,
children,
}: ITableCellSortableProps) => {
const { setAnnouncement } = useContext(AnnouncerContext);
const { classes: styles } = useStyles();
const ariaSort =
sort.type === name
? sort.desc
? 'descending'
: 'ascending'
: undefined;
const onSortClick = () => {
setSort(prev => ({
desc: !Boolean(prev.desc),
type: name,
}));
setAnnouncement(
`Sorted table by ${name}, ${sort.desc ? 'ascending' : 'descending'}`
);
};
return (
<TableCell
aria-sort={ariaSort}
className={classnames(
className,
styles.tableCellHeaderSortable,
sort.type === name && 'sorted'
)}
>
<button className={styles.sortButton} onClick={onSortClick}>
{children}
<ConditionallyRender
condition={sort.type === name}
show={
<ConditionallyRender
condition={Boolean(sort.desc)}
show={
<KeyboardArrowDown
className={styles.icon}
fontSize="inherit"
/>
}
elseShow={
<KeyboardArrowUp
className={styles.icon}
fontSize="inherit"
/>
}
/>
}
elseShow={
<UnfoldMoreOutlined
className={styles.icon}
fontSize="inherit"
/>
}
/>
</button>
</TableCell>
);
};

View File

@ -6,7 +6,7 @@ export const useStyles = makeStyles()(theme => ({
padding: '0.8rem',
textAlign: 'center',
display: 'flex',
justifyContent: 'space-between',
justifyContent: 'center',
alignItems: 'center',
marginTop: theme.spacing(2),
},

View File

@ -21,7 +21,7 @@ export const FeatureTypeCell: VFC<IFeatureTypeProps> = ({ value }) => {
return (
<div className={styles.container}>
<Tooltip arrow placement="right" title={title} describeChild>
<Tooltip arrow title={title} describeChild>
<IconComponent data-loading className={styles.icon} />
</Tooltip>
</div>

View File

@ -0,0 +1,38 @@
import { Tooltip, Typography } from '@mui/material';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { VFC } from 'react';
import { formatDateYMD } from 'utils/formatDate';
import { TextCell } from '../TextCell/TextCell';
import TimeAgo from 'react-timeago';
interface ITimeAgoCellProps {
value?: string | number | Date;
live?: boolean;
emptyText?: string;
}
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
value,
live = false,
emptyText,
}) => {
const { locationSettings } = useLocationSettings();
if (!value) return <TextCell>{emptyText}</TextCell>;
return (
<TextCell>
<Tooltip
title={`Last login: ${formatDateYMD(
value,
locationSettings.locale
)}`}
arrow
>
<Typography noWrap variant="body2" data-loading>
<TimeAgo date={new Date(value)} live={live} title={''} />
</Typography>
</Tooltip>
</TextCell>
);
};

View File

@ -54,6 +54,7 @@ const ContextList: VFC = () => {
{
id: 'Icon',
Cell: () => <IconCell icon={<Adjust color="disabled" />} />,
disableGlobalFilter: true,
},
{
Header: 'Name',
@ -90,6 +91,7 @@ const ContextList: VFC = () => {
/>
),
width: 150,
disableGlobalFilter: true,
disableSortBy: true,
},
{
@ -98,6 +100,7 @@ const ContextList: VFC = () => {
},
{
accessor: 'sortOrder',
disableGlobalFilter: true,
sortType: 'number',
},
],

View File

@ -7,6 +7,7 @@ import {
TableSearch,
SortableTableHeader,
Table,
TablePlaceholder,
} from 'component/common/Table';
import { useCallback } from 'react';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
@ -22,6 +23,7 @@ import useEnvironmentApi, {
createSortOrderPayload,
} from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export const EnvironmentTable = () => {
const { changeSortOrder } = useEnvironmentApi();
@ -97,6 +99,27 @@ export const EnvironmentTable = () => {
</TableBody>
</Table>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No environments found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No environments available. Get started by adding
one.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};
@ -106,6 +129,7 @@ const COLUMNS = [
id: 'Icon',
canSort: false,
Cell: () => <IconCell icon={<CloudCircle color="disabled" />} />,
disableGlobalFilter: true,
},
{
Header: 'Name',
@ -124,5 +148,6 @@ const COLUMNS = [
Cell: ({ row: { original } }: any) => (
<EnvironmentActionCell environment={original} />
),
disableGlobalFilter: true,
},
];

View File

@ -16,7 +16,7 @@ const FeatureStatus = ({ type }: IFeatureTypeProps) => {
const title = `"${typeName || type}" toggle`;
return (
<Tooltip arrow placement="right" title={title}>
<Tooltip arrow title={title}>
<IconComponent data-loading className={styles.icon} />
</Tooltip>
);

View File

@ -95,7 +95,7 @@ export const useStyles = makeStyles()(theme => ({
boxShadow: 'none',
textAlign: 'left',
},
accordionBody: { padding: '0' },
accordionBody: { padding: '0', wordBreak: 'break-all' },
accordionActions: {
padding: '0',
justifyContent: 'flex-start',

View File

@ -95,6 +95,7 @@ export const StrategiesList = () => {
<Extension color="disabled" />
</Box>
),
disableGlobalFilter: true,
},
{
Header: 'Name',
@ -147,6 +148,7 @@ export const StrategiesList = () => {
</Box>
),
width: 150,
disableGlobalFilter: true,
disableSortBy: true,
},
{
@ -155,6 +157,7 @@ export const StrategiesList = () => {
},
{
accessor: 'sortOrder',
disableGlobalFilter: true,
sortType: 'number',
},
],

View File

@ -71,6 +71,7 @@ export const TagTypeList = () => {
<Label color="disabled" />
</Box>
),
disableGlobalFilter: true,
},
{
Header: 'Name',
@ -125,6 +126,7 @@ export const TagTypeList = () => {
</Box>
),
width: 150,
disableGlobalFilter: true,
disableSortBy: true,
},
{

View File

@ -1,36 +1,28 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
const useUsers = (options: SWRConfiguration = {}) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/user-admin`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Users'))
.then(res => res.json());
};
export const useUsers = () => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/user-admin`),
fetcher
);
const { data, error } = useSWR(`api/admin/user-admin`, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(`api/admin/user-admin`);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
users: data?.users || [],
roles: data?.rootRoles || [],
error,
loading,
refetch,
};
return useMemo(
() => ({
users: data?.users ?? [],
roles: data?.rootRoles ?? [],
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate]
);
};
export default useUsers;
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Users'))
.then(res => res.json());
};

View File

@ -1,56 +0,0 @@
import { IUser } from 'interfaces/user';
import React, { useMemo } from 'react';
import { basePath } from 'utils/formatPath';
import { createGlobalStateHook } from 'hooks/useGlobalState';
export interface IUsersFilter {
query?: string;
}
export interface IUsersFilterOutput {
filtered: IUser[];
filter: IUsersFilter;
setFilter: React.Dispatch<React.SetStateAction<IUsersFilter>>;
}
// Store the users filter state globally, and in localStorage.
// When changing the format of IUsersFilter, change the version as well.
const useUsersFilterState = createGlobalStateHook<IUsersFilter>(
`${basePath}:useUsersFilter:v1`,
{ query: '' }
);
export const useUsersFilter = (users: IUser[]): IUsersFilterOutput => {
const [filter, setFilter] = useUsersFilterState();
const filtered = useMemo(() => {
return filterUsers(users, filter);
}, [users, filter]);
return {
setFilter,
filter,
filtered,
};
};
const filterUsers = (users: IUser[], filter: IUsersFilter): IUser[] => {
return filterUsersByQuery(users, filter);
};
const filterUsersByQuery = (users: IUser[], filter: IUsersFilter): IUser[] => {
if (!filter.query) {
return users;
}
return users.filter(user => {
return filterUserByText(user, filter.query);
});
};
const filterUserByText = (user: IUser, search: string = ''): boolean => {
const fieldsToSearch = [user.name ?? '', user.username ?? user.email ?? ''];
return fieldsToSearch.some(field =>
field.toLowerCase().includes(search.toLowerCase())
);
};

View File

@ -13,10 +13,11 @@ export const useUsersPlan = (users: IUser[]): IUsersPlanOutput => {
const { instanceStatus } = useInstanceStatus();
const isBillingUsers = STRIPE && instanceStatus?.plan === InstancePlan.PRO;
const seats = instanceStatus?.seats ?? 5;
const planUsers = useMemo(
() => calculatePaidUsers(users, isBillingUsers, instanceStatus?.seats),
[users, isBillingUsers, instanceStatus?.seats]
() => calculatePaidUsers(users, isBillingUsers, seats),
[users, isBillingUsers, seats]
);
return {

View File

@ -1,111 +0,0 @@
import { IUser } from 'interfaces/user';
import React, { useMemo } from 'react';
import { basePath } from 'utils/formatPath';
import { createPersistentGlobalStateHook } from './usePersistentGlobalState';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import IRole from 'interfaces/role';
export type UsersSortType = 'created' | 'name' | 'role' | 'last-seen';
export interface IUsersSort {
type: UsersSortType;
desc?: boolean;
}
export interface IUsersSortOutput {
sort: IUsersSort;
sorted: IUser[];
setSort: React.Dispatch<React.SetStateAction<IUsersSort>>;
}
// Store the users sort state globally, and in localStorage.
// When changing the format of IUsersSort, change the version as well.
const useUsersSortState = createPersistentGlobalStateHook<IUsersSort>(
`${basePath}:useUsersSort:v1`,
{ type: 'created', desc: false }
);
export const useUsersSort = (users: IUser[]): IUsersSortOutput => {
const [sort, setSort] = useUsersSortState();
const { roles } = useUsers();
const sorted = useMemo(() => {
return sortUsers(users, roles, sort);
}, [users, roles, sort]);
return {
setSort,
sort,
sorted,
};
};
const sortAscendingUsers = (
users: IUser[],
roles: IRole[],
sort: IUsersSort
): IUser[] => {
switch (sort.type) {
case 'created':
return sortByCreated(users);
case 'name':
return sortByName(users);
case 'role':
return sortByRole(users, roles);
case 'last-seen':
return sortByLastSeen(users);
default:
console.error(`Unknown feature sort type: ${sort.type}`);
return users;
}
};
const sortUsers = (
users: IUser[],
roles: IRole[],
sort: IUsersSort
): IUser[] => {
const sorted = sortAscendingUsers(users, roles, sort);
if (sort.desc) {
return [...sorted].reverse();
}
return sorted;
};
const sortByCreated = (users: Readonly<IUser[]>): IUser[] => {
return [...users].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
};
const sortByName = (users: Readonly<IUser[]>): IUser[] => {
return [...users].sort((a, b) => {
const aName = a.name ?? '';
const bName = b.name ?? '';
return aName.localeCompare(bName);
});
};
const sortByRole = (
users: Readonly<IUser[]>,
roles: Readonly<IRole[]>
): IUser[] => {
return [...users].sort((a, b) =>
getRoleName(a.rootRole, roles).localeCompare(
getRoleName(b.rootRole, roles)
)
);
};
const getRoleName = (roleId: number, roles: Readonly<IRole[]>) => {
const role = roles.find((role: IRole) => role.id === roleId);
return role ? role.name : '';
};
const sortByLastSeen = (users: Readonly<IUser[]>): IUser[] => {
return [...users].sort((a, b) => {
const aSeenAt = a.seenAt ?? '';
const bSeenAt = b.seenAt ?? '';
return bSeenAt.localeCompare(aSeenAt);
});
};

View File

@ -7,7 +7,7 @@ export const sortTypes = {
date: (v1: any, v2: any, id: string) => {
const a = new Date(v1?.values?.[id] || 0);
const b = new Date(v2?.values?.[id] || 0);
return b?.getTime() - a?.getTime();
return a?.getTime() - b?.getTime();
},
boolean: (v1: any, v2: any, id: string) => {
const a = v1?.values?.[id];