diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts index 82117023..45fdd8c3 100644 --- a/cypress/e2e/user/user-list.cy.ts +++ b/cypress/e2e/user/user-list.cy.ts @@ -36,7 +36,7 @@ describe('User List', () => { cy.get('#email').type(testUser.emailAddress); cy.get('#password').type(testUser.password); - cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + cy.intercept('/api/v1/user*').as('user'); cy.get('[data-testid=modal-ok-button]').click(); @@ -56,7 +56,7 @@ describe('User List', () => { cy.get('[data-testid=modal-title]').should('contain', `Delete User`); - cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + cy.intercept('/api/v1/user*').as('user'); cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click(); @@ -67,4 +67,37 @@ describe('User List', () => { .contains(testUser.emailAddress) .should('not.exist'); }); + + it('sorts by column headers and updates request params and row order', () => { + cy.intercept('GET', '/api/v1/user?*').as('userListFetch'); + + cy.visit('/users'); + cy.wait('@userListFetch'); + + cy.get('[data-testid=column-header-displayname]').click(); + cy.wait('@userListFetch').then((interception) => { + const url = interception.request.url; + expect(url).to.include('sort=displayname'); + expect(url).to.include('sortDirection=asc'); + }); + + cy.get( + '[data-testid=user-list-row] [data-testid=user-list-username-link]' + ).then(($links) => { + const displayNames = $links + .toArray() + .map((el) => (el as HTMLElement).innerText.trim().toLowerCase()); + const sortedAsc = [...displayNames].sort((a, b) => a.localeCompare(b)); + expect(displayNames).to.deep.equal(sortedAsc); + }); + + cy.get('[data-testid=column-header-created]').click(); + cy.wait('@userListFetch').then((interception) => { + const url = interception.request.url; + expect(url).to.include('sort=created'); + expect(url).to.include('sortDirection=desc'); + }); + + cy.get('[data-testid=user-list-row]').should('have.length.greaterThan', 0); + }); }); diff --git a/seerr-api.yml b/seerr-api.yml index 75e52eae..75ea9f4f 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4117,8 +4117,16 @@ paths: name: sort schema: type: string - enum: [created, updated, requests, displayname] + enum: [created, updated, requests, displayname, usertype, role] default: created + - in: query + name: sortDirection + description: | + Sort direction. When omitted, the server chooses the direction per sort + field (e.g. displayname defaults to asc, requests/updated to desc). + schema: + type: string + enum: [asc, desc] - in: query name: q required: false diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 7a8ad407..765215a3 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -45,6 +45,35 @@ 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 sortParam = req.query.sort ? req.query.sort.toString() : undefined; + const sortDirectionQuery = req.query.sortDirection + ? req.query.sortDirection.toString().toLowerCase() + : undefined; + + let sortDirection: 'ASC' | 'DESC'; + if (sortDirectionQuery === 'asc') { + sortDirection = 'ASC'; + } else if (sortDirectionQuery === 'desc') { + sortDirection = 'DESC'; + } else { + switch (sortParam) { + case 'displayname': + sortDirection = 'ASC'; + break; + case 'requests': + case 'updated': + sortDirection = 'DESC'; + break; + case 'created': + case 'usertype': + case 'role': + case undefined: + default: + sortDirection = 'ASC'; + break; + } + } + let query = getRepository(User).createQueryBuilder('user'); if (q) { @@ -58,29 +87,32 @@ router.get('/', async (req, res, next) => { query.andWhereInIds(includeIds); } - switch (req.query.sort) { + switch (sortParam) { + 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 @@ -90,10 +122,25 @@ 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 + .addSelect( + `CASE + WHEN user.id = 1 THEN 0 + WHEN (user.permissions & ${Permission.ADMIN}) != 0 THEN 1 + ELSE 2 + END`, + 'role_sort_key' + ) + .orderBy('role_sort_key', 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 6e9354be..457fb402 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -19,8 +19,11 @@ import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { BarsArrowDownIcon, + BarsArrowUpIcon, + ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, + ChevronUpIcon, InboxArrowDownIcon, PencilIcon, UserPlusIcon, @@ -78,14 +81,28 @@ const messages = defineMessages('components.UserList', { autogeneratepasswordTip: 'Email a server-generated password to the user', validationUsername: 'You must provide an username', validationEmail: 'Email required', - sortCreated: 'Join Date', - sortDisplayName: 'Display Name', - sortRequests: 'Request Count', + 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}', + toggleSortDirectionAria: 'Toggle sort direction', + ascending: 'ascending', + descending: 'descending', localLoginDisabled: '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(); @@ -93,13 +110,20 @@ const UserList = () => { const settings = useSettings(); const { addToast } = useToasts(); const { user: currentUser, hasPermission: currentHasPermission } = useUser(); - const [currentSort, setCurrentSort] = useState('displayname'); + const [currentSort, setCurrentSort] = useState('created'); const [currentPageSize, setCurrentPageSize] = useState(10); const page = router.query.page ? Number(router.query.page) : 1; const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + const defaultSortDirection = (sortKey: Sort): SortDirection => + sortKey === 'requests' || sortKey === 'updated' ? 'desc' : 'asc'; + + const [sortDirection, setSortDirection] = useState(() => + defaultSortDirection('created') + ); + const { data, error, @@ -107,9 +131,19 @@ 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(defaultSortDirection(sortKey)); + } + updateQueryParams('page', '1'); + }; + const [isDeleting, setDeleting] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [deleteModal, setDeleteModal] = useState<{ @@ -134,6 +168,9 @@ const UserList = () => { setCurrentSort(filterSettings.currentSort); setCurrentPageSize(filterSettings.currentPageSize); + if (filterSettings.sortDirection) { + setSortDirection(filterSettings.sortDirection); + } } }, []); @@ -143,9 +180,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; @@ -546,28 +648,47 @@ const UserList = () => { +
- - - +
@@ -589,11 +710,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 && (