mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
feat: add user table sort and search (#879)
* add user sort and filter hooks, adapt table to match design * refactor: abstract away TableActions and TableCellSortable into components, small fixes and improvements * feat: implement search * feat: add search word highlighter to match design * refactor: small UI/UX improvements * fix: rounded corners for th on responsive breakpoints * refactor: small UI/UX improvements * Update src/hooks/useUsersSort.ts Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com> * refactor: clearer arg name in users filter * refactor: specify transition properties * refactor: add theme v2 properties and cleanup styles * refactor: create lightweight highlighter component Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
c93c65f7f7
commit
fdd683813a
@ -1,9 +1,17 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
export const useStyles = makeStyles(() => ({
|
||||
userListBody: {
|
||||
paddingBottom: '4rem',
|
||||
minHeight: '50vh',
|
||||
position: 'relative',
|
||||
},
|
||||
tableActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
'&>button': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import UsersList from './UsersList/UsersList';
|
||||
import AdminMenu from '../menu/AdminMenu';
|
||||
import PageContent from 'component/common/PageContent/PageContent';
|
||||
@ -7,11 +7,13 @@ import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import HeaderTitle from 'component/common/HeaderTitle';
|
||||
import { TableActions } from 'component/common/Table/TableActions/TableActions';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { useStyles } from './UserAdmin.styles';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
const UsersAdmin = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const history = useHistory();
|
||||
const styles = useStyles();
|
||||
@ -28,15 +30,25 @@ const UsersAdmin = () => {
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(ADMIN)}
|
||||
show={
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
history.push('/admin/create-user')
|
||||
}
|
||||
>
|
||||
New user
|
||||
</Button>
|
||||
<div className={styles.tableActions}>
|
||||
<TableActions
|
||||
search={search}
|
||||
onSearch={search =>
|
||||
setSearch(search)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
history.push(
|
||||
'/admin/create-user'
|
||||
)
|
||||
}
|
||||
>
|
||||
New user
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
elseShow={
|
||||
<small>
|
||||
@ -50,7 +62,7 @@ const UsersAdmin = () => {
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(ADMIN)}
|
||||
show={<UsersList />}
|
||||
show={<UsersList search={search} />}
|
||||
elseShow={
|
||||
<Alert severity="error">
|
||||
You need instance admin to access this section.
|
||||
|
@ -2,13 +2,57 @@ import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
tableRow: {
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
'& > td': {
|
||||
padding: '4px 16px',
|
||||
borderColor: theme.v2.palette.grey[30],
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.v2.palette.grey[10],
|
||||
},
|
||||
},
|
||||
tableCellHeader: {
|
||||
'& > th': {
|
||||
backgroundColor: theme.v2.palette.grey[20],
|
||||
fontWeight: 'normal',
|
||||
border: 0,
|
||||
'&:first-child': {
|
||||
borderTopLeftRadius: '8px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
},
|
||||
'&:last-child': {
|
||||
borderTopRightRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
},
|
||||
},
|
||||
},
|
||||
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('sm')]: {
|
||||
borderTopLeftRadius: '8px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
},
|
||||
},
|
||||
firstColumnXS: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
borderTopLeftRadius: '8px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
},
|
||||
},
|
||||
hideSM: {
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
display: 'none',
|
||||
|
@ -3,18 +3,21 @@ import {
|
||||
IconButton,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import classnames from 'classnames';
|
||||
import { Delete, Edit, Lock } from '@material-ui/icons';
|
||||
import { SyntheticEvent, useContext } from 'react';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import { useStyles } from './UserListItem.styles';
|
||||
import { useHistory } 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';
|
||||
|
||||
interface IUserListItemProps {
|
||||
user: IUser;
|
||||
@ -22,6 +25,7 @@ interface IUserListItemProps {
|
||||
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||
locationSettings: ILocationSettings;
|
||||
search: string;
|
||||
}
|
||||
|
||||
const UserListItem = ({
|
||||
@ -30,6 +34,7 @@ const UserListItem = ({
|
||||
openDelDialog,
|
||||
openPwDialog,
|
||||
locationSettings,
|
||||
search,
|
||||
}: IUserListItemProps) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const history = useHistory();
|
||||
@ -37,32 +42,37 @@ const UserListItem = ({
|
||||
|
||||
return (
|
||||
<TableRow key={user.id} className={styles.tableRow}>
|
||||
<TableCell className={styles.hideXS}>
|
||||
<Avatar
|
||||
data-loading
|
||||
alt={user.name}
|
||||
src={user.imageUrl}
|
||||
title={`${user.name || user.email || user.username} (id: ${
|
||||
user.id
|
||||
})`}
|
||||
/>
|
||||
</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={user.name}
|
||||
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>
|
||||
{user.name}
|
||||
<Highlighter search={search}>{user.name}</Highlighter>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell className={`${styles.leftTableCell} ${styles.hideSM}`}>
|
||||
<TableCell
|
||||
className={classnames(styles.leftTableCell, styles.hideSM)}
|
||||
>
|
||||
<Typography variant="body2" data-loading>
|
||||
{user.username || user.email}
|
||||
<Highlighter search={search}>
|
||||
{user.username || user.email}
|
||||
</Highlighter>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center" className={styles.hideXS}>
|
||||
<TableCell className={styles.hideXS}>
|
||||
<Typography variant="body2" data-loading>
|
||||
{renderRole(user.rootRole)}
|
||||
</Typography>
|
||||
@ -70,33 +80,39 @@ const UserListItem = ({
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(ADMIN)}
|
||||
show={
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Edit"
|
||||
title="Edit"
|
||||
onClick={() =>
|
||||
history.push(`/admin/users/${user.id}/edit`)
|
||||
}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Change password"
|
||||
title="Change password"
|
||||
onClick={openPwDialog(user)}
|
||||
>
|
||||
<Lock />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Remove user"
|
||||
title="Remove user"
|
||||
onClick={openDelDialog(user)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
<TableCell
|
||||
align="center"
|
||||
className={styles.shrinkTableCell}
|
||||
>
|
||||
<Tooltip title="Edit user" arrow>
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Edit user"
|
||||
onClick={() =>
|
||||
history.push(`/admin/users/${user.id}/edit`)
|
||||
}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Change password" arrow>
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Change password"
|
||||
onClick={openPwDialog(user)}
|
||||
>
|
||||
<Lock />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Remove user" arrow>
|
||||
<IconButton
|
||||
data-loading
|
||||
aria-label="Remove user"
|
||||
onClick={openDelDialog(user)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
}
|
||||
elseShow={<TableCell />}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-alert */
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -7,6 +7,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@material-ui/core';
|
||||
import classnames from 'classnames';
|
||||
import ChangePassword from './ChangePassword/ChangePassword';
|
||||
import DeleteUser from './DeleteUser/DeleteUser';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
@ -25,9 +26,16 @@ 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';
|
||||
|
||||
const UsersList = () => {
|
||||
interface IUsersListProps {
|
||||
search: string;
|
||||
}
|
||||
|
||||
const UsersList = ({ search }: IUsersListProps) => {
|
||||
const styles = useStyles();
|
||||
const { users, roles, refetch, loading } = useUsers();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
@ -49,8 +57,14 @@ const UsersList = () => {
|
||||
const [inviteLink, setInviteLink] = useState('');
|
||||
const [delUser, setDelUser] = useState<IUser>();
|
||||
const ref = useLoading(loading);
|
||||
const { filtered, setFilter } = useUsersFilter(users);
|
||||
const { sorted, sort, setSort } = useUsersSort(filtered);
|
||||
const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } =
|
||||
usePagination(users, 50);
|
||||
usePagination(sorted, 50);
|
||||
|
||||
useEffect(() => {
|
||||
setFilter(filter => ({ ...filter, query: search }));
|
||||
}, [search, setFilter]);
|
||||
|
||||
const closeDelDialog = () => {
|
||||
setDelDialog(false);
|
||||
@ -109,6 +123,7 @@ const UsersList = () => {
|
||||
openDelDialog={openDelDialog}
|
||||
locationSettings={locationSettings}
|
||||
renderRole={renderRole}
|
||||
search={search}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@ -123,6 +138,7 @@ const UsersList = () => {
|
||||
openDelDialog={openDelDialog}
|
||||
locationSettings={locationSettings}
|
||||
renderRole={renderRole}
|
||||
search={search}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -134,17 +150,50 @@ const UsersList = () => {
|
||||
<div ref={ref}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className={styles.hideXS}></TableCell>
|
||||
<TableCell className={styles.hideSM}>Created</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableRow className={styles.tableCellHeader}>
|
||||
<TableCellSortable
|
||||
className={classnames(
|
||||
styles.hideSM,
|
||||
styles.shrinkTableCell
|
||||
)}
|
||||
name="created"
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
>
|
||||
Created on
|
||||
</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>
|
||||
<TableCell align="center" className={styles.hideXS}>
|
||||
<TableCellSortable
|
||||
className={classnames(
|
||||
styles.hideXS,
|
||||
styles.shrinkTableCell
|
||||
)}
|
||||
name="role"
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
>
|
||||
Role
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
</TableCellSortable>
|
||||
<TableCell align="center">
|
||||
{hasAccess(ADMIN) ? 'Actions' : ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -158,6 +207,14 @@ const UsersList = () => {
|
||||
prevPage={prevPage}
|
||||
/>
|
||||
</Table>
|
||||
<ConditionallyRender
|
||||
condition={!pages.length && search.length > 0}
|
||||
show={
|
||||
<p className={styles.errorMessage}>
|
||||
There are no results for "{search}"
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
|
||||
<ConfirmUserAdded
|
||||
|
@ -8,6 +8,8 @@ interface IAnimateOnMountProps {
|
||||
leave: string;
|
||||
container?: string;
|
||||
style?: React.CSSProperties;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
@ -18,6 +20,8 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
container,
|
||||
children,
|
||||
style,
|
||||
onStart,
|
||||
onEnd,
|
||||
}) => {
|
||||
const [show, setShow] = useState(mounted);
|
||||
const [styles, setStyles] = useState('');
|
||||
@ -27,6 +31,7 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
if (mountedRef.current !== mounted || mountedRef === null) {
|
||||
if (mounted) {
|
||||
setShow(true);
|
||||
onStart?.();
|
||||
setTimeout(() => {
|
||||
setStyles(enter);
|
||||
}, 50);
|
||||
@ -37,11 +42,12 @@ const AnimateOnMount: FC<IAnimateOnMountProps> = ({
|
||||
setStyles(leave);
|
||||
}
|
||||
}
|
||||
}, [mounted, enter, leave]);
|
||||
}, [mounted, enter, onStart, leave]);
|
||||
|
||||
const onTransitionEnd = () => {
|
||||
if (!mounted) {
|
||||
setShow(false);
|
||||
onEnd?.();
|
||||
}
|
||||
};
|
||||
|
||||
|
23
frontend/src/component/common/Highlighter/Highlighter.tsx
Normal file
23
frontend/src/component/common/Highlighter/Highlighter.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
interface IHighlighterProps {
|
||||
search: string;
|
||||
children: string;
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const Highlighter = ({
|
||||
search,
|
||||
children,
|
||||
caseSensitive,
|
||||
}: IHighlighterProps) => {
|
||||
const regex = new RegExp(escapeRegex(search), caseSensitive ? 'g' : 'gi');
|
||||
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: children?.replaceAll(regex, '<mark>$&</mark>') || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
fieldWidth: {
|
||||
width: '49px',
|
||||
'& .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: '100%',
|
||||
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: '49px',
|
||||
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.v2.palette.grey[50],
|
||||
width: '1px',
|
||||
display: 'inline-block',
|
||||
marginLeft: '16px',
|
||||
marginRight: '32px',
|
||||
padding: '10px 0',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
}));
|
@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import { Search } from '@material-ui/icons';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
|
||||
import { TableSearchField } from 'component/common/Table/TableActions/TableSearchField/TableSearchField';
|
||||
import { useStyles } from 'component/common/Table/TableActions/TableActions.styles';
|
||||
|
||||
interface ITableActionsProps {
|
||||
search: string;
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
|
||||
export const TableActions = ({ search, onSearch }: ITableActionsProps) => {
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const onBlur = (clear = false) => {
|
||||
if (!search || clear) {
|
||||
setSearchExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimateOnMount
|
||||
mounted={searchExpanded}
|
||||
start={styles.fieldWidth}
|
||||
enter={styles.fieldWidthEnter}
|
||||
leave={styles.fieldWidthLeave}
|
||||
onStart={() => setAnimating(true)}
|
||||
onEnd={() => setAnimating(false)}
|
||||
>
|
||||
<TableSearchField
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
placeholder="Search users..."
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</AnimateOnMount>
|
||||
<ConditionallyRender
|
||||
condition={!searchExpanded && !animating}
|
||||
show={
|
||||
<Tooltip title="Search users" arrow>
|
||||
<IconButton
|
||||
aria-label="Search users"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<div className={styles.verticalSeparator}></div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
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: '25px',
|
||||
padding: '3px 5px 3px 12px',
|
||||
maxWidth: '450px',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
width: '100%',
|
||||
},
|
||||
'&.search-container:focus-within': {
|
||||
borderColor: theme.palette.primary.light,
|
||||
boxShadow: theme.v2.boxShadows.primary,
|
||||
},
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 8,
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
clearContainer: {
|
||||
width: '30px',
|
||||
'& > button': {
|
||||
padding: '7px',
|
||||
},
|
||||
},
|
||||
clearIcon: {
|
||||
color: theme.palette.grey[600],
|
||||
fontSize: '18px',
|
||||
},
|
||||
inputRoot: {
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
@ -0,0 +1,75 @@
|
||||
import { IconButton, InputBase, Tooltip } from '@material-ui/core';
|
||||
import { Search, Close } from '@material-ui/icons';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import { useStyles } from 'component/common/Table/TableActions/TableSearchField/TableSearchField.styles';
|
||||
import classnames from 'classnames';
|
||||
|
||||
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 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"
|
||||
aria-label="Clear search query"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
onBlur?.(true);
|
||||
}}
|
||||
>
|
||||
<Close className={styles.clearIcon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
tableCellHeaderSortable: {
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.v2.palette.grey[40],
|
||||
'& > svg': {
|
||||
color: theme.v2.palette.grey[90],
|
||||
},
|
||||
},
|
||||
'& > svg': {
|
||||
fontSize: theme.v2.fontSizes.headerIcon,
|
||||
verticalAlign: 'middle',
|
||||
color: theme.v2.palette.grey[70],
|
||||
marginLeft: '4px',
|
||||
},
|
||||
'&.sorted': {
|
||||
fontWeight: 'bold',
|
||||
'& > svg': {
|
||||
color: theme.v2.palette.grey[90],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
@ -0,0 +1,62 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { TableCell } from '@material-ui/core';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
UnfoldMoreOutlined,
|
||||
KeyboardArrowDown,
|
||||
KeyboardArrowUp,
|
||||
} from '@material-ui/icons';
|
||||
import { IUsersSort, UsersSortType } from 'hooks/useUsersSort';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import { useStyles } from 'component/common/Table/TableCellSortable/TableCellSortable.styles';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export const TableCellSortable = ({
|
||||
className,
|
||||
name,
|
||||
sort,
|
||||
setSort,
|
||||
children,
|
||||
}: ITableCellSortableProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className={classnames(
|
||||
className,
|
||||
styles.tableCellHeaderSortable,
|
||||
sort.type === name && 'sorted'
|
||||
)}
|
||||
onClick={() =>
|
||||
setSort(prev => ({
|
||||
desc: !Boolean(prev.desc),
|
||||
type: name,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<ConditionallyRender
|
||||
condition={sort.type === name}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={Boolean(sort.desc)}
|
||||
show={<KeyboardArrowDown />}
|
||||
elseShow={<KeyboardArrowUp />}
|
||||
/>
|
||||
}
|
||||
elseShow={<UnfoldMoreOutlined />}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
62
frontend/src/hooks/useUsersFilter.ts
Normal file
62
frontend/src/hooks/useUsersFilter.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { IUser } from 'interfaces/user';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getBasePath } 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>(
|
||||
`${getBasePath()}: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;
|
||||
}
|
||||
|
||||
// Try to parse the search query as a RegExp.
|
||||
// Return all users if it can't be parsed.
|
||||
try {
|
||||
const regExp = new RegExp(filter.query, 'i');
|
||||
return users.filter(user => filterUserByRegExp(user, regExp));
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
return users;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filterUserByRegExp = (user: IUser, regExp: RegExp): boolean =>
|
||||
regExp.test(user.name ?? '') ||
|
||||
regExp.test(user.username ?? user.email ?? '');
|
114
frontend/src/hooks/useUsersSort.ts
Normal file
114
frontend/src/hooks/useUsersSort.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { IUser } from 'interfaces/user';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getBasePath } 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';
|
||||
|
||||
export interface IUsersSort {
|
||||
type: UsersSortType;
|
||||
desc?: boolean;
|
||||
}
|
||||
|
||||
export interface IUsersSortOutput {
|
||||
sort: IUsersSort;
|
||||
sorted: IUser[];
|
||||
setSort: React.Dispatch<React.SetStateAction<IUsersSort>>;
|
||||
}
|
||||
|
||||
export interface IUsersFilterSortOption {
|
||||
type: UsersSortType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Store the users sort state globally, and in localStorage.
|
||||
// When changing the format of IUsersSort, change the version as well.
|
||||
const useUsersSortState = createPersistentGlobalStateHook<IUsersSort>(
|
||||
`${getBasePath()}: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,
|
||||
};
|
||||
};
|
||||
|
||||
export const createUsersFilterSortOptions = (): IUsersFilterSortOption[] => {
|
||||
return [
|
||||
{ type: 'created', name: 'Created' },
|
||||
{ type: 'name', name: 'Name' },
|
||||
{ type: 'role', name: 'Role' },
|
||||
];
|
||||
};
|
||||
|
||||
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);
|
||||
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 : '';
|
||||
};
|
@ -131,6 +131,26 @@ const mainTheme = {
|
||||
semi: 700,
|
||||
bold: 700,
|
||||
},
|
||||
v2: {
|
||||
palette: {
|
||||
primary: '',
|
||||
grey: {
|
||||
'10': '#F7F7FA',
|
||||
'20': '#F2F2F5',
|
||||
'30': '#EAEAED',
|
||||
'40': '#E1E1E3',
|
||||
'50': '#BDBDBF',
|
||||
'70': '#78787A',
|
||||
'90': '#202021',
|
||||
},
|
||||
},
|
||||
fontSizes: {
|
||||
headerIcon: '18px',
|
||||
},
|
||||
boxShadows: {
|
||||
primary: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default createTheme(mainTheme as unknown as Theme);
|
||||
|
Loading…
Reference in New Issue
Block a user