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

View File

@@ -3909,8 +3909,14 @@ 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
schema:
type: string
enum: [asc, desc]
default: desc
- in: query - in: query
name: q name: q
required: false required: false

View File

@@ -42,6 +42,9 @@ 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 sortDirection =
(req.query.sortDirection as string) === 'asc' ? 'ASC' : 'DESC';
let query = getRepository(User).createQueryBuilder('user'); let query = getRepository(User).createQueryBuilder('user');
if (q) { if (q) {
@@ -56,28 +59,31 @@ router.get('/', async (req, res, next) => {
} }
switch (req.query.sort) { switch (req.query.sort) {
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
@@ -87,10 +93,16 @@ 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.orderBy('user.permissions', 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,
@@ -77,14 +80,27 @@ 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}',
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();
@@ -94,10 +110,12 @@ const UserList = () => {
const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname'); const [currentSort, setCurrentSort] = useState<Sort>('displayname');
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [localUsers, setLocalUsers] = useState<User[]>([]);
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 [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const { const {
data, data,
@@ -106,9 +124,59 @@ 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=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 [isDeleting, setDeleting] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ const [deleteModal, setDeleteModal] = useState<{
@@ -133,6 +201,9 @@ const UserList = () => {
setCurrentSort(filterSettings.currentSort); setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize); setCurrentPageSize(filterSettings.currentPageSize);
if (filterSettings.sortDirection) {
setSortDirection(filterSettings.sortDirection);
}
} }
}, []); }, []);
@@ -142,9 +213,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 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) => const isUserPermsEditable = (userId: number) =>
userId !== 1 && userId !== currentUser?.id; userId !== 1 && userId !== currentUser?.id;
@@ -541,28 +677,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-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 <select
id="sort" id="sort"
name="sort" name="sort"
onChange={(e) => { onChange={(e) => {
setCurrentSort(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>
@@ -584,11 +739,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
@@ -604,7 +794,7 @@ const UserList = () => {
</tr> </tr>
</thead> </thead>
<Table.TBody> <Table.TBody>
{data?.results.map((user) => ( {localUsers.map((user) => (
<tr key={`user-list-${user.id}`} data-testid="user-list-row"> <tr key={`user-list-${user.id}`} data-testid="user-list-row">
<Table.TD> <Table.TD>
{isUserPermsEditable(user.id) && ( {isUserPermsEditable(user.id) && (

View File

@@ -1282,6 +1282,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",
@@ -1291,6 +1292,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!",
@@ -1312,9 +1314,13 @@
"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.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.",