mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +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 { styled } from '@mui/material';
|
||||||
import { IReportTableRow } from 'component/Reporting/ReportTable/ReportTable';
|
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 {
|
interface IReportStatusCellProps {
|
||||||
row: {
|
row: {
|
||||||
original: IReportTableRow;
|
original: IReportTableRow;
|
||||||
@ -33,11 +41,3 @@ export const ReportStatusCell: VFC<IReportStatusCellProps> = ({
|
|||||||
</TextCell>
|
</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,
|
TableSearch,
|
||||||
SortableTableHeader,
|
SortableTableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
TablePlaceholder,
|
||||||
} from 'component/common/Table';
|
} from 'component/common/Table';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
@ -24,6 +25,7 @@ import {
|
|||||||
import { formatExpiredAt } from 'component/Reporting/ReportExpiredCell/formatExpiredAt';
|
import { formatExpiredAt } from 'component/Reporting/ReportExpiredCell/formatExpiredAt';
|
||||||
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
|
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
|
||||||
import theme from 'themes/theme';
|
import theme from 'themes/theme';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
interface IReportTableProps {
|
interface IReportTableProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -52,7 +54,7 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
|
|||||||
|
|
||||||
const initialState = useMemo(
|
const initialState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
hiddenColumns: ['description'],
|
hiddenColumns: [],
|
||||||
sortBy: [{ id: 'name' }],
|
sortBy: [{ id: 'name' }],
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
@ -83,9 +85,7 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
setHiddenColumns(['createdAt', 'expiredAt', 'description']);
|
setHiddenColumns(['createdAt', 'expiredAt']);
|
||||||
} else {
|
|
||||||
setHiddenColumns(['description']);
|
|
||||||
}
|
}
|
||||||
}, [setHiddenColumns, isSmallScreen]);
|
}, [setHiddenColumns, isSmallScreen]);
|
||||||
|
|
||||||
@ -101,6 +101,8 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(rows);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent header={header}>
|
<PageContent header={header}>
|
||||||
<SearchHighlightProvider value={globalFilter}>
|
<SearchHighlightProvider value={globalFilter}>
|
||||||
@ -122,6 +124,27 @@ export const ReportTable = ({ projectId, features }: IReportTableProps) => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</SearchHighlightProvider>
|
</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>
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -149,12 +172,14 @@ const COLUMNS = [
|
|||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
Cell: FeatureSeenCell,
|
Cell: FeatureSeenCell,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Type',
|
Header: 'Type',
|
||||||
accessor: 'type',
|
accessor: 'type',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
Cell: FeatureTypeCell,
|
Cell: FeatureTypeCell,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Feature toggle name',
|
Header: 'Feature toggle name',
|
||||||
@ -168,11 +193,13 @@ const COLUMNS = [
|
|||||||
accessor: 'createdAt',
|
accessor: 'createdAt',
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
Cell: DateCell,
|
Cell: DateCell,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Expired',
|
Header: 'Expired',
|
||||||
accessor: 'expiredAt',
|
accessor: 'expiredAt',
|
||||||
Cell: ReportExpiredCell,
|
Cell: ReportExpiredCell,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Status',
|
Header: 'Status',
|
||||||
@ -185,8 +212,6 @@ const COLUMNS = [
|
|||||||
accessor: 'stale',
|
accessor: 'stale',
|
||||||
sortType: 'boolean',
|
sortType: 'boolean',
|
||||||
Cell: FeatureStaleCell,
|
Cell: FeatureStaleCell,
|
||||||
},
|
disableGlobalFilter: true,
|
||||||
{
|
|
||||||
accessor: 'description',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -4,6 +4,30 @@ import { BillingInformationButton } from './BillingInformationButton/BillingInfo
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
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 {
|
interface IBillingInformationProps {
|
||||||
instanceStatus: IInstanceStatus;
|
instanceStatus: IInstanceStatus;
|
||||||
}
|
}
|
||||||
@ -43,28 +67,3 @@ export const BillingInformation: FC<IBillingInformationProps> = ({
|
|||||||
</Grid>
|
</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. --`;
|
-- 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 {
|
interface IBillingInformationButtonProps {
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
}
|
}
|
||||||
@ -29,8 +34,3 @@ export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({
|
|||||||
</StyledButton>
|
</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 { Alert, Divider, Grid, styled, Typography } from '@mui/material';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import {
|
import {
|
||||||
IInstanceStatus,
|
IInstanceStatus,
|
||||||
@ -15,6 +15,66 @@ import { GridCol } from 'component/common/GridCol/GridCol';
|
|||||||
import { GridColLink } from './GridColLink/GridColLink';
|
import { GridColLink } from './GridColLink/GridColLink';
|
||||||
import { STRIPE } from 'component/admin/billing/flags';
|
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 {
|
interface IBillingPlanProps {
|
||||||
instanceStatus: IInstanceStatus;
|
instanceStatus: IInstanceStatus;
|
||||||
}
|
}
|
||||||
@ -194,63 +254,3 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
|||||||
</Grid>
|
</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 { styled } from '@mui/material';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
|
||||||
export const GridColLink: FC = ({ children }) => {
|
|
||||||
return <StyledSpan>({children})</StyledSpan>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const StyledSpan = styled('span')(({ theme }) => ({
|
const StyledSpan = styled('span')(({ theme }) => ({
|
||||||
fontSize: theme.fontSizes.smallBody,
|
fontSize: theme.fontSizes.smallBody,
|
||||||
marginLeft: theme.spacing(1),
|
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 FileDownload from '@mui/icons-material/FileDownload';
|
||||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
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 {
|
interface IBillingHistoryProps {
|
||||||
data: Record<string, any>[];
|
data: Record<string, any>[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@ -29,18 +34,19 @@ const columns = [
|
|||||||
{
|
{
|
||||||
Header: 'Status',
|
Header: 'Status',
|
||||||
accessor: 'status',
|
accessor: 'status',
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Due date',
|
Header: 'Due date',
|
||||||
accessor: 'dueDate',
|
accessor: 'dueDate',
|
||||||
Cell: DateCell,
|
Cell: DateCell,
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Download',
|
Header: 'Download',
|
||||||
accessor: 'invoicePDF',
|
accessor: 'invoicePDF',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
disableSortBy: true,
|
|
||||||
Cell: ({ value }: { value: string }) => (
|
Cell: ({ value }: { value: string }) => (
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||||
@ -52,6 +58,8 @@ const columns = [
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
width: 100,
|
width: 100,
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -110,9 +118,3 @@ export const BillingHistory: VFC<IBillingHistoryProps> = ({
|
|||||||
</PageContent>
|
</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" />}
|
icon={<SupervisedUserCircle color="disabled" />}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Project role',
|
Header: 'Project role',
|
||||||
@ -133,6 +134,7 @@ const ProjectRoleList = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
width: 100,
|
width: 100,
|
||||||
|
disableGlobalFilter: true,
|
||||||
disableSortBy: 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';
|
} from '@mui/material';
|
||||||
import { useStyles } from './UserForm.styles';
|
import { useStyles } from './UserForm.styles';
|
||||||
import React from 'react';
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { EDIT } from 'constants/misc';
|
import { EDIT } from 'constants/misc';
|
||||||
import useUiBootstrap from 'hooks/api/getters/useUiBootstrap/useUiBootstrap';
|
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 UsersList from './UsersList/UsersList';
|
||||||
import AdminMenu from '../menu/AdminMenu';
|
import AdminMenu from '../menu/AdminMenu';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
|
||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
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';
|
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||||
|
|
||||||
const UsersAdmin = () => {
|
const UsersAdmin = () => {
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const navigate = useNavigate();
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminMenu />
|
<AdminMenu />
|
||||||
<PageContent
|
<ConditionallyRender
|
||||||
bodyClass={styles.userListBody}
|
condition={hasAccess(ADMIN)}
|
||||||
header={
|
show={<UsersList />}
|
||||||
<PageHeader
|
elseShow={<AdminAlert />}
|
||||||
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>
|
|
||||||
</div>
|
</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 */
|
/* eslint-disable no-alert */
|
||||||
import React, { useContext, useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
SortableTableHeader,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@mui/material';
|
TablePlaceholder,
|
||||||
import classnames from 'classnames';
|
TableSearch,
|
||||||
|
} from 'component/common/Table';
|
||||||
import ChangePassword from './ChangePassword/ChangePassword';
|
import ChangePassword from './ChangePassword/ChangePassword';
|
||||||
import DeleteUser from './DeleteUser/DeleteUser';
|
import DeleteUser from './DeleteUser/DeleteUser';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
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 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 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 { IUser } from 'interfaces/user';
|
||||||
import IRole from 'interfaces/role';
|
import IRole from 'interfaces/role';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
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 { 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 {
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
search: string;
|
width: theme.spacing(4),
|
||||||
}
|
height: theme.spacing(4),
|
||||||
|
margin: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
const UsersList = ({ search }: IUsersListProps) => {
|
const UsersList = () => {
|
||||||
const { classes: styles } = useStyles();
|
const navigate = useNavigate();
|
||||||
const { users, roles, refetch, loading } = useUsers();
|
const { users, roles, refetch, loading } = useUsers();
|
||||||
const { setToastData, setToastApiError } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const { removeUser, changePassword, userLoading, userApiErrors } =
|
const { removeUser, changePassword, userLoading, userApiErrors } =
|
||||||
useAdminUsersApi();
|
useAdminUsersApi();
|
||||||
const { hasAccess } = useContext(AccessContext);
|
|
||||||
const { locationSettings } = useLocationSettings();
|
|
||||||
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
|
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
|
||||||
open: false,
|
open: false,
|
||||||
});
|
});
|
||||||
@ -52,28 +55,10 @@ const UsersList = ({ search }: IUsersListProps) => {
|
|||||||
const [emailSent, setEmailSent] = useState(false);
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
const [inviteLink, setInviteLink] = useState('');
|
const [inviteLink, setInviteLink] = useState('');
|
||||||
const [delUser, setDelUser] = useState<IUser>();
|
const [delUser, setDelUser] = useState<IUser>();
|
||||||
const ref = useLoading(loading);
|
|
||||||
const { planUsers, isBillingUsers } = useUsersPlan(users);
|
const { planUsers, isBillingUsers } = useUsersPlan(users);
|
||||||
const { filtered, setFilter } = useUsersFilter(planUsers);
|
|
||||||
const { sorted, sort, setSort } = useUsersSort(filtered);
|
|
||||||
|
|
||||||
const filterUsersByQueryPage = (user: IUser) => {
|
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const fieldsToSearch = [
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
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 closeDelDialog = () => {
|
const closeDelDialog = () => {
|
||||||
setDelDialog(false);
|
setDelDialog(false);
|
||||||
@ -116,136 +101,208 @@ const UsersList = ({ search }: IUsersListProps) => {
|
|||||||
setInviteLink('');
|
setInviteLink('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRole = (roleId: number) => {
|
const columns = useMemo(
|
||||||
const role = roles.find((r: IRole) => r.id === roleId);
|
() => [
|
||||||
return role ? role.name : '';
|
{
|
||||||
};
|
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 = () => {
|
const initialState = useMemo(() => {
|
||||||
if (loading) {
|
return {
|
||||||
return loadingData.map(user => (
|
sortBy: [{ id: 'createdAt', desc: false }],
|
||||||
<UserListItem
|
hiddenColumns: isBillingUsers ? [] : ['type'],
|
||||||
key={user.id}
|
};
|
||||||
user={user}
|
}, [isBillingUsers]);
|
||||||
openPwDialog={openPwDialog}
|
|
||||||
openDelDialog={openDelDialog}
|
const data = isBillingUsers ? planUsers : users;
|
||||||
locationSettings={locationSettings}
|
|
||||||
renderRole={renderRole}
|
const {
|
||||||
search={search}
|
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');
|
||||||
}
|
}
|
||||||
|
if (isSmallScreen) {
|
||||||
return page.map(user => {
|
hiddenColumns.push(...['createdAt', 'username']);
|
||||||
return (
|
}
|
||||||
<UserListItem
|
if (isExtraSmallScreen) {
|
||||||
key={user.id}
|
hiddenColumns.push(...['imageUrl', 'role', 'last-login']);
|
||||||
user={user}
|
}
|
||||||
openPwDialog={openPwDialog}
|
setHiddenColumns(hiddenColumns);
|
||||||
openDelDialog={openDelDialog}
|
}, [setHiddenColumns, isExtraSmallScreen, isSmallScreen, isBillingUsers]);
|
||||||
locationSettings={locationSettings}
|
|
||||||
renderRole={renderRole}
|
|
||||||
search={search}
|
|
||||||
isBillingUsers={isBillingUsers}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!users) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<PageContent
|
||||||
<Table>
|
isLoading={loading}
|
||||||
<TableHead>
|
header={
|
||||||
<TableRow className={styles.tableCellHeader}>
|
<PageHeader
|
||||||
<ConditionallyRender
|
title="Users"
|
||||||
condition={isBillingUsers}
|
actions={
|
||||||
show={
|
<>
|
||||||
<TableCell
|
<TableSearch
|
||||||
align="center"
|
initialValue={globalFilter}
|
||||||
className={classnames(styles.hideSM)}
|
onChange={setGlobalFilter}
|
||||||
>
|
/>
|
||||||
Type
|
<PageHeader.Divider />
|
||||||
</TableCell>
|
<Button
|
||||||
}
|
variant="contained"
|
||||||
/>
|
color="primary"
|
||||||
<TableCellSortable
|
onClick={() => navigate('/admin/create-user')}
|
||||||
className={classnames(
|
>
|
||||||
styles.hideSM,
|
New user
|
||||||
styles.shrinkTableCell
|
</Button>
|
||||||
)}
|
</>
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</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
|
<ConditionallyRender
|
||||||
condition={!pages.length && search.length > 0}
|
condition={rows.length === 0}
|
||||||
show={
|
show={
|
||||||
<p className={styles.errorMessage}>
|
<ConditionallyRender
|
||||||
There are no results for "{search}"
|
condition={globalFilter?.length > 0}
|
||||||
</p>
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No users found matching “
|
||||||
|
{globalFilter}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No users available. Get started by adding one.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<br />
|
|
||||||
|
|
||||||
<ConfirmUserAdded
|
<ConfirmUserAdded
|
||||||
open={showConfirm}
|
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 { useEffect, useState } from 'react';
|
||||||
import useUiBootstrap from 'hooks/api/getters/useUiBootstrap/useUiBootstrap';
|
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 = (
|
const useCreateUserForm = (
|
||||||
initialName = '',
|
initialName = '',
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { Grid, styled, SxProps, Theme } from '@mui/material';
|
import { Grid, styled, SxProps, Theme } from '@mui/material';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
const StyledGrid = styled(Grid)(({ theme }) => ({
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
|
export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
|
||||||
return (
|
return (
|
||||||
<StyledGrid
|
<StyledGrid
|
||||||
@ -14,8 +19,3 @@ export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
|
|||||||
</StyledGrid>
|
</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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { calculateTrialDaysRemaining } from 'utils/billing';
|
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 {
|
interface IInstanceStatusBarProps {
|
||||||
instanceStatus: IInstanceStatus;
|
instanceStatus: IInstanceStatus;
|
||||||
}
|
}
|
||||||
@ -85,45 +127,3 @@ const UpgradeButton = () => {
|
|||||||
</StyledButton>
|
</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 { styled, useTheme } from '@mui/material';
|
||||||
import { ReactNode } from 'react';
|
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 {
|
interface IStatusBadgeProps {
|
||||||
severity: 'success' | 'warning';
|
severity: 'success' | 'warning';
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -21,14 +32,3 @@ export const StatusBadge = ({
|
|||||||
</StyledStatusBadge>
|
</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',
|
padding: '0.8rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: theme.spacing(2),
|
marginTop: theme.spacing(2),
|
||||||
},
|
},
|
||||||
|
@ -21,7 +21,7 @@ export const FeatureTypeCell: VFC<IFeatureTypeProps> = ({ value }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Tooltip arrow placement="right" title={title} describeChild>
|
<Tooltip arrow title={title} describeChild>
|
||||||
<IconComponent data-loading className={styles.icon} />
|
<IconComponent data-loading className={styles.icon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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',
|
id: 'Icon',
|
||||||
Cell: () => <IconCell icon={<Adjust color="disabled" />} />,
|
Cell: () => <IconCell icon={<Adjust color="disabled" />} />,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
@ -90,6 +91,7 @@ const ContextList: VFC = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
width: 150,
|
width: 150,
|
||||||
|
disableGlobalFilter: true,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -98,6 +100,7 @@ const ContextList: VFC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'sortOrder',
|
accessor: 'sortOrder',
|
||||||
|
disableGlobalFilter: true,
|
||||||
sortType: 'number',
|
sortType: 'number',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
TableSearch,
|
TableSearch,
|
||||||
SortableTableHeader,
|
SortableTableHeader,
|
||||||
Table,
|
Table,
|
||||||
|
TablePlaceholder,
|
||||||
} from 'component/common/Table';
|
} from 'component/common/Table';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
@ -22,6 +23,7 @@ import useEnvironmentApi, {
|
|||||||
createSortOrderPayload,
|
createSortOrderPayload,
|
||||||
} from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
|
} from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
export const EnvironmentTable = () => {
|
export const EnvironmentTable = () => {
|
||||||
const { changeSortOrder } = useEnvironmentApi();
|
const { changeSortOrder } = useEnvironmentApi();
|
||||||
@ -97,6 +99,27 @@ export const EnvironmentTable = () => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</SearchHighlightProvider>
|
</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>
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -106,6 +129,7 @@ const COLUMNS = [
|
|||||||
id: 'Icon',
|
id: 'Icon',
|
||||||
canSort: false,
|
canSort: false,
|
||||||
Cell: () => <IconCell icon={<CloudCircle color="disabled" />} />,
|
Cell: () => <IconCell icon={<CloudCircle color="disabled" />} />,
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
@ -124,5 +148,6 @@ const COLUMNS = [
|
|||||||
Cell: ({ row: { original } }: any) => (
|
Cell: ({ row: { original } }: any) => (
|
||||||
<EnvironmentActionCell environment={original} />
|
<EnvironmentActionCell environment={original} />
|
||||||
),
|
),
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -16,7 +16,7 @@ const FeatureStatus = ({ type }: IFeatureTypeProps) => {
|
|||||||
const title = `"${typeName || type}" toggle`;
|
const title = `"${typeName || type}" toggle`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip arrow placement="right" title={title}>
|
<Tooltip arrow title={title}>
|
||||||
<IconComponent data-loading className={styles.icon} />
|
<IconComponent data-loading className={styles.icon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
@ -95,7 +95,7 @@ export const useStyles = makeStyles()(theme => ({
|
|||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
},
|
},
|
||||||
accordionBody: { padding: '0' },
|
accordionBody: { padding: '0', wordBreak: 'break-all' },
|
||||||
accordionActions: {
|
accordionActions: {
|
||||||
padding: '0',
|
padding: '0',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
|
@ -95,6 +95,7 @@ export const StrategiesList = () => {
|
|||||||
<Extension color="disabled" />
|
<Extension color="disabled" />
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
@ -147,6 +148,7 @@ export const StrategiesList = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
width: 150,
|
width: 150,
|
||||||
|
disableGlobalFilter: true,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -155,6 +157,7 @@ export const StrategiesList = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'sortOrder',
|
accessor: 'sortOrder',
|
||||||
|
disableGlobalFilter: true,
|
||||||
sortType: 'number',
|
sortType: 'number',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -71,6 +71,7 @@ export const TagTypeList = () => {
|
|||||||
<Label color="disabled" />
|
<Label color="disabled" />
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
|
disableGlobalFilter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
@ -125,6 +126,7 @@ export const TagTypeList = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
width: 150,
|
width: 150,
|
||||||
|
disableGlobalFilter: true,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,36 +1,28 @@
|
|||||||
import useSWR, { mutate, SWRConfiguration } from 'swr';
|
import useSWR from 'swr';
|
||||||
import { useState, useEffect } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
|
||||||
const useUsers = (options: SWRConfiguration = {}) => {
|
export const useUsers = () => {
|
||||||
const fetcher = () => {
|
const { data, error, mutate } = useSWR(
|
||||||
const path = formatApiPath(`api/admin/user-admin`);
|
formatApiPath(`api/admin/user-admin`),
|
||||||
return fetch(path, {
|
fetcher
|
||||||
method: 'GET',
|
);
|
||||||
})
|
|
||||||
.then(handleErrorResponses('Users'))
|
|
||||||
.then(res => res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, error } = useSWR(`api/admin/user-admin`, fetcher, options);
|
return useMemo(
|
||||||
const [loading, setLoading] = useState(!error && !data);
|
() => ({
|
||||||
|
users: data?.users ?? [],
|
||||||
const refetch = () => {
|
roles: data?.rootRoles ?? [],
|
||||||
mutate(`api/admin/user-admin`);
|
loading: !error && !data,
|
||||||
};
|
refetch: () => mutate(),
|
||||||
|
error,
|
||||||
useEffect(() => {
|
}),
|
||||||
setLoading(!error && !data);
|
[data, error, mutate]
|
||||||
}, [data, error]);
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
users: data?.users || [],
|
|
||||||
roles: data?.rootRoles || [],
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { instanceStatus } = useInstanceStatus();
|
||||||
|
|
||||||
const isBillingUsers = STRIPE && instanceStatus?.plan === InstancePlan.PRO;
|
const isBillingUsers = STRIPE && instanceStatus?.plan === InstancePlan.PRO;
|
||||||
|
const seats = instanceStatus?.seats ?? 5;
|
||||||
|
|
||||||
const planUsers = useMemo(
|
const planUsers = useMemo(
|
||||||
() => calculatePaidUsers(users, isBillingUsers, instanceStatus?.seats),
|
() => calculatePaidUsers(users, isBillingUsers, seats),
|
||||||
[users, isBillingUsers, instanceStatus?.seats]
|
[users, isBillingUsers, seats]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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) => {
|
date: (v1: any, v2: any, id: string) => {
|
||||||
const a = new Date(v1?.values?.[id] || 0);
|
const a = new Date(v1?.values?.[id] || 0);
|
||||||
const b = new Date(v2?.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) => {
|
boolean: (v1: any, v2: any, id: string) => {
|
||||||
const a = v1?.values?.[id];
|
const a = v1?.values?.[id];
|
||||||
|
Loading…
Reference in New Issue
Block a user