1
0
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:
Nuno Góis 2022-04-20 13:22:50 +01:00 committed by GitHub
parent c93c65f7f7
commit fdd683813a
16 changed files with 756 additions and 67 deletions

View File

@ -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,
},
},
}));

View File

@ -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.

View File

@ -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',

View File

@ -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 />}

View File

@ -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

View File

@ -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?.();
}
};

View 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>') || '',
}}
/>
);
};

View File

@ -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',
},
}));

View File

@ -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>
</>
);
};

View File

@ -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%',
},
}));

View File

@ -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>
);
};

View File

@ -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],
},
},
},
}));

View File

@ -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>
);
};

View 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 ?? '');

View 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 : '';
};

View File

@ -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);