From 8ac8d537c8066cbe09e0147dc51f51f7764d012d Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sun, 16 Oct 2022 17:15:11 +0100 Subject: [PATCH] Server: Paginate users --- packages/server/src/models/BaseModel.ts | 19 ++- .../server/src/models/utils/pagination.ts | 2 +- packages/server/src/routes/admin/users.ts | 110 ++++++++++++++---- packages/server/src/utils/views/table.ts | 10 +- .../server/src/views/admin/users.mustache | 57 +-------- 5 files changed, 116 insertions(+), 82 deletions(-) diff --git a/packages/server/src/models/BaseModel.ts b/packages/server/src/models/BaseModel.ts index 625f63504c..363129faee 100644 --- a/packages/server/src/models/BaseModel.ts +++ b/packages/server/src/models/BaseModel.ts @@ -10,6 +10,7 @@ import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/pe import Logger from '@joplin/lib/Logger'; import dbuuid from '../utils/dbuuid'; import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination'; +import { Knex } from 'knex'; import { unique } from '../utils/array'; const logger = Logger.create('BaseModel'); @@ -33,6 +34,10 @@ export interface LoadOptions { fields?: string[]; } +export interface AllPaginatedOptions extends LoadOptions { + queryCallback?: (query: Knex.QueryBuilder)=> Knex.QueryBuilder; +} + export interface DeleteOptions { validationRules?: any; allowNoOp?: boolean; @@ -242,7 +247,7 @@ export default abstract class BaseModel { return rows as T[]; } - public async allPaginated(pagination: Pagination, options: LoadOptions = {}): Promise> { + public async allPaginated(pagination: Pagination, options: AllPaginatedOptions = {}): Promise> { pagination = { ...defaultPagination(), ...pagination, @@ -250,12 +255,18 @@ export default abstract class BaseModel { const itemCount = await this.count(); - const items = await this + let query = this .db(this.tableName) - .select(this.selectFields(options)) + .select(this.selectFields(options)); + + if (options.queryCallback) query = options.queryCallback(query); + + void query .orderBy(pagination.order[0].by, pagination.order[0].dir) .offset((pagination.page - 1) * pagination.limit) - .limit(pagination.limit) as T[]; + .limit(pagination.limit); + + const items = (await query) as T[]; return { items, diff --git a/packages/server/src/models/utils/pagination.ts b/packages/server/src/models/utils/pagination.ts index c708ba0a29..653aba2689 100644 --- a/packages/server/src/models/utils/pagination.ts +++ b/packages/server/src/models/utils/pagination.ts @@ -168,7 +168,7 @@ export function createPaginationLinks(page: number, pageCount: number, urlTempla firstPages.push({ page: p }); } - if (firstPages.length && (output[0].page - firstPages[firstPages.length - 1].page) > 1) { + if (firstPages.length && output.length && (output[0].page - firstPages[firstPages.length - 1].page) > 1) { firstPages.push({ isEllipsis: true }); } diff --git a/packages/server/src/routes/admin/users.ts b/packages/server/src/routes/admin/users.ts index 28a5a22ba4..f73ad703e8 100644 --- a/packages/server/src/routes/admin/users.ts +++ b/packages/server/src/routes/admin/users.ts @@ -1,6 +1,7 @@ import { SubPath, redirect } from '../../utils/routeUtils'; import Router from '../../utils/Router'; import { RouteType } from '../../utils/types'; +import { Knex } from 'knex'; import { AppContext, HttpMethod } from '../../utils/types'; import { contextSessionId, formParse } from '../../utils/requestUtils'; import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors'; @@ -14,13 +15,15 @@ import uuidgen from '../../utils/uuidgen'; import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings'; import { getCanShareFolder, totalSizeClass } from '../../models/utils/user'; import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select'; -import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../utils/urlUtils'; +import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl, adminUsersUrl, setQueryParameters } from '../../utils/urlUtils'; import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe'; import { createCsrfTag } from '../../utils/csrf'; import { formatDateTime, Hour } from '../../utils/time'; import { startImpersonating, stopImpersonating } from './utils/users/impersonate'; import { userFlagToString } from '../../models/UserFlagModel'; import { _ } from '@joplin/lib/locale'; +import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table'; +import { PaginationOrderDir } from '../../models/utils/pagination'; export interface CheckRepeatPasswordInput { password: string; @@ -95,33 +98,96 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => { const userModel = ctx.joplin.models.user(); await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List); - const users = await userModel.all(); + const showDisabled = ctx.query.show_disabled === '1'; - users.sort((u1: User, u2: User) => { - if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1; - if (u1.full_name && !u2.full_name) return +1; - if (!u1.full_name && u2.full_name) return -1; - return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1; + const pagination = makeTablePagination(ctx.query, 'full_name', PaginationOrderDir.ASC); + pagination.limit = 1000; + const page = await ctx.joplin.models.user().allPaginated(pagination, { + queryCallback: (query: Knex.QueryBuilder) => { + if (!showDisabled) { + void query.where('enabled', '=', 1); + } + return query; + }, }); - const view: View = defaultView('admin/users', _('Users')); - view.content = { - users: users.map(user => { - return { - ...user, - url: adminUserUrl(user.id), - displayName: user.full_name ? user.full_name : '(not set)', - formattedItemMaxSize: formatMaxItemSize(user), - formattedTotalSize: formatTotalSize(user), - formattedMaxTotalSize: formatMaxTotalSize(user), - formattedTotalSizePercent: formatTotalSizePercent(user), - totalSizeClass: totalSizeClass(user), - formattedAccountType: accountTypeToString(user.account_type), - formattedCanShareFolder: yesOrNo(getCanShareFolder(user)), - rowClassName: user.enabled ? '' : 'is-disabled', + const table: Table = { + baseUrl: adminUsersUrl(), + requestQuery: ctx.query, + pageCount: page.page_count, + pagination, + headers: [ + { + name: 'full_name', + label: _('Full name'), + }, + { + name: 'email', + label: _('Email'), + }, + { + name: 'account', + label: _('Account'), + }, + { + name: 'max_item_size', + label: _('Max Item Size'), + }, + { + name: 'total_size', + label: _('Total Size'), + }, + { + name: 'max_total_size', + label: _('Max Total Size'), + }, + { + name: 'can_share', + label: _('Can Share'), + }, + ], + rows: page.items.map(user => { + const row: Row = { + classNames: [user.enabled ? '' : 'is-disabled'], + items: [ + { + value: user.full_name ? user.full_name : '(not set)', + url: adminUserUrl(user.id), + }, + { + value: user.email, + }, + { + value: accountTypeToString(user.account_type), + }, + { + value: formatMaxItemSize(user), + }, + { + value: `${formatTotalSize(user)} (${formatTotalSizePercent(user)})`, + classNames: [totalSizeClass(user)], + }, + { + value: formatMaxTotalSize(user), + }, + { + value: yesOrNo(getCanShareFolder(user)), + }, + ], }; + + return row; }), }; + + const view = defaultView('admin/users', _('Users')); + view.content = { + userTable: makeTableView(table), + csrfTag: await createCsrfTag(ctx), + disabledToggleButtonLabel: showDisabled ? _('Hide disabled') : _('Show disabled'), + disabledToggleButtonUrl: setQueryParameters(adminUsersUrl(), { ...ctx.query, show_disabled: showDisabled ? '0' : '1' }), + }; + return view; }); diff --git a/packages/server/src/utils/views/table.ts b/packages/server/src/utils/views/table.ts index ed1dbac6c1..4348e7ad6f 100644 --- a/packages/server/src/utils/views/table.ts +++ b/packages/server/src/utils/views/table.ts @@ -1,4 +1,4 @@ -import { createPaginationLinks, filterPaginationQueryParams, PageLink, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, PaginationQueryParams, requestPaginationOrder, validatePagination } from '../../models/utils/pagination'; +import { createPaginationLinks, PageLink, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, PaginationQueryParams, requestPaginationOrder, validatePagination } from '../../models/utils/pagination'; import { setQueryParameters } from '../urlUtils'; const defaultSortOrder = PaginationOrderDir.ASC; @@ -45,6 +45,7 @@ interface RowItem { stretch?: boolean; hint?: string; render?: RowItemRenderCallback; + classNames?: string[]; } export interface Row { @@ -106,10 +107,13 @@ function makeRowView(row: Row): RowView { return { classNames: row.classNames, items: row.items.map(rowItem => { + let classNames = [rowItem.stretch ? 'stretch' : 'nowrap']; + if (rowItem.classNames) classNames = classNames.concat(rowItem.classNames); + return { value: rowItem.value, valueHtml: rowItem.render ? rowItem.render() : '', - classNames: [rowItem.stretch ? 'stretch' : 'nowrap'], + classNames, url: rowItem.url, checkbox: rowItem.checkbox, hint: rowItem.hint, @@ -126,7 +130,7 @@ export function makeTableView(table: Table): TableView { if (table.pageCount) { if (!table.baseUrl || !table.requestQuery) throw new Error('Table.baseUrl and Table.requestQuery are required for pagination when there is more than one page'); - baseUrlQuery = filterPaginationQueryParams(table.requestQuery); + baseUrlQuery = table.requestQuery; // filterPaginationQueryParams(table.requestQuery); pagination = table.pagination; paginationLinks = createPaginationLinks(pagination.page, table.pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); } diff --git a/packages/server/src/views/admin/users.mustache b/packages/server/src/views/admin/users.mustache index db6af3d609..f59cee6e64 100644 --- a/packages/server/src/views/admin/users.mustache +++ b/packages/server/src/views/admin/users.mustache @@ -1,60 +1,13 @@ - - - - - - - - - - - - - - {{#users}} - - - - - - - - - - {{/users}} - -
Full nameEmailAccountMax Item SizeTotal SizeMax Total SizeCan share
{{displayName}}{{email}}{{formattedAccountType}}{{formattedItemMaxSize}}{{formattedTotalSize}} ({{formattedTotalSizePercent}}){{formattedMaxTotalSize}}{{formattedCanShareFolder}}
+{{#userTable}} + {{>table}} +{{/userTable}} - - \ No newline at end of file