From fdd683813a8630cdb22ee7a8ccf0a2fc956bd194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 20 Apr 2022 13:22:50 +0100 Subject: [PATCH] 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 * 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 --- .../component/admin/users/UserAdmin.styles.ts | 10 +- .../src/component/admin/users/UsersAdmin.tsx | 34 ++++-- .../UserListItem/UserListItem.styles.ts | 48 +++++++- .../UsersList/UserListItem/UserListItem.tsx | 100 ++++++++------- .../admin/users/UsersList/UsersList.tsx | 77 ++++++++++-- .../common/AnimateOnMount/AnimateOnMount.tsx | 8 +- .../common/Highlighter/Highlighter.tsx | 23 ++++ .../Table/TableActions/TableActions.styles.ts | 63 ++++++++++ .../Table/TableActions/TableActions.tsx | 59 +++++++++ .../TableSearchField.styles.ts | 43 +++++++ .../TableSearchField/TableSearchField.tsx | 75 ++++++++++++ .../TableCellSortable.styles.ts | 25 ++++ .../TableCellSortable/TableCellSortable.tsx | 62 ++++++++++ frontend/src/hooks/useUsersFilter.ts | 62 ++++++++++ frontend/src/hooks/useUsersSort.ts | 114 ++++++++++++++++++ frontend/src/themes/mainTheme.ts | 20 +++ 16 files changed, 756 insertions(+), 67 deletions(-) create mode 100644 frontend/src/component/common/Highlighter/Highlighter.tsx create mode 100644 frontend/src/component/common/Table/TableActions/TableActions.styles.ts create mode 100644 frontend/src/component/common/Table/TableActions/TableActions.tsx create mode 100644 frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.styles.ts create mode 100644 frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.tsx create mode 100644 frontend/src/component/common/Table/TableCellSortable/TableCellSortable.styles.ts create mode 100644 frontend/src/component/common/Table/TableCellSortable/TableCellSortable.tsx create mode 100644 frontend/src/hooks/useUsersFilter.ts create mode 100644 frontend/src/hooks/useUsersSort.ts diff --git a/frontend/src/component/admin/users/UserAdmin.styles.ts b/frontend/src/component/admin/users/UserAdmin.styles.ts index 8d85cf5c4f..9bc46de2d6 100644 --- a/frontend/src/component/admin/users/UserAdmin.styles.ts +++ b/frontend/src/component/admin/users/UserAdmin.styles.ts @@ -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, + }, + }, })); diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx index ae47c1a33a..9b18a1c2e5 100644 --- a/frontend/src/component/admin/users/UsersAdmin.tsx +++ b/frontend/src/component/admin/users/UsersAdmin.tsx @@ -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 = () => { - history.push('/admin/create-user') - } - > - New user - +
+ + setSearch(search) + } + /> + +
} elseShow={ @@ -50,7 +62,7 @@ const UsersAdmin = () => { > } + show={} elseShow={ You need instance admin to access this section. diff --git a/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.styles.ts b/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.styles.ts index f94324a4f2..77951d1be3 100644 --- a/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.styles.ts +++ b/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.styles.ts @@ -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', diff --git a/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.tsx b/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.tsx index a28583a293..ade828415d 100644 --- a/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.tsx +++ b/frontend/src/component/admin/users/UsersList/UserListItem/UserListItem.tsx @@ -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 ( - - - {formatDateYMD(user.createdAt, locationSettings.locale)} + + + - {user.name} + {user.name} - + - {user.username || user.email} + + {user.username || user.email} + - + {renderRole(user.rootRole)} @@ -70,33 +80,39 @@ const UserListItem = ({ - - history.push(`/admin/users/${user.id}/edit`) - } - > - - - - - - - - + + + + history.push(`/admin/users/${user.id}/edit`) + } + > + + + + + + + + + + + + + } elseShow={} diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx index 386bc902fb..07d11b2835 100644 --- a/frontend/src/component/admin/users/UsersList/UsersList.tsx +++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx @@ -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(); 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 = () => {
- - - Created - Name + + + Created on + + + Avatar + + + Name + Username - + Role - - + + {hasAccess(ADMIN) ? 'Actions' : ''} @@ -158,6 +207,14 @@ const UsersList = () => { prevPage={prevPage} />
+ 0} + show={ +

+ There are no results for "{search}" +

+ } + />
void; + onEnd?: () => void; } const AnimateOnMount: FC = ({ @@ -18,6 +20,8 @@ const AnimateOnMount: FC = ({ container, children, style, + onStart, + onEnd, }) => { const [show, setShow] = useState(mounted); const [styles, setStyles] = useState(''); @@ -27,6 +31,7 @@ const AnimateOnMount: FC = ({ if (mountedRef.current !== mounted || mountedRef === null) { if (mounted) { setShow(true); + onStart?.(); setTimeout(() => { setStyles(enter); }, 50); @@ -37,11 +42,12 @@ const AnimateOnMount: FC = ({ setStyles(leave); } } - }, [mounted, enter, leave]); + }, [mounted, enter, onStart, leave]); const onTransitionEnd = () => { if (!mounted) { setShow(false); + onEnd?.(); } }; diff --git a/frontend/src/component/common/Highlighter/Highlighter.tsx b/frontend/src/component/common/Highlighter/Highlighter.tsx new file mode 100644 index 0000000000..52984475f7 --- /dev/null +++ b/frontend/src/component/common/Highlighter/Highlighter.tsx @@ -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 ( + $&') || '', + }} + /> + ); +}; diff --git a/frontend/src/component/common/Table/TableActions/TableActions.styles.ts b/frontend/src/component/common/Table/TableActions/TableActions.styles.ts new file mode 100644 index 0000000000..8909630bc6 --- /dev/null +++ b/frontend/src/component/common/Table/TableActions/TableActions.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/common/Table/TableActions/TableActions.tsx b/frontend/src/component/common/Table/TableActions/TableActions.tsx new file mode 100644 index 0000000000..dcf1ee2283 --- /dev/null +++ b/frontend/src/component/common/Table/TableActions/TableActions.tsx @@ -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 ( + <> + setAnimating(true)} + onEnd={() => setAnimating(false)} + > + + + + setSearchExpanded(true)} + > + + + + } + /> +
+ + ); +}; diff --git a/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.styles.ts b/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.styles.ts new file mode 100644 index 0000000000..3d3274b1fb --- /dev/null +++ b/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.styles.ts @@ -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%', + }, +})); diff --git a/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.tsx b/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.tsx new file mode 100644 index 0000000000..774fe8a324 --- /dev/null +++ b/frontend/src/component/common/Table/TableActions/TableSearchField/TableSearchField.tsx @@ -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 ( +
+
+ + onChange(e.target.value)} + onBlur={() => onBlur?.()} + /> +
+ + { + onChange(''); + onBlur?.(true); + }} + > + + + + } + /> +
+
+
+ ); +}; diff --git a/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.styles.ts b/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.styles.ts new file mode 100644 index 0000000000..112ee30bbc --- /dev/null +++ b/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.styles.ts @@ -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], + }, + }, + }, +})); diff --git a/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.tsx b/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.tsx new file mode 100644 index 0000000000..7870e9f94e --- /dev/null +++ b/frontend/src/component/common/Table/TableCellSortable/TableCellSortable.tsx @@ -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>; + children: ReactNode; +} + +export const TableCellSortable = ({ + className, + name, + sort, + setSort, + children, +}: ITableCellSortableProps) => { + const styles = useStyles(); + + return ( + + setSort(prev => ({ + desc: !Boolean(prev.desc), + type: name, + })) + } + > + {children} + } + elseShow={} + /> + } + elseShow={} + /> + + ); +}; diff --git a/frontend/src/hooks/useUsersFilter.ts b/frontend/src/hooks/useUsersFilter.ts new file mode 100644 index 0000000000..cf7080f874 --- /dev/null +++ b/frontend/src/hooks/useUsersFilter.ts @@ -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>; +} + +// Store the users filter state globally, and in localStorage. +// When changing the format of IUsersFilter, change the version as well. +const useUsersFilterState = createGlobalStateHook( + `${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 ?? ''); diff --git a/frontend/src/hooks/useUsersSort.ts b/frontend/src/hooks/useUsersSort.ts new file mode 100644 index 0000000000..e87110379f --- /dev/null +++ b/frontend/src/hooks/useUsersSort.ts @@ -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>; +} + +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( + `${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[] => { + return [...users].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +}; + +const sortByName = (users: Readonly): IUser[] => { + return [...users].sort((a, b) => { + const aName = a.name ?? ''; + const bName = b.name ?? ''; + return aName.localeCompare(bName); + }); +}; + +const sortByRole = ( + users: Readonly, + roles: Readonly +): IUser[] => { + return [...users].sort((a, b) => + getRoleName(a.rootRole, roles).localeCompare( + getRoleName(b.rootRole, roles) + ) + ); +}; + +const getRoleName = (roleId: number, roles: Readonly) => { + const role = roles.find((role: IRole) => role.id === roleId); + return role ? role.name : ''; +}; diff --git a/frontend/src/themes/mainTheme.ts b/frontend/src/themes/mainTheme.ts index 18298b29f7..98bdff31fd 100644 --- a/frontend/src/themes/mainTheme.ts +++ b/frontend/src/themes/mainTheme.ts @@ -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);