1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-03-20 20:55:18 +02:00

Server: Paginate users

This commit is contained in:
Laurent Cozic 2022-10-16 17:15:11 +01:00
parent 8ea6d89d49
commit 8ac8d537c8
5 changed files with 116 additions and 82 deletions

View File

@ -10,6 +10,7 @@ import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/pe
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import dbuuid from '../utils/dbuuid'; import dbuuid from '../utils/dbuuid';
import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination'; import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination';
import { Knex } from 'knex';
import { unique } from '../utils/array'; import { unique } from '../utils/array';
const logger = Logger.create('BaseModel'); const logger = Logger.create('BaseModel');
@ -33,6 +34,10 @@ export interface LoadOptions {
fields?: string[]; fields?: string[];
} }
export interface AllPaginatedOptions extends LoadOptions {
queryCallback?: (query: Knex.QueryBuilder)=> Knex.QueryBuilder;
}
export interface DeleteOptions { export interface DeleteOptions {
validationRules?: any; validationRules?: any;
allowNoOp?: boolean; allowNoOp?: boolean;
@ -242,7 +247,7 @@ export default abstract class BaseModel<T> {
return rows as T[]; return rows as T[];
} }
public async allPaginated(pagination: Pagination, options: LoadOptions = {}): Promise<PaginatedResults<T>> { public async allPaginated(pagination: Pagination, options: AllPaginatedOptions = {}): Promise<PaginatedResults<T>> {
pagination = { pagination = {
...defaultPagination(), ...defaultPagination(),
...pagination, ...pagination,
@ -250,12 +255,18 @@ export default abstract class BaseModel<T> {
const itemCount = await this.count(); const itemCount = await this.count();
const items = await this let query = this
.db(this.tableName) .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) .orderBy(pagination.order[0].by, pagination.order[0].dir)
.offset((pagination.page - 1) * pagination.limit) .offset((pagination.page - 1) * pagination.limit)
.limit(pagination.limit) as T[]; .limit(pagination.limit);
const items = (await query) as T[];
return { return {
items, items,

View File

@ -168,7 +168,7 @@ export function createPaginationLinks(page: number, pageCount: number, urlTempla
firstPages.push({ page: p }); 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 }); firstPages.push({ isEllipsis: true });
} }

View File

@ -1,6 +1,7 @@
import { SubPath, redirect } from '../../utils/routeUtils'; import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types'; import { RouteType } from '../../utils/types';
import { Knex } from 'knex';
import { AppContext, HttpMethod } from '../../utils/types'; import { AppContext, HttpMethod } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils'; import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors'; 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 { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user'; import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select'; 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 { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf'; import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime, Hour } from '../../utils/time'; import { formatDateTime, Hour } from '../../utils/time';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate'; import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel'; import { userFlagToString } from '../../models/UserFlagModel';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
export interface CheckRepeatPasswordInput { export interface CheckRepeatPasswordInput {
password: string; password: string;
@ -95,33 +98,96 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.joplin.models.user(); const userModel = ctx.joplin.models.user();
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List); 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) => { const pagination = makeTablePagination(ctx.query, 'full_name', PaginationOrderDir.ASC);
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1; pagination.limit = 1000;
if (u1.full_name && !u2.full_name) return +1; const page = await ctx.joplin.models.user().allPaginated(pagination, {
if (!u1.full_name && u2.full_name) return -1; queryCallback: (query: Knex.QueryBuilder) => {
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1; if (!showDisabled) {
void query.where('enabled', '=', 1);
}
return query;
},
}); });
const view: View = defaultView('admin/users', _('Users')); const table: Table = {
view.content = { baseUrl: adminUsersUrl(),
users: users.map(user => { requestQuery: ctx.query,
return { pageCount: page.page_count,
...user, pagination,
url: adminUserUrl(user.id), headers: [
displayName: user.full_name ? user.full_name : '(not set)', {
formattedItemMaxSize: formatMaxItemSize(user), name: 'full_name',
formattedTotalSize: formatTotalSize(user), label: _('Full name'),
formattedMaxTotalSize: formatMaxTotalSize(user), },
formattedTotalSizePercent: formatTotalSizePercent(user), {
totalSizeClass: totalSizeClass(user), name: 'email',
formattedAccountType: accountTypeToString(user.account_type), label: _('Email'),
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)), },
rowClassName: user.enabled ? '' : 'is-disabled', {
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; return view;
}); });

View File

@ -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'; import { setQueryParameters } from '../urlUtils';
const defaultSortOrder = PaginationOrderDir.ASC; const defaultSortOrder = PaginationOrderDir.ASC;
@ -45,6 +45,7 @@ interface RowItem {
stretch?: boolean; stretch?: boolean;
hint?: string; hint?: string;
render?: RowItemRenderCallback; render?: RowItemRenderCallback;
classNames?: string[];
} }
export interface Row { export interface Row {
@ -106,10 +107,13 @@ function makeRowView(row: Row): RowView {
return { return {
classNames: row.classNames, classNames: row.classNames,
items: row.items.map(rowItem => { items: row.items.map(rowItem => {
let classNames = [rowItem.stretch ? 'stretch' : 'nowrap'];
if (rowItem.classNames) classNames = classNames.concat(rowItem.classNames);
return { return {
value: rowItem.value, value: rowItem.value,
valueHtml: rowItem.render ? rowItem.render() : '', valueHtml: rowItem.render ? rowItem.render() : '',
classNames: [rowItem.stretch ? 'stretch' : 'nowrap'], classNames,
url: rowItem.url, url: rowItem.url,
checkbox: rowItem.checkbox, checkbox: rowItem.checkbox,
hint: rowItem.hint, hint: rowItem.hint,
@ -126,7 +130,7 @@ export function makeTableView(table: Table): TableView {
if (table.pageCount) { 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'); 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; pagination = table.pagination;
paginationLinks = createPaginationLinks(pagination.page, table.pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); paginationLinks = createPaginationLinks(pagination.page, table.pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
} }

View File

@ -1,60 +1,13 @@
<div class="block"> <div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a> <a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a> <a class="button is-link toggle-disabled-button hide-disabled" href="{{disabledToggleButtonUrl}}">{{disabledToggleButtonLabel}}</a>
</div> </div>
<table class="table"> {{#userTable}}
<thead> {{>table}}
<tr> {{/userTable}}
<th>Full name</th>
<th>Email</th>
<th>Account</th>
<th>Max Item Size</th>
<th>Total Size</th>
<th>Max Total Size</th>
<th>Can share</th>
</tr>
</thead>
<tbody>
{{#users}}
<tr class="{{rowClassName}}">
<td><a href="{{{url}}}">{{displayName}}</a></td>
<td>{{email}}</td>
<td>{{formattedAccountType}}</td>
<td>{{formattedItemMaxSize}}</td>
<td class="{{totalSizeClass}}">{{formattedTotalSize}} ({{formattedTotalSizePercent}})</td>
<td>{{formattedMaxTotalSize}}</td>
<td>{{formattedCanShareFolder}}</td>
</tr>
{{/users}}
</tbody>
</table>
<div class="block"> <div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a> <a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a> <a class="button is-link toggle-disabled-button hide-disabled" href="{{disabledToggleButtonUrl}}">{{disabledToggleButtonLabel}}</a>
</div> </div>
<script>
$(() => {
function toggleDisabled() {
if ($('.hide-disabled').length) {
$('.hide-disabled').addClass('show-disabled');
$('.hide-disabled').removeClass('hide-disabled');
$('.show-disabled').text('Show disabled');
$('table tr.is-disabled').hide();
} else {
$('.show-disabled').addClass('hide-disabled');
$('.show-disabled').removeClass('show-disabled');
$('.hide-disabled').text('Hide disabled');
$('table tr.is-disabled').show();
}
}
toggleDisabled();
$('.toggle-disabled-button').click(() => {
toggleDisabled();
});
});
</script>