Add users list to admin dashboard (#2626)

* Commit initial WIP code

* Fix squashed grid on mobile

* Add pagination support; rename to userList

* Improve grid sizing

* Improve grid row shading

* Move EditUserModal to common

* Add link to profile page in grid

* Use Less styling vars

* Move EditUserModal translations to lib

* Add edit user button to grid

* Fix incorrect profile link priority

* Update profile link translation key

* Add priorities to other columns

* Add group badges to grid

* Add username to profile link tooltip

* Organise imports

* Use variable for header border bottom color

* Fix broken export

* Add total user count to API payload's metadata

* Add new metadata to ApiPayload type

* Implement correct page number

* Remove debug code

* Use function to get the total pages

This allows us to use the raw count elsewhere in the component (pssst... check the next commit!)

* Center profile link in column

* Add profile link header

* Show total users above table

* Use ItemList's itemName property for column data attributes

* Add user email column, hidden by default

This column is hidden by default using a placeholder email and blur filter. These are then removed when the visibility toggle is pressed.

This prevents any over-the-shoulder accidental data leakage, as emails are classed as PII under GDPR.

* Fix incorrect tooltip translation keys

* Add extra padding between email and visibility toggle button

* Prevent selection of blurred email

* Fix incorrect icon state for email toggle

* Update API response type to include metadata (for now)

* Increase number of users per page to 50

* Update compat files with new locations

* Format

* Add @deprecated notices for forum compat export

* Use AdminPayload for user count instead of supplying as REST API metadata

* Make nav look less squashed using bottom margin

* Suppress TS warning

* StyleCI fixes

* Fix TS error

* Update based on review comments

* Rename user list -> users

* Rename internal instances of user_list to users

* Fix formatting

* Use CSS custom properties for the table column count

* Use .Button--icon instead of custom style

* Make fake email more realistic length

* Add a11y attributes

* Use padding bottom instead of margin bottom for page spacing

* Make compatible with new CSS LoadingIndicator

I won't let it break here! :P

* Integrate profile link into username column

* Don't force columns to be 300px

This made the grid look very bloated and intimidating -- lets instead increase the padding between items and make it only the width it needs to be.

* Center edit user button in column

* Increase spacing between email and visibility toggle button

* Rename `statistics` to `modelStatistics` in Admin payload

This prevents any possible conflicts with core and `flarum/statistics`. We might want to consider migrating the stats extension to extend this object in the future.

* Update comments, fix TS error

* Various translation key changes

* Change gmail.com -> example.com

* Stretch 'edit user' button to entire cell size

* Update translations

* Is the YAML formatted right this time? 🙈

* Remove email placeholder

Fixes an issue where the table would jump if an email was unhidden that was longer than the placeholder.

* Re-order lib translations

* Clicking blurred email now unblurs

* Correct header class

* Improve edit user button centring

* Improve vertical row item centering

* Fix incorrect column length in aria attribute

* Use .Button--text!
This commit is contained in:
David Wheatley 2021-04-22 23:35:42 +01:00 committed by GitHub
parent 43d6b3104d
commit f9779284e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 622 additions and 37 deletions

View File

@ -24,6 +24,7 @@ import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import UserListPage from './components/UserListPage';
import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
@ -59,6 +60,7 @@ export default Object.assign(compat, {
'components/LoadingModal': LoadingModal,
'components/DashboardPage': DashboardPage,
'components/BasicsPage': BasicsPage,
'components/UserListPage': UserListPage,
'components/EditCustomHeaderModal': EditCustomHeaderModal,
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,

View File

@ -94,6 +94,13 @@ export default class AdminNav extends Component {
</LinkButton>
);
items.add(
'userList',
<LinkButton href={app.route('users')} icon="fas fa-users" title={app.translator.trans('core.admin.nav.userlist_title')}>
{app.translator.trans('core.admin.nav.userlist_button')}
</LinkButton>
);
items.add(
'search',
<div className="Search-input">

View File

@ -0,0 +1,384 @@
import EditUserModal from '../../common/components/EditUserModal';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import type User from '../../common/models/User';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import AdminPage from './AdminPage';
type ColumnData = {
/**
* Column title
*/
name: String;
/**
* Component(s) to show for this column.
*/
content: (user: User) => JSX.Element;
};
type ApiPayload = {
data: Record<string, unknown>[];
included: Record<string, unknown>[];
links: {
first: string;
next?: string;
};
};
type UsersApiResponse = User[] & { payload: ApiPayload };
/**
* Admin page which displays a paginated list of all users on the forum.
*/
export default class UserListPage extends AdminPage {
/**
* Number of users to load per page.
*/
private numPerPage: number = 50;
/**
* Current page number. Zero-indexed.
*/
private pageNumber: number = 0;
/**
* Total number of forum users.
*
* Fetched from the active `AdminApplication` (`app`), with
* data provided by `AdminPayload.php`, or `flarum/statistics`
* if installed.
*/
readonly userCount: number = app.data.modelStatistics.users.total;
/**
* Get total number of user pages.
*/
private getTotalPageCount(): number {
if (this.userCount === -1) return 0;
return Math.ceil(this.userCount / this.numPerPage);
}
/**
* This page's array of users.
*
* `undefined` when page loads as no data has been fetched.
*/
private pageData: User[] | undefined = undefined;
/**
* Are there more users available?
*/
private moreData: boolean = false;
private isLoadingPage: boolean = false;
/**
* Component to render.
*/
content() {
if (typeof this.pageData === 'undefined') {
this.loadPage(0);
return [
<section class="UserListPage-grid UserListPage-grid--loading">
<LoadingIndicator containerClassName="LoadingIndicator--block" size="large" />
</section>,
];
}
const columns: (ColumnData & { itemName: string })[] = this.columns().toArray();
return [
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
<section
class={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
style={{ '--columns': columns.length }}
role="table"
// +1 to account for header
aria-rowcount={this.pageData.length + 1}
aria-colcount={columns.length}
aria-live="polite"
aria-busy={this.isLoadingPage ? 'true' : 'false'}
>
{/* Render columns */}
{columns.map((column, colIndex) => (
<div class="UserListPage-grid-header" role="columnheader" aria-colindex={colIndex + 1} aria-rowindex={1}>
{column.name}
</div>
))}
{/* Render user data */}
{this.pageData.map((user, rowIndex) =>
columns.map((col, colIndex) => {
const columnContent = col.content && col.content(user);
return (
<div
class={classList(['UserListPage-grid--rowItem', rowIndex % 2 > 0 && 'UserListPage-grid--shadedRow'])}
data-user-id={user.id()}
data-column-name={col.itemName}
aria-colindex={colIndex + 1}
// +2 to account for 0-based index, and for the header row
aria-rowindex={rowIndex + 2}
role="cell"
>
{columnContent || app.translator.trans('core.admin.users.grid.invalid_column_content')}
</div>
);
})
)}
{/* Loading spinner that shows when a new page is being loaded */}
{this.isLoadingPage && <LoadingIndicator size="large" />}
</section>,
<nav class="UserListPage-gridPagination">
<Button
disabled={this.pageNumber === 0}
title={app.translator.trans('core.admin.users.pagination.back_button')}
onclick={this.previousPage.bind(this)}
icon="fas fa-chevron-left"
className="Button Button--icon UserListPage-backBtn"
/>
<span class="UserListPage-pageNumber">
{app.translator.trans('core.admin.users.pagination.page_counter', {
current: this.pageNumber + 1,
total: this.getTotalPageCount(),
})}
</span>
<Button
disabled={!this.moreData}
title={app.translator.trans('core.admin.users.pagination.next_button')}
onclick={this.nextPage.bind(this)}
icon="fas fa-chevron-right"
className="Button Button--icon UserListPage-nextBtn"
/>
</nav>,
];
}
/**
* Build an item list of columns to show for each user.
*
* Each column in the list should be an object with keys `name` and `content`.
*
* `name` is a string that will be used as the column name.
* `content` is a function with the User model passed as the first and only argument.
*
* See `UserListPage.tsx` for examples.
*/
columns(): ItemList {
const columns = new ItemList();
columns.add(
'id',
{
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
content: (user: User) => user.id(),
},
100
);
columns.add(
'username',
{
name: app.translator.trans('core.admin.users.grid.columns.username.title'),
content: (user: User) => {
const profileUrl = `${app.forum.attribute('baseUrl')}/u/${user.slug()}`;
return (
<a
target="_blank"
href={profileUrl}
title={extractText(app.translator.trans('core.admin.users.grid.columns.username.profile_link_tooltip', { username: user.username() }))}
>
{user.username()}
</a>
);
},
},
90
);
columns.add(
'joinDate',
{
name: app.translator.trans('core.admin.users.grid.columns.join_time.title'),
content: (user: User) => (
<span class="UserList-joinDate" title={user.joinTime()}>
{dayjs(user.joinTime()).format('LLL')}
</span>
),
},
80
);
columns.add(
'groupBadges',
{
name: app.translator.trans('core.admin.users.grid.columns.group_badges.title'),
content: (user: User) => {
const badges = user.badges().toArray();
if (badges.length) {
return <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>;
} else {
return app.translator.trans('core.admin.users.grid.columns.group_badges.no_badges');
}
},
},
70
);
columns.add(
'emailAddress',
{
name: app.translator.trans('core.admin.users.grid.columns.email.title'),
content: (user: User) => {
function setEmailVisibility(visible: boolean) {
// Get needed jQuery element refs
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
const emailAddress = emailContainer.find('.UserList-emailAddress');
const emailToggleButton = emailContainer.find('.UserList-emailIconBtn');
const emailToggleButtonIcon = emailToggleButton.find('.icon');
emailToggleButton.attr(
'title',
extractText(
visible
? app.translator.trans('core.admin.users.grid.columns.email.visibility_hide')
: app.translator.trans('core.admin.users.grid.columns.email.visibility_show')
)
);
emailAddress.attr('aria-hidden', visible ? 'false' : 'true');
if (visible) {
emailToggleButtonIcon.addClass('fa-eye');
emailToggleButtonIcon.removeClass('fa-eye-slash');
} else {
emailToggleButtonIcon.removeClass('fa-eye');
emailToggleButtonIcon.addClass('fa-eye-slash');
}
// Need the string interpolation to prevent TS error.
emailContainer.attr('data-email-shown', `${visible}`);
}
function toggleEmailVisibility() {
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
const emailShown = emailContainer.attr('data-email-shown') === 'true';
if (emailShown) {
setEmailVisibility(false);
} else {
setEmailVisibility(true);
}
}
return (
<div class="UserList-email" key={user.id()} data-email-shown="false">
<span class="UserList-emailAddress" aria-hidden onclick={() => setEmailVisibility(true)}>
{user.email()}
</span>
<button
onclick={toggleEmailVisibility}
class="Button Button--text UserList-emailIconBtn"
title={app.translator.trans('core.admin.users.grid.columns.email.visibility_show')}
>
{icon('far fa-eye-slash fa-fw', { className: 'icon' })}
</button>
</div>
);
},
},
70
);
columns.add(
'editUser',
{
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
content: (user: User) => (
<Button
className="Button UserList-editModalBtn"
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
onclick={() => app.modal.show(EditUserModal, { user })}
>
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
</Button>
),
},
-90
);
return columns;
}
headerInfo() {
return {
className: 'UserListPage',
icon: 'fas fa-users',
title: app.translator.trans('core.admin.users.title'),
description: app.translator.trans('core.admin.users.description'),
};
}
/**
* Asynchronously fetch the next set of users to be rendered.
*
* Returns an array of Users, plus the raw API payload.
*
* Uses the `this.numPerPage` as the response limit, and automatically calculates the offset required from `pageNumber`.
*
* @param pageNumber The page number to load and display
*/
async loadPage(pageNumber: number) {
if (pageNumber < 0) pageNumber = 0;
app.store
.find('users', {
page: {
limit: this.numPerPage,
offset: pageNumber * this.numPerPage,
},
})
.then((apiData: UsersApiResponse) => {
// Next link won't be present if there's no more data
this.moreData = !!apiData.payload.links.next;
let data = apiData;
// @ts-ignore
delete data.payload;
this.pageData = data;
this.pageNumber = pageNumber;
this.isLoadingPage = false;
m.redraw();
})
.catch((err: Error) => {
console.error(err);
this.pageData = [];
});
}
nextPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber + 1);
}
previousPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber - 1);
}
}

View File

@ -3,6 +3,7 @@ import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import MailPage from './components/MailPage';
import UserListPage from './components/UserListPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
@ -18,6 +19,7 @@ export default function (app) {
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
mail: { path: '/mail', component: MailPage },
users: { path: '/users', component: UserListPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
};
}

View File

@ -59,6 +59,7 @@ import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import EditUserModal from './components/EditUserModal';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@ -136,6 +137,7 @@ export default {
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
'components/EditUserModal': EditUserModal,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,

View File

@ -1,10 +1,10 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import GroupBadge from '../../common/components/GroupBadge';
import Group from '../../common/models/Group';
import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import Modal from './Modal';
import Button from './Button';
import GroupBadge from './GroupBadge';
import Group from '../models/Group';
import extractText from '../utils/extractText';
import ItemList from '../utils/ItemList';
import Stream from '../utils/Stream';
/**
* The `EditUserModal` component displays a modal dialog with a login form.
@ -33,14 +33,14 @@ export default class EditUserModal extends Modal {
}
title() {
return app.translator.trans('core.forum.edit_user.title');
return app.translator.trans('core.lib.edit_user.title');
}
content() {
const fields = this.fields().toArray();
return (
<div className="Modal-body">
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.lib.edit_user.nothing_available')}
</div>
);
}
@ -52,10 +52,10 @@ export default class EditUserModal extends Modal {
items.add(
'username',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.username_heading')}</label>
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.username_label'))}
bidi={this.username}
disabled={this.nonAdminEditingAdmin()}
/>
@ -67,11 +67,11 @@ export default class EditUserModal extends Modal {
items.add(
'email',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.email_heading')}</label>
<div>
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.email_label'))}
bidi={this.email}
disabled={this.nonAdminEditingAdmin()}
/>
@ -84,7 +84,7 @@ export default class EditUserModal extends Modal {
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
app.translator.trans('core.lib.edit_user.activate_button')
)}
</div>
) : (
@ -97,7 +97,7 @@ export default class EditUserModal extends Modal {
items.add(
'password',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.password_heading')}</label>
<div>
<label className="checkbox">
<input
@ -110,14 +110,14 @@ export default class EditUserModal extends Modal {
}}
disabled={this.nonAdminEditingAdmin()}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
{app.translator.trans('core.lib.edit_user.set_password_label')}
</label>
{this.setPassword() ? (
<input
className="FormControl"
type="password"
name="password"
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.password_label'))}
bidi={this.password}
disabled={this.nonAdminEditingAdmin()}
/>
@ -135,7 +135,7 @@ export default class EditUserModal extends Modal {
items.add(
'groups',
<div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
<div>
{Object.keys(this.groups)
.map((id) => app.store.getById('groups', id))
@ -164,7 +164,7 @@ export default class EditUserModal extends Modal {
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.edit_user.submit_button')
app.translator.trans('core.lib.edit_user.submit_button')
)}
</div>,
-10

View File

@ -51,7 +51,6 @@ import PostPreview from './components/PostPreview';
import EventPost from './components/EventPost';
import DiscussionHero from './components/DiscussionHero';
import PostMeta from './components/PostMeta';
import EditUserModal from './components/EditUserModal';
import SearchSource from './components/SearchSource';
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
import DiscussionComposer from './components/DiscussionComposer';
@ -70,6 +69,10 @@ import Search from './components/Search';
import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
/**
* @deprecated
*/
import EditUserModal from '../common/components/EditUserModal';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
import routes from './routes';
@ -128,6 +131,9 @@ export default Object.assign(compat, {
'components/EventPost': EventPost,
'components/DiscussionHero': DiscussionHero,
'components/PostMeta': PostMeta,
/**
* @deprecated Used for backwards compatibility now that the EditUserModal has moved to common. Remove in beta 17.
*/
'components/EditUserModal': EditUserModal,
'components/SearchSource': SearchSource,
'components/DiscussionRenamedPost': DiscussionRenamedPost,

View File

@ -1,6 +1,6 @@
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import EditUserModal from '../components/EditUserModal';
import EditUserModal from '../../common/components/EditUserModal';
import UserPage from '../components/UserPage';
import ItemList from '../../common/utils/ItemList';

View File

@ -10,3 +10,4 @@
@import "admin/ExtensionWidget";
@import "admin/AppearancePage";
@import "admin/MailPage";
@import "admin/UsersListPage.less";

View File

@ -0,0 +1,125 @@
.UserListPage {
// Pad bottom of page to make nav area look less squashed
padding-bottom: 24px;
&-grid {
width: 100%;
position: relative;
border-radius: @border-radius;
// Use CSS custom properties to define the number of columns in the grid
grid-template-columns: repeat(var(--columns), max-content);
// Ensure mobile scrollbar isn't on top of content
padding-bottom: 4px;
// Table refreshing overlay
&--loadingPage {
&::after {
content: "";
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(128, 128, 128, 0.2);
}
.LoadingIndicator-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
}
&--loaded,
&--loadingPage {
display: grid;
overflow-x: auto;
}
&-header {
font-weight: bold;
border-bottom: 1px solid @muted-more-color;
padding: 8px 16px;
background: @control-bg;
}
&--rowItem {
padding: 4px 16px;
display: flex;
align-items: center;
&[data-column-name="editUser"] {
padding: 0;
position: relative;
}
}
&--shadedRow {
background: darken(@body-bg, 3%);
& when (@config-dark-mode = true) {
background: lighten(@body-bg, 5%);
}
}
}
&-gridPagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
}
// Handles styling of default UserList columns
.UserList {
&-joinDate {
cursor: help;
text-decoration: underline;
text-decoration-style: dotted;
}
&-editModalBtn {
width: 100%;
height: 100%;
border-radius: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
&-email {
display: flex;
flex-grow: 1;
&[data-email-shown="false"] {
.UserList-emailAddress {
user-select: none;
filter: blur(4px);
cursor: pointer;
}
}
&Address {
flex-grow: 1;
margin-right: 4px;
transition: filter 0.2s ease-out;
}
&IconBtn {
margin-left: 12px;
&:hover {
text-decoration: none;
}
}
}
}

View File

@ -17,6 +17,7 @@
@import "Button";
@import "Checkbox";
@import "Dropdown";
@import "EditUserModal";
@import "Form";
@import "FormControl";
@import "LoadingIndicator";

View File

@ -7,7 +7,6 @@
@import "forum/DiscussionList";
@import "forum/DiscussionListItem";
@import "forum/DiscussionPage";
@import "forum/EditUserModal";
@import "forum/Hero";
@import "forum/IndexPage";
@import "forum/LogInButton";

View File

@ -163,6 +163,8 @@ core:
email_title: => core.admin.email.description
permissions_button: => core.admin.permissions.title
permissions_title: => core.admin.permissions.description
userlist_button: => core.admin.users.title
userlist_title: => core.admin.users.description
search_placeholder: Search Extensions
# These translations are used in the Permissions page of the admin interface.
@ -217,6 +219,46 @@ core:
remove_button: => core.ref.remove
upload_button: Choose an Image...
# These translations are used for the users list on the admin dashboard.
users:
description: A paginated list of all users on your forum.
grid:
columns:
edit_user:
button: => core.ref.edit
title: => core.ref.edit_user
tooltip: Edit {username}
email:
title: => core.ref.email
visibility_hide: Hide email address
visibility_show: Show email address
group_badges:
no_badges: None
title: Groups
join_time:
title: Joined
user_id:
title: ID
username:
profile_link_tooltip: Visit {username}'s profile
title: => core.ref.username
invalid_column_content: Invalid
pagination:
back_button: Previous page
next_button: Next page
page_counter: Page {current} of {total}
title: => core.ref.users
total_users: "Total users: {count}"
# Translations in this namespace are used by the forum user interface.
forum:
@ -288,21 +330,6 @@ core:
replied_text: "{username} replied {ago}"
started_text: "{username} started {ago}"
# These translations are used in the Edit User modal dialog (admin function).
edit_user:
activate_button: Activate User
email_heading: => core.ref.email
email_label: => core.ref.email
groups_heading: Groups
nothing_available: There is nothing available for you to edit at this time.
password_heading: => core.ref.password
password_label: => core.ref.password
set_password_label: Set new password
submit_button: => core.ref.save_changes
title: Edit User
username_heading: => core.ref.username
username_label: => core.ref.username
# These translations are used in the Forgot Password modal dialog.
forgot_password:
dismiss_button: => core.ref.okay
@ -474,6 +501,20 @@ core:
dropdown:
toggle_dropdown_accessible_label: Toggle dropdown menu
# These translations are used in the Edit User modal dialog (admin function).
edit_user:
activate_button: Activate User
email_heading: => core.ref.email
email_label: => core.ref.email
groups_heading: Groups
password_heading: => core.ref.password
password_label: => core.ref.password
set_password_label: Set new password
submit_button: => core.ref.save_changes
title: => core.ref.edit_user
username_heading: => core.ref.username
username_label: => core.ref.username
# These translations are displayed as error messages.
error:
dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}"
@ -624,6 +665,7 @@ core:
delete_forever: Delete Forever
discussions: Discussions # Referenced by flarum-statistics.yml
edit: Edit
edit_user: Edit User
email: Email
icon: Icon
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."

View File

@ -14,6 +14,7 @@ use Flarum\Frontend\Document;
use Flarum\Group\Permission;
use Flarum\Settings\Event\Deserializing;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionInterface;
@ -81,5 +82,18 @@ class AdminPayload
$document->payload['phpVersion'] = PHP_VERSION;
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
/**
* Used in the admin user list. Implemented as this as it matches the API in flarum/statistics.
* If flarum/statistics ext is enabled, it will override this data with its own stats.
*
* This allows the front-end code to be simpler and use one single source of truth to pull the
* total user count from.
*/
$document->payload['modelStatistics'] = [
'users' => [
'total' => User::count()
]
];
}
}