diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts index 503bd23f1b..33ead0c222 100644 --- a/cypress/e2e/user/user-list.cy.ts +++ b/cypress/e2e/user/user-list.cy.ts @@ -40,7 +40,6 @@ describe('User List', () => { cy.get('[data-testid=modal-ok-button]').click(); - cy.wait('@user'); // Wait a little longer for the user list to fully re-render cy.wait(1000); @@ -60,7 +59,6 @@ describe('User List', () => { cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click(); - cy.wait('@user'); cy.wait(1000); cy.get('[data-testid=user-list-row]') diff --git a/overseerr-api.yml b/overseerr-api.yml index c48b6575f7..c663e28945 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3460,6 +3460,12 @@ paths: type: string enum: [created, updated, requests, displayname] default: created + - in: query + name: searchQuery + schema: + type: string + nullable: true + example: 'steve@gmail.com' responses: '200': description: A JSON array of all users diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 8bcde77444..8b4c378c61 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -30,8 +30,15 @@ router.get('/', async (req, res, next) => { try { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; + const searchQuery = req.query.searchQuery; let query = getRepository(User).createQueryBuilder('user'); + if (searchQuery) { + await query.where('user.email like :query OR user.username like :query', { + query: `%${searchQuery}%`, + }); + } + switch (req.query.sort) { case 'updated': query = query.orderBy('user.updatedAt', 'DESC'); diff --git a/src/components/UserList/hooks.tsx b/src/components/UserList/hooks.tsx new file mode 100644 index 0000000000..b41af4ea53 --- /dev/null +++ b/src/components/UserList/hooks.tsx @@ -0,0 +1,36 @@ +import type { Sort } from '@app/components/UserList'; +import type { User } from '@app/hooks/useUser'; +import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; +import { isEqual } from 'lodash'; +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; + +export const useUsers = ({ + pageIndex, + currentPageSize, + searchString, + currentSort, +}: { + pageIndex: number; + currentPageSize: number; + searchString: string; + currentSort: Sort; +}) => { + const [users, setUsers] = useState([]); + + const { data, mutate, isLoading } = useSWR( + `/api/v1/user?take=${currentPageSize}&skip=${ + pageIndex * currentPageSize + }&searchQuery=${ + searchString ? encodeURIComponent(searchString) : '%00' + }&sort=${currentSort}` + ); + + useEffect(() => { + if (!isLoading && data?.results && !isEqual(data.results, users)) { + setUsers(data.results); + } + }, [isLoading, data?.results, users]); + + return { users, mutate, isLoading, pageInfo: data?.pageInfo }; +}; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 5aec7b3d95..aed2d9214a 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -8,6 +8,7 @@ import PageTitle from '@app/components/Common/PageTitle'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import Table from '@app/components/Common/Table'; import BulkEditModal from '@app/components/UserList/BulkEditModal'; +import { useUsers } from '@app/components/UserList/hooks'; import PlexImportModal from '@app/components/UserList/PlexImportModal'; import useSettings from '@app/hooks/useSettings'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; @@ -20,19 +21,21 @@ import { ChevronLeftIcon, ChevronRightIcon, InboxArrowDownIcon, + MagnifyingGlassIcon, PencilIcon, UserPlusIcon, } from '@heroicons/react/24/solid'; -import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import { hasPermission } from '@server/lib/permissions'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; +import { debounce } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import type React from 'react'; import { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; -import useSWR from 'swr'; + import * as Yup from 'yup'; const messages = defineMessages({ @@ -76,9 +79,10 @@ const messages = defineMessages({ sortRequests: 'Request Count', localLoginDisabled: 'The Enable Local Sign-In setting is currently disabled.', + searchUsersPlaceholder: 'Search Users', }); -type Sort = 'created' | 'updated' | 'requests' | 'displayname'; +export type Sort = 'created' | 'updated' | 'requests' | 'displayname'; const UserList = () => { const intl = useIntl(); @@ -88,20 +92,22 @@ const UserList = () => { const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const [currentSort, setCurrentSort] = useState('displayname'); const [currentPageSize, setCurrentPageSize] = useState(10); + const [searchString, setSearchString] = useState(''); + + const debounceSetSearchString = debounce((str: string) => { + setSearchString(str); + }, 200); const page = router.query.page ? Number(router.query.page) : 1; const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); const { - data, - error, mutate: revalidate, - } = useSWR( - `/api/v1/user?take=${currentPageSize}&skip=${ - pageIndex * currentPageSize - }&sort=${currentSort}` - ); + isLoading, + users, + pageInfo, + } = useUsers({ currentPageSize, pageIndex, searchString, currentSort }); const [isDeleting, setDeleting] = useState(false); const [showImportModal, setShowImportModal] = useState(false); @@ -145,20 +151,14 @@ const UserList = () => { const isAllUsersSelected = () => { return ( selectedUsers.length === - data?.results.filter((user) => user.id !== currentUser?.id).length + users.filter((user) => user.id !== currentUser?.id).length ); }; const isUserSelected = (userId: number) => selectedUsers.includes(userId); const toggleAllUsers = () => { - if ( - data && - selectedUsers.length >= 0 && - selectedUsers.length < data?.results.length - 1 - ) { + if (selectedUsers.length >= 0 && selectedUsers.length < users.length - 1) { setSelectedUsers( - data.results - .filter((user) => isUserPermsEditable(user.id)) - .map((u) => u.id) + users.filter((user) => isUserPermsEditable(user.id)).map((u) => u.id) ); } else { setSelectedUsers([]); @@ -194,10 +194,6 @@ const UserList = () => { } }; - if (!data && !error) { - return ; - } - const CreateUserSchema = Yup.object().shape({ email: Yup.string() .required(intl.formatMessage(messages.validationEmail)) @@ -212,11 +208,7 @@ const UserList = () => { ), }); - if (!data) { - return ; - } - - const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasNextPage = (pageInfo?.pages ?? 0) > pageIndex + 1; const hasPrevPage = pageIndex > 0; const passwordGenerationEnabled = @@ -459,7 +451,7 @@ const UserList = () => { revalidate(); }} selectedUserIds={selectedUsers} - users={data.results} + users={users} /> @@ -490,6 +482,7 @@ const UserList = () => { className="mb-2 flex-grow sm:mb-0 sm:mr-2" buttonType="primary" onClick={() => setCreateModal({ isOpen: true })} + disabled={!users.length} > {intl.formatMessage(messages.createlocaluser)} @@ -498,6 +491,7 @@ const UserList = () => { className="flex-grow lg:mr-2" buttonType="primary" onClick={() => setShowImportModal(true)} + disabled={!users.length} > {intl.formatMessage(messages.importfromplex)} @@ -516,6 +510,7 @@ const UserList = () => { }} value={currentSort} className="rounded-r-only" + disabled={!users.length} >