feat(ui): Add 'Page Size' setting for request/user list pages (#957)

This commit is contained in:
TheCatLady
2021-02-18 20:37:08 -05:00
committed by GitHub
parent 77b2d9ea22
commit 621db89328
11 changed files with 233 additions and 82 deletions

View File

@@ -1070,9 +1070,6 @@ components:
pages: pages:
type: number type: number
example: 10 example: 10
pageSize:
type: number
example: 10
results: results:
type: number type: number
example: 100 example: 100
@@ -2747,10 +2744,22 @@ paths:
/user: /user:
get: get:
summary: Get all users summary: Get all users
description: Returns all users in a JSON array. description: Returns all users in a JSON object.
tags: tags:
- users - users
parameters: parameters:
- in: query
name: take
schema:
type: number
nullable: true
example: 20
- in: query
name: skip
schema:
type: number
nullable: true
example: 0
- in: query - in: query
name: sort name: sort
schema: schema:
@@ -2763,9 +2772,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: array type: object
items: properties:
$ref: '#/components/schemas/User' pageInfo:
$ref: '#/components/schemas/PageInfo'
results:
type: array
items:
$ref: '#/components/schemas/User'
post: post:
summary: Create new user summary: Create new user
description: | description: |

View File

@@ -1,6 +1,11 @@
import type { User } from '../../entity/User';
import { MediaRequest } from '../../entity/MediaRequest'; import { MediaRequest } from '../../entity/MediaRequest';
import { PaginatedResponse } from './common'; import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
results: User[];
}
export interface UserRequestsResponse extends PaginatedResponse { export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[]; results: MediaRequest[];
} }

View File

@@ -14,9 +14,8 @@ import { User } from '../entity/User';
const requestRoutes = Router(); const requestRoutes = Router();
requestRoutes.get('/', async (req, res, next) => { requestRoutes.get('/', async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
try { try {
const pageSize = req.query.take ? Number(req.query.take) : 20; const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
let statusFilter: MediaRequestStatus[]; let statusFilter: MediaRequestStatus[];
@@ -79,7 +78,7 @@ requestRoutes.get('/', async (req, res, next) => {
sortFilter = 'request.id'; sortFilter = 'request.id';
} }
let query = requestRepository let query = getRepository(MediaRequest)
.createQueryBuilder('request') .createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media') .leftJoinAndSelect('request.media', 'media')
.leftJoinAndSelect('request.seasons', 'seasons') .leftJoinAndSelect('request.seasons', 'seasons')

View File

@@ -9,46 +9,63 @@ import logger from '../../logger';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { UserType } from '../../constants/user'; import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth'; import { isAuthenticated } from '../../middleware/auth';
import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces'; import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings'; import userSettingsRoutes from './usersettings';
const router = Router(); const router = Router();
router.get('/', async (req, res) => { router.get('/', async (req, res, next) => {
let query = getRepository(User).createQueryBuilder('user'); try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let query = getRepository(User).createQueryBuilder('user');
switch (req.query.sort) { switch (req.query.sort) {
case 'updated': case 'updated':
query = query.orderBy('user.updatedAt', 'DESC'); query = query.orderBy('user.updatedAt', 'DESC');
break; break;
case 'displayname': case 'displayname':
query = query.orderBy( query = query.orderBy(
'(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)', '(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)',
'ASC' 'ASC'
); );
break; break;
case 'requests': case 'requests':
query = query query = query
.addSelect((subQuery) => { .addSelect((subQuery) => {
return subQuery return subQuery
.select('COUNT(request.id)', 'requestCount') .select('COUNT(request.id)', 'requestCount')
.from(MediaRequest, 'request') .from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id'); .where('request.requestedBy.id = user.id');
}, 'requestCount') }, 'requestCount')
.orderBy('requestCount', 'DESC'); .orderBy('requestCount', 'DESC');
break; break;
default: default:
query = query.orderBy('user.id', 'ASC'); query = query.orderBy('user.id', 'ASC');
break; break;
}
const [users, userCount] = await query
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(userCount / pageSize),
pageSize,
results: userCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: User.filterMany(
users,
req.user?.hasPermission(Permission.MANAGE_USERS)
),
} as UserResultsResponse);
} catch (e) {
next({ status: 500, message: e.message });
} }
const users = await query.getMany();
return res
.status(200)
.json(
User.filterMany(users, req.user?.hasPermission(Permission.MANAGE_USERS))
);
}); });
router.post( router.post(

View File

@@ -17,6 +17,7 @@ const messages = defineMessages({
modifiedBy: 'Last Modified By', modifiedBy: 'Last Modified By',
showingresults: showingresults:
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results', 'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
resultsperpage: 'Display {pageSize} results per page',
next: 'Next', next: 'Next',
previous: 'Previous', previous: 'Previous',
filterAll: 'All', filterAll: 'All',
@@ -38,10 +39,11 @@ const RequestList: React.FC = () => {
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [currentFilter, setCurrentFilter] = useState<Filter>('pending'); const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
const [currentSort, setCurrentSort] = useState<Sort>('added'); const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const { data, error, revalidate } = useSWR<RequestResultsResponse>( const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=10&skip=${ `/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * 10 pageIndex * currentPageSize
}&filter=${currentFilter}&sort=${currentSort}` }&filter=${currentFilter}&sort=${currentSort}`
); );
if (!data && !error) { if (!data && !error) {
@@ -160,9 +162,9 @@ const RequestList: React.FC = () => {
})} })}
{data.results.length === 0 && ( {data.results.length === 0 && (
<tr className="relative w-full h-24 p-2 text-white"> <tr className="relative h-24 p-2 text-white">
<Table.TD colSpan={6} noPadding> <Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center p-6"> <div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
<span className="text-base"> <span className="text-base">
{intl.formatMessage(messages.noresults)} {intl.formatMessage(messages.noresults)}
</span> </span>
@@ -184,33 +186,56 @@ const RequestList: React.FC = () => {
<tr className="bg-gray-700"> <tr className="bg-gray-700">
<Table.TD colSpan={6} noPadding> <Table.TD colSpan={6} noPadding>
<nav <nav
className="flex items-center justify-between px-6 py-3" className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
aria-label="Pagination" aria-label="Pagination"
> >
<div className="hidden sm:block"> <div className="hidden lg:flex lg:flex-1">
<p className="text-sm"> <p className="text-sm">
{intl.formatMessage(messages.showingresults, { {data.results.length > 0 &&
from: pageIndex * 10, intl.formatMessage(messages.showingresults, {
to: from: pageIndex * currentPageSize + 1,
data.results.length < 10 to:
? pageIndex * 10 + data.results.length data.results.length < currentPageSize
: (pageIndex + 1) * 10, ? pageIndex * currentPageSize + data.results.length
total: data.pageInfo.results, : (pageIndex + 1) * currentPageSize,
strong: function strong(msg) { total: data.pageInfo.results,
return <span className="font-medium">{msg}</span>; strong: function strong(msg) {
}, return <span className="font-medium">{msg}</span>;
})} },
})}
</p> </p>
</div> </div>
<div className="flex justify-start flex-1 sm:justify-end"> <div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="mr-2"> <span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
<Button {intl.formatMessage(messages.resultsperpage, {
disabled={!hasPrevPage} pageSize: (
onClick={() => setPageIndex((current) => current - 1)} <select
> id="pageSize"
{intl.formatMessage(messages.previous)} name="pageSize"
</Button> onChange={(e) => {
setPageIndex(0);
setCurrentPageSize(Number(e.target.value));
}}
value={currentPageSize}
className="inline short"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span> </span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)}
>
{intl.formatMessage(messages.previous)}
</Button>
<Button <Button
disabled={!hasNextPage} disabled={!hasNextPage}
onClick={() => setPageIndex((current) => current + 1)} onClick={() => setPageIndex((current) => current + 1)}

View File

@@ -402,7 +402,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
name="port" name="port"
type="text" type="text"
placeholder="7878" placeholder="7878"
className="port" className="short"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false); setIsValidated(false);
setFieldValue('port', e.target.value); setFieldValue('port', e.target.value);

View File

@@ -486,7 +486,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
id="port" id="port"
name="port" name="port"
placeholder="32400" placeholder="32400"
className="port" className="short"
/> />
</div> </div>
{errors.port && touched.port && ( {errors.port && touched.port && (

View File

@@ -431,7 +431,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
name="port" name="port"
type="text" type="text"
placeholder="8989" placeholder="8989"
className="port" className="short"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false); setIsValidated(false);
setFieldValue('port', e.target.value); setFieldValue('port', e.target.value);

View File

@@ -21,6 +21,7 @@ import Alert from '../Common/Alert';
import BulkEditModal from './BulkEditModal'; import BulkEditModal from './BulkEditModal';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import Link from 'next/link'; import Link from 'next/link';
import type { UserResultsResponse } from '../../../server/interfaces/api/userInterfaces';
const messages = defineMessages({ const messages = defineMessages({
users: 'Users', users: 'Users',
@@ -65,6 +66,11 @@ const messages = defineMessages({
sortUpdated: 'Last Updated', sortUpdated: 'Last Updated',
sortDisplayName: 'Display Name', sortDisplayName: 'Display Name',
sortRequests: 'Request Count', sortRequests: 'Request Count',
next: 'Next',
previous: 'Previous',
showingresults:
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
resultsperpage: 'Display {pageSize} results per page',
}); });
type Sort = 'created' | 'updated' | 'requests' | 'displayname'; type Sort = 'created' | 'updated' | 'requests' | 'displayname';
@@ -73,10 +79,16 @@ const UserList: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter(); const router = useRouter();
const { addToast } = useToasts(); const { addToast } = useToasts();
const [pageIndex, setPageIndex] = useState(0);
const [currentSort, setCurrentSort] = useState<Sort>('created'); const [currentSort, setCurrentSort] = useState<Sort>('created');
const { data, error, revalidate } = useSWR<User[]>( const [currentPageSize, setCurrentPageSize] = useState<number>(10);
`/api/v1/user?sort=${currentSort}`
const { data, error, revalidate } = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&sort=${currentSort}`
); );
const [isDeleting, setDeleting] = useState(false); const [isDeleting, setDeleting] = useState(false);
const [isImporting, setImporting] = useState(false); const [isImporting, setImporting] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ const [deleteModal, setDeleteModal] = useState<{
@@ -99,7 +111,7 @@ const UserList: React.FC = () => {
const isAllUsersSelected = () => { const isAllUsersSelected = () => {
return ( return (
selectedUsers.length === selectedUsers.length ===
data?.filter((user) => user.id !== currentUser?.id).length data?.results.filter((user) => user.id !== currentUser?.id).length
); );
}; };
const isUserSelected = (userId: number) => selectedUsers.includes(userId); const isUserSelected = (userId: number) => selectedUsers.includes(userId);
@@ -107,10 +119,12 @@ const UserList: React.FC = () => {
if ( if (
data && data &&
selectedUsers.length >= 0 && selectedUsers.length >= 0 &&
selectedUsers.length < data?.length - 1 selectedUsers.length < data?.results.length - 1
) { ) {
setSelectedUsers( setSelectedUsers(
data.filter((user) => isUserPermsEditable(user.id)).map((u) => u.id) data.results
.filter((user) => isUserPermsEditable(user.id))
.map((u) => u.id)
); );
} else { } else {
setSelectedUsers([]); setSelectedUsers([]);
@@ -191,6 +205,13 @@ const UserList: React.FC = () => {
), ),
}); });
if (!data) {
return <LoadingSpinner />;
}
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.users)} /> <PageTitle title={intl.formatMessage(messages.users)} />
@@ -372,7 +393,7 @@ const UserList: React.FC = () => {
revalidate(); revalidate();
}} }}
selectedUserIds={selectedUsers} selectedUserIds={selectedUsers}
users={data} users={data.results}
/> />
</Transition> </Transition>
@@ -411,6 +432,7 @@ const UserList: React.FC = () => {
id="sort" id="sort"
name="sort" name="sort"
onChange={(e) => { onChange={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort); setCurrentSort(e.target.value as Sort);
}} }}
value={currentSort} value={currentSort}
@@ -436,7 +458,7 @@ const UserList: React.FC = () => {
<thead> <thead>
<tr> <tr>
<Table.TH> <Table.TH>
{(data ?? []).length > 1 && ( {(data.results ?? []).length > 1 && (
<input <input
type="checkbox" type="checkbox"
id="selectAll" id="selectAll"
@@ -455,7 +477,7 @@ const UserList: React.FC = () => {
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH> <Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.lastupdated)}</Table.TH> <Table.TH>{intl.formatMessage(messages.lastupdated)}</Table.TH>
<Table.TH className="text-right"> <Table.TH className="text-right">
{(data ?? []).length > 1 && ( {(data.results ?? []).length > 1 && (
<Button <Button
buttonType="warning" buttonType="warning"
onClick={() => setShowBulkEditModal(true)} onClick={() => setShowBulkEditModal(true)}
@@ -468,7 +490,7 @@ const UserList: React.FC = () => {
</tr> </tr>
</thead> </thead>
<Table.TBody> <Table.TBody>
{data?.map((user) => ( {data?.results.map((user) => (
<tr key={`user-list-${user.id}`}> <tr key={`user-list-${user.id}`}>
<Table.TD> <Table.TD>
{isUserPermsEditable(user.id) && ( {isUserPermsEditable(user.id) && (
@@ -554,6 +576,69 @@ const UserList: React.FC = () => {
</Table.TD> </Table.TD>
</tr> </tr>
))} ))}
<tr className="bg-gray-700">
<Table.TD colSpan={8} noPadding>
<nav
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data.results.length > 0 &&
intl.formatMessage(messages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
{intl.formatMessage(messages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setPageIndex(0);
setCurrentPageSize(Number(e.target.value));
}}
value={currentPageSize}
className="inline short"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)}
>
{intl.formatMessage(messages.previous)}
</Button>
<Button
disabled={!hasNextPage}
onClick={() => setPageIndex((current) => current + 1)}
>
{intl.formatMessage(messages.next)}
</Button>
</div>
</nav>
</Table.TD>
</tr>
</Table.TBody> </Table.TBody>
</Table> </Table>
</> </>

View File

@@ -170,6 +170,7 @@
"components.RequestList.previous": "Previous", "components.RequestList.previous": "Previous",
"components.RequestList.requestedAt": "Requested At", "components.RequestList.requestedAt": "Requested At",
"components.RequestList.requests": "Requests", "components.RequestList.requests": "Requests",
"components.RequestList.resultsperpage": "Display {pageSize} results per page",
"components.RequestList.showallrequests": "Show All Requests", "components.RequestList.showallrequests": "Show All Requests",
"components.RequestList.showingresults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results", "components.RequestList.showingresults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results",
"components.RequestList.sortAdded": "Request Date", "components.RequestList.sortAdded": "Request Date",
@@ -635,14 +636,18 @@
"components.UserList.importfromplexerror": "Something went wrong while importing users from Plex.", "components.UserList.importfromplexerror": "Something went wrong while importing users from Plex.",
"components.UserList.lastupdated": "Last Updated", "components.UserList.lastupdated": "Last Updated",
"components.UserList.localuser": "Local User", "components.UserList.localuser": "Local User",
"components.UserList.next": "Next",
"components.UserList.password": "Password", "components.UserList.password": "Password",
"components.UserList.passwordinfo": "Password Information", "components.UserList.passwordinfo": "Password Information",
"components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.", "components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.",
"components.UserList.permissions": "Permissions", "components.UserList.permissions": "Permissions",
"components.UserList.plexuser": "Plex User", "components.UserList.plexuser": "Plex User",
"components.UserList.previous": "Previous",
"components.UserList.resultsperpage": "Display {pageSize} results per page",
"components.UserList.role": "Role", "components.UserList.role": "Role",
"components.UserList.save": "Save Changes", "components.UserList.save": "Save Changes",
"components.UserList.saving": "Saving…", "components.UserList.saving": "Saving…",
"components.UserList.showingResults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results",
"components.UserList.sortCreated": "Creation Date", "components.UserList.sortCreated": "Creation Date",
"components.UserList.sortDisplayName": "Display Name", "components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count", "components.UserList.sortRequests": "Request Count",

View File

@@ -102,8 +102,9 @@ select.rounded-r-only {
@apply rounded-l-none; @apply rounded-l-none;
} }
input.port { input.short,
@apply w-24; select.short {
@apply w-20;
} }
.protocol { .protocol {