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:
parent
63a30695ce
commit
570e9f88be
@ -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 },
|
||||
}));
|
||||
|
@ -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 “
|
||||
{globalFilter}
|
||||
”
|
||||
</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,
|
||||
},
|
||||
];
|
||||
|
@ -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,
|
||||
}));
|
||||
|
@ -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),
|
||||
}));
|
||||
|
@ -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`,
|
||||
}));
|
||||
|
@ -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>;
|
||||
};
|
||||
|
@ -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,
|
||||
}));
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 “
|
||||
{globalFilter}
|
||||
”
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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 = '',
|
||||
|
@ -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),
|
||||
}));
|
||||
|
@ -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,
|
||||
}));
|
||||
|
@ -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,
|
||||
}));
|
||||
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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%',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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),
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
@ -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 “
|
||||
{globalFilter}
|
||||
”
|
||||
</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,
|
||||
},
|
||||
];
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
@ -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());
|
||||
};
|
||||
|
@ -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())
|
||||
);
|
||||
};
|
@ -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 {
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
@ -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];
|
||||
|
Loading…
Reference in New Issue
Block a user