1
0
mirror of https://github.com/flarum/core.git synced 2025-07-19 07:41:22 +02:00

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 88fcc352a5
commit 3db457c0f9
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';