feat(userlist): add sortable columns to User List (#1615)

This commit is contained in:
0xsysr3ll
2026-03-21 00:45:00 +01:00
committed by GitHub
parent 25e376c74f
commit eaf397a021
5 changed files with 295 additions and 44 deletions

View File

@@ -36,7 +36,7 @@ describe('User List', () => {
cy.get('#email').type(testUser.emailAddress); cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password); 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(); 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.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(); cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
@@ -67,4 +67,37 @@ describe('User List', () => {
.contains(testUser.emailAddress) .contains(testUser.emailAddress)
.should('not.exist'); .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);
});
}); });

View File

@@ -4117,8 +4117,16 @@ paths:
name: sort name: sort
schema: schema:
type: string type: string
enum: [created, updated, requests, displayname] enum: [created, updated, requests, displayname, usertype, role]
default: created 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 - in: query
name: q name: q
required: false required: false

View File

@@ -45,6 +45,35 @@ router.get('/', async (req, res, next) => {
: Math.max(10, includeIds.length); : Math.max(10, includeIds.length);
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
const q = req.query.q ? req.query.q.toString().toLowerCase() : ''; 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'); let query = getRepository(User).createQueryBuilder('user');
if (q) { if (q) {
@@ -58,29 +87,32 @@ router.get('/', async (req, res, next) => {
query.andWhereInIds(includeIds); query.andWhereInIds(includeIds);
} }
switch (req.query.sort) { switch (sortParam) {
case 'created':
query = query.orderBy('user.createdAt', sortDirection);
break;
case 'updated': case 'updated':
query = query.orderBy('user.updatedAt', 'DESC'); query = query.orderBy('user.updatedAt', sortDirection);
break; break;
case 'displayname': case 'displayname':
query = query query = query
.addSelect( .addSelect(
`CASE WHEN (user.username IS NULL OR user.username = '') THEN ( `CASE WHEN (user.username IS NULL OR user.username = '') THEN (
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
"user"."email" "user"."email"
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE ELSE
LOWER(user.jellyfinUsername) LOWER(user.plexUsername)
END) END)
ELSE ELSE
LOWER(user.jellyfinUsername) LOWER(user.username)
END) END`,
ELSE
LOWER(user.username)
END`,
'displayname_sort_key' 'displayname_sort_key'
) )
.orderBy('displayname_sort_key', 'ASC'); .orderBy('displayname_sort_key', sortDirection);
break; break;
case 'requests': case 'requests':
query = query query = query
@@ -90,10 +122,25 @@ router.get('/', async (req, res, next) => {
.from(MediaRequest, 'request') .from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id'); .where('request.requestedBy.id = user.id');
}, 'request_count') }, '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; break;
default: default:
query = query.orderBy('user.id', 'ASC'); query = query.orderBy('user.id', sortDirection);
break; break;
} }

View File

@@ -19,8 +19,11 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { import {
BarsArrowDownIcon, BarsArrowDownIcon,
BarsArrowUpIcon,
ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ChevronUpIcon,
InboxArrowDownIcon, InboxArrowDownIcon,
PencilIcon, PencilIcon,
UserPlusIcon, UserPlusIcon,
@@ -78,14 +81,28 @@ const messages = defineMessages('components.UserList', {
autogeneratepasswordTip: 'Email a server-generated password to the user', autogeneratepasswordTip: 'Email a server-generated password to the user',
validationUsername: 'You must provide an username', validationUsername: 'You must provide an username',
validationEmail: 'Email required', validationEmail: 'Email required',
sortCreated: 'Join Date', sortBy: 'Sort by {field}',
sortDisplayName: 'Display Name', sortByUser: 'Sort by username',
sortRequests: 'Request Count', 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: localLoginDisabled:
'The <strong>Enable Local Sign-In</strong> setting is currently disabled.', 'The <strong>Enable Local Sign-In</strong> 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 UserList = () => {
const intl = useIntl(); const intl = useIntl();
@@ -93,13 +110,20 @@ const UserList = () => {
const settings = useSettings(); const settings = useSettings();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname'); const [currentSort, setCurrentSort] = useState<Sort>('created');
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const page = router.query.page ? Number(router.query.page) : 1; const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1; const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const defaultSortDirection = (sortKey: Sort): SortDirection =>
sortKey === 'requests' || sortKey === 'updated' ? 'desc' : 'asc';
const [sortDirection, setSortDirection] = useState<SortDirection>(() =>
defaultSortDirection('created')
);
const { const {
data, data,
error, error,
@@ -107,9 +131,19 @@ const UserList = () => {
} = useSWR<UserResultsResponse>( } = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${ `/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize 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 [isDeleting, setDeleting] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ const [deleteModal, setDeleteModal] = useState<{
@@ -134,6 +168,9 @@ const UserList = () => {
setCurrentSort(filterSettings.currentSort); setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize); setCurrentPageSize(filterSettings.currentPageSize);
if (filterSettings.sortDirection) {
setSortDirection(filterSettings.sortDirection);
}
} }
}, []); }, []);
@@ -143,9 +180,74 @@ const UserList = () => {
JSON.stringify({ JSON.stringify({
currentSort, currentSort,
currentPageSize, 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 (
<Table.TH
className="cursor-pointer"
onClick={() => onSortChange(sortKey)}
data-testid={`column-header-${sortKey}`}
title={getTooltip()}
>
<div className="flex items-center">
<span>{children}</span>
{currentSort === sortKey && (
<span className="ml-1">
{sortDirection === 'asc' ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
)}
</div>
</Table.TH>
);
};
const isUserPermsEditable = (userId: number) => const isUserPermsEditable = (userId: number) =>
userId !== 1 && userId !== currentUser?.id; userId !== 1 && userId !== currentUser?.id;
@@ -546,28 +648,47 @@ const UserList = () => {
</span> </span>
</Button> </Button>
</div> </div>
<div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0"> <div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100"> <button
<BarsArrowDownIcon className="h-6 w-6" /> type="button"
</span> className="inline-flex cursor-pointer items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100"
onClick={() => {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
updateQueryParams('page', '1');
}}
aria-label={intl.formatMessage(messages.toggleSortDirectionAria)}
title={
sortDirection === 'asc'
? intl.formatMessage(messages.descending)
: intl.formatMessage(messages.ascending)
}
>
{sortDirection === 'asc' ? (
<BarsArrowUpIcon className="h-6 w-6" />
) : (
<BarsArrowDownIcon className="h-6 w-6" />
)}
</button>
<select <select
id="sort" id="sort"
name="sort" name="sort"
onChange={(e) => { onChange={(e) => handleSortChange(e.target.value as Sort)}
setCurrentSort(e.target.value as Sort);
router.push(router.pathname);
}}
value={currentSort} value={currentSort}
className="rounded-r-only" className="rounded-r-only"
> >
<option value="created"> <option value="displayname">
{intl.formatMessage(messages.sortCreated)} {intl.formatMessage(messages.username)}
</option> </option>
<option value="requests"> <option value="requests">
{intl.formatMessage(messages.sortRequests)} {intl.formatMessage(messages.totalrequests)}
</option> </option>
<option value="displayname"> <option value="usertype">
{intl.formatMessage(messages.sortDisplayName)} {intl.formatMessage(messages.accounttype)}
</option>
<option value="role">{intl.formatMessage(messages.role)}</option>
<option value="created">
{intl.formatMessage(messages.created)}
</option> </option>
</select> </select>
</div> </div>
@@ -589,11 +710,46 @@ const UserList = () => {
/> />
)} )}
</Table.TH> </Table.TH>
<Table.TH>{intl.formatMessage(messages.user)}</Table.TH> <SortableColumnHeader
<Table.TH>{intl.formatMessage(messages.totalrequests)}</Table.TH> sortKey="displayname"
<Table.TH>{intl.formatMessage(messages.accounttype)}</Table.TH> currentSort={currentSort}
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH> sortDirection={sortDirection}
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH> onSortChange={handleSortChange}
>
{intl.formatMessage(messages.user)}
</SortableColumnHeader>
<SortableColumnHeader
sortKey="requests"
currentSort={currentSort}
sortDirection={sortDirection}
onSortChange={handleSortChange}
>
{intl.formatMessage(messages.totalrequests)}
</SortableColumnHeader>
<SortableColumnHeader
sortKey="usertype"
currentSort={currentSort}
sortDirection={sortDirection}
onSortChange={handleSortChange}
>
{intl.formatMessage(messages.accounttype)}
</SortableColumnHeader>
<SortableColumnHeader
sortKey="role"
currentSort={currentSort}
sortDirection={sortDirection}
onSortChange={handleSortChange}
>
{intl.formatMessage(messages.role)}
</SortableColumnHeader>
<SortableColumnHeader
sortKey="created"
currentSort={currentSort}
sortDirection={sortDirection}
onSortChange={handleSortChange}
>
{intl.formatMessage(messages.created)}
</SortableColumnHeader>
<Table.TH className="text-right"> <Table.TH className="text-right">
{(data.results ?? []).length > 1 && ( {(data.results ?? []).length > 1 && (
<Button <Button

View File

@@ -1348,6 +1348,7 @@
"components.TvDetails.watchtrailer": "Watch Trailer", "components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserList.accounttype": "Type", "components.UserList.accounttype": "Type",
"components.UserList.admin": "Admin", "components.UserList.admin": "Admin",
"components.UserList.ascending": "ascending",
"components.UserList.autogeneratepassword": "Automatically Generate Password", "components.UserList.autogeneratepassword": "Automatically Generate Password",
"components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user", "components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user",
"components.UserList.bulkedit": "Bulk Edit", "components.UserList.bulkedit": "Bulk Edit",
@@ -1357,6 +1358,7 @@
"components.UserList.creating": "Creating…", "components.UserList.creating": "Creating…",
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.", "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
"components.UserList.deleteuser": "Delete User", "components.UserList.deleteuser": "Delete User",
"components.UserList.descending": "descending",
"components.UserList.edituser": "Edit User Permissions", "components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address", "components.UserList.email": "Email Address",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!", "components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
@@ -1378,9 +1380,14 @@
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.", "components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
"components.UserList.plexuser": "Plex User", "components.UserList.plexuser": "Plex User",
"components.UserList.role": "Role", "components.UserList.role": "Role",
"components.UserList.sortCreated": "Join Date", "components.UserList.sortBy": "Sort by {field}",
"components.UserList.sortDisplayName": "Display Name", "components.UserList.sortByJoined": "Sort by join date",
"components.UserList.sortRequests": "Request Count", "components.UserList.sortByRequests": "Sort by number of requests",
"components.UserList.sortByRole": "Sort by user role",
"components.UserList.sortByType": "Sort by account type",
"components.UserList.sortByUser": "Sort by username",
"components.UserList.toggleSortDirection": "Click again to sort {direction}",
"components.UserList.toggleSortDirectionAria": "Toggle sort direction",
"components.UserList.totalrequests": "Requests", "components.UserList.totalrequests": "Requests",
"components.UserList.user": "User", "components.UserList.user": "User",
"components.UserList.usercreatedfailed": "Something went wrong while creating the user.", "components.UserList.usercreatedfailed": "Something went wrong while creating the user.",