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('#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);
});
});

View File

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

View File

@@ -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,9 +87,12 @@ 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
@@ -73,14 +105,14 @@ router.get('/', async (req, res, next) => {
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.jellyfinUsername)
LOWER(user.plexUsername)
END)
ELSE
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;
}

View File

@@ -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 <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 intl = useIntl();
@@ -93,13 +110,20 @@ const UserList = () => {
const settings = useSettings();
const { addToast } = useToasts();
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 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<SortDirection>(() =>
defaultSortDirection('created')
);
const {
data,
error,
@@ -107,9 +131,19 @@ const UserList = () => {
} = useSWR<UserResultsResponse>(
`/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 (
<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) =>
userId !== 1 && userId !== currentUser?.id;
@@ -546,28 +648,47 @@ const UserList = () => {
</span>
</Button>
</div>
<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
type="button"
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" />
</span>
)}
</button>
<select
id="sort"
name="sort"
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push(router.pathname);
}}
onChange={(e) => handleSortChange(e.target.value as Sort)}
value={currentSort}
className="rounded-r-only"
>
<option value="created">
{intl.formatMessage(messages.sortCreated)}
<option value="displayname">
{intl.formatMessage(messages.username)}
</option>
<option value="requests">
{intl.formatMessage(messages.sortRequests)}
{intl.formatMessage(messages.totalrequests)}
</option>
<option value="displayname">
{intl.formatMessage(messages.sortDisplayName)}
<option value="usertype">
{intl.formatMessage(messages.accounttype)}
</option>
<option value="role">{intl.formatMessage(messages.role)}</option>
<option value="created">
{intl.formatMessage(messages.created)}
</option>
</select>
</div>
@@ -589,11 +710,46 @@ const UserList = () => {
/>
)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.user)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.totalrequests)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.accounttype)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
<SortableColumnHeader
sortKey="displayname"
currentSort={currentSort}
sortDirection={sortDirection}
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">
{(data.results ?? []).length > 1 && (
<Button

View File

@@ -1348,6 +1348,7 @@
"components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserList.accounttype": "Type",
"components.UserList.admin": "Admin",
"components.UserList.ascending": "ascending",
"components.UserList.autogeneratepassword": "Automatically Generate Password",
"components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user",
"components.UserList.bulkedit": "Bulk Edit",
@@ -1357,6 +1358,7 @@
"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.deleteuser": "Delete User",
"components.UserList.descending": "descending",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"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.plexuser": "Plex User",
"components.UserList.role": "Role",
"components.UserList.sortCreated": "Join Date",
"components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count",
"components.UserList.sortBy": "Sort by {field}",
"components.UserList.sortByJoined": "Sort by join date",
"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.user": "User",
"components.UserList.usercreatedfailed": "Something went wrong while creating the user.",