From 1dbacec4f9ff69faa99d88aec870fc25c35a6e72 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 28 Apr 2025 19:45:50 +0200 Subject: [PATCH] feat(userlist): add sortable columns to User List --- jellyseerr-api.yml | 8 +- server/routes/user/index.ts | 38 ++++--- src/components/UserList/index.tsx | 175 ++++++++++++++++++++++++------ src/i18n/locale/en.json | 9 ++ 4 files changed, 181 insertions(+), 49 deletions(-) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 65bb54951..497c23305 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -3909,8 +3909,14 @@ paths: name: sort schema: type: string - enum: [created, updated, requests, displayname] + enum: [created, updated, requests, displayname, usertype, role] default: created + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + default: desc - in: query name: q required: false diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f817a9cb3..e1d54cc6e 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -42,6 +42,9 @@ router.get('/', async (req, res, next) => { : Math.max(10, includeIds.length); const skip = req.query.skip ? Number(req.query.skip) : 0; const q = req.query.q ? req.query.q.toString().toLowerCase() : ''; + const sortDirection = + (req.query.sortDirection as string) === 'asc' ? 'ASC' : 'DESC'; + let query = getRepository(User).createQueryBuilder('user'); if (q) { @@ -56,28 +59,31 @@ router.get('/', async (req, res, next) => { } switch (req.query.sort) { + case 'created': + query = query.orderBy('user.createdAt', sortDirection); + break; case 'updated': - query = query.orderBy('user.updatedAt', 'DESC'); + query = query.orderBy('user.updatedAt', sortDirection); break; case 'displayname': query = query .addSelect( `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( - CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( - CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN - "user"."email" + CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( + CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN + "user"."email" + ELSE + LOWER(user.jellyfinUsername) + END) ELSE - LOWER(user.jellyfinUsername) + LOWER(user.plexUsername) END) ELSE - LOWER(user.jellyfinUsername) - END) - ELSE - LOWER(user.username) - END`, + LOWER(user.username) + END`, 'displayname_sort_key' ) - .orderBy('displayname_sort_key', 'ASC'); + .orderBy('displayname_sort_key', sortDirection); break; case 'requests': query = query @@ -87,10 +93,16 @@ router.get('/', async (req, res, next) => { .from(MediaRequest, 'request') .where('request.requestedBy.id = user.id'); }, 'request_count') - .orderBy('request_count', 'DESC'); + .orderBy('request_count', sortDirection); + break; + case 'usertype': + query = query.orderBy('user.userType', sortDirection); + break; + case 'role': + query = query.orderBy('user.permissions', sortDirection); break; default: - query = query.orderBy('user.id', 'ASC'); + query = query.orderBy('user.id', sortDirection); break; } diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 877f95aec..f13bc69e3 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -18,9 +18,10 @@ import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { - BarsArrowDownIcon, + ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, + ChevronUpIcon, InboxArrowDownIcon, PencilIcon, UserPlusIcon, @@ -77,6 +78,15 @@ const messages = defineMessages('components.UserList', { autogeneratepasswordTip: 'Email a server-generated password to the user', validationUsername: 'You must provide an username', validationEmail: 'Email required', + sortBy: 'Sort by {field}', + sortByUser: 'Sort by username', + sortByRequests: 'Sort by number of requests', + sortByType: 'Sort by account type', + sortByRole: 'Sort by user role', + sortByJoined: 'Sort by join date', + toggleSortDirection: 'Click again to sort {direction}', + ascending: 'ascending', + descending: 'descending', sortCreated: 'Join Date', sortDisplayName: 'Display Name', sortRequests: 'Request Count', @@ -84,7 +94,14 @@ const messages = defineMessages('components.UserList', { 'The Enable Local Sign-In setting is currently disabled.', }); -type Sort = 'created' | 'updated' | 'requests' | 'displayname'; +type Sort = + | 'created' + | 'updated' + | 'requests' + | 'displayname' + | 'usertype' + | 'role'; +type SortDirection = 'asc' | 'desc'; const UserList = () => { const intl = useIntl(); @@ -98,6 +115,7 @@ const UserList = () => { const page = router.query.page ? Number(router.query.page) : 1; const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + const [sortDirection, setSortDirection] = useState('desc'); const { data, @@ -106,9 +124,18 @@ const UserList = () => { } = useSWR( `/api/v1/user?take=${currentPageSize}&skip=${ pageIndex * currentPageSize - }&sort=${currentSort}` + }&sort=${currentSort}&sortDirection=${sortDirection}` ); + const handleSortChange = (sortKey: Sort) => { + if (currentSort === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setCurrentSort(sortKey); + setSortDirection('desc'); + } + }; + const [isDeleting, setDeleting] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [deleteModal, setDeleteModal] = useState<{ @@ -133,6 +160,9 @@ const UserList = () => { setCurrentSort(filterSettings.currentSort); setCurrentPageSize(filterSettings.currentPageSize); + if (filterSettings.sortDirection) { + setSortDirection(filterSettings.sortDirection); + } } }, []); @@ -142,9 +172,74 @@ const UserList = () => { JSON.stringify({ currentSort, currentPageSize, + sortDirection, }) ); - }, [currentSort, currentPageSize]); + }, [currentSort, currentPageSize, sortDirection]); + + const SortableColumnHeader = ({ + sortKey, + currentSort, + sortDirection, + onSortChange, + children, + }: { + sortKey: Sort; + currentSort: Sort; + sortDirection: SortDirection; + onSortChange: (sortKey: Sort) => void; + children: React.ReactNode; + }) => { + const intl = useIntl(); + + const getTooltip = () => { + if (currentSort === sortKey) { + return intl.formatMessage(messages.toggleSortDirection, { + direction: + sortDirection === 'asc' + ? intl.formatMessage(messages.descending) + : intl.formatMessage(messages.ascending), + }); + } + + switch (sortKey) { + case 'displayname': + return intl.formatMessage(messages.sortByUser); + case 'requests': + return intl.formatMessage(messages.sortByRequests); + case 'usertype': + return intl.formatMessage(messages.sortByType); + case 'role': + return intl.formatMessage(messages.sortByRole); + case 'created': + return intl.formatMessage(messages.sortByJoined); + default: + return intl.formatMessage(messages.sortBy, { field: sortKey }); + } + }; + + return ( + onSortChange(sortKey)} + data-testid={`column-header-${sortKey}`} + title={getTooltip()} + > +
+ {children} + {currentSort === sortKey && ( + + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + )} +
+
+ ); + }; const isUserPermsEditable = (userId: number) => userId !== 1 && userId !== currentUser?.id; @@ -519,7 +614,7 @@ const UserList = () => { {intl.formatMessage(messages.createlocaluser)} -
- - - - -
@@ -584,11 +654,46 @@ const UserList = () => { /> )} - {intl.formatMessage(messages.user)} - {intl.formatMessage(messages.totalrequests)} - {intl.formatMessage(messages.accounttype)} - {intl.formatMessage(messages.role)} - {intl.formatMessage(messages.created)} + + {intl.formatMessage(messages.user)} + + + {intl.formatMessage(messages.totalrequests)} + + + {intl.formatMessage(messages.accounttype)} + + + {intl.formatMessage(messages.role)} + + + {intl.formatMessage(messages.created)} + {(data.results ?? []).length > 1 && (