Compare commits

...

7 Commits

Author SHA1 Message Date
0xsysr3ll
0a7529dc07 refactor(userlist): streamline user sorting logic and improve performance 2025-06-17 20:02:25 +02:00
0xsysr3ll
cbee8fd843 feat(userlist): implement local user sorting and update sorting logic
This prevents useless API calls when changing sorting filters.
2025-06-17 19:39:03 +02:00
0xsysr3ll
b9435427dc refactor(userlist): reorder sorting options to match columns order 2025-06-17 19:39:03 +02:00
0xsysr3ll
8ceec0f9c4 fix(userlist): bring back the sort dropdown
- Restore sort dropdown for better mobile usability
- Add new sort options (Type, Role) to match column header sorting
- Implement sort direction toggle (asc/desc) with intuitive icon indicator
- Fix displayname sorting in backend to properly sort by username/plexUsername
2025-06-17 19:39:03 +02:00
0xsysr3ll
5a1040bb61 fix(userlist): update cypress API intercepts to match user list requests 2025-06-17 19:39:03 +02:00
0xsysr3ll
a97a3f3512 refactor(userlist): remove unused sort messages from User List 2025-06-17 19:39:03 +02:00
0xsysr3ll
1dbacec4f9 feat(userlist): add sortable columns to User List 2025-06-17 19:39:03 +02:00
5 changed files with 254 additions and 40 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();

View File

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

View File

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

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,
@@ -77,14 +80,27 @@ 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}',
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();
@@ -94,10 +110,12 @@ const UserList = () => {
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [localUsers, setLocalUsers] = useState<User[]>([]);
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const {
data,
@@ -106,9 +124,59 @@ const UserList = () => {
} = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&sort=${currentSort}`
}&sort=created&sortDirection=desc`
);
const sortUsers = (
users: User[],
sortKey: Sort,
direction: SortDirection
) => {
return [...users].sort((a, b) => {
let comparison = 0;
switch (sortKey) {
case 'displayname':
comparison = a.displayName.localeCompare(b.displayName);
break;
case 'requests':
comparison = (a.requestCount ?? 0) - (b.requestCount ?? 0);
break;
case 'usertype':
comparison = a.userType - b.userType;
break;
case 'role':
comparison = a.permissions - b.permissions;
break;
case 'created':
comparison =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
default:
comparison = 0;
}
return direction === 'asc' ? comparison : -comparison;
});
};
useEffect(() => {
if (data?.results) {
setLocalUsers(sortUsers(data.results, currentSort, sortDirection));
}
}, [data, currentSort, sortDirection]);
const handleSortChange = (sortKey: Sort) => {
const newSortDirection =
currentSort === sortKey
? sortDirection === 'asc'
? 'desc'
: 'asc'
: 'desc';
setCurrentSort(sortKey);
setSortDirection(newSortDirection);
setLocalUsers(sortUsers(localUsers, sortKey, newSortDirection));
};
const [isDeleting, setDeleting] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [deleteModal, setDeleteModal] = useState<{
@@ -133,6 +201,9 @@ const UserList = () => {
setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize);
if (filterSettings.sortDirection) {
setSortDirection(filterSettings.sortDirection);
}
}
}, []);
@@ -142,9 +213,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 hover:bg-gray-700"
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;
@@ -541,28 +677,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">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<button
type="button"
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"
onClick={() =>
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
}
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
id="sort"
name="sort"
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push(router.pathname);
}}
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>
@@ -584,11 +739,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
@@ -604,7 +794,7 @@ const UserList = () => {
</tr>
</thead>
<Table.TBody>
{data?.results.map((user) => (
{localUsers.map((user) => (
<tr key={`user-list-${user.id}`} data-testid="user-list-row">
<Table.TD>
{isUserPermsEditable(user.id) && (

View File

@@ -1282,6 +1282,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",
@@ -1291,6 +1292,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!",
@@ -1312,9 +1314,13 @@
"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.totalrequests": "Requests",
"components.UserList.user": "User",
"components.UserList.usercreatedfailed": "Something went wrong while creating the user.",