1
0
mirror of https://github.com/flarum/core.git synced 2025-10-22 04:06:37 +02:00

Create abstract PaginatedListState for DiscussionList and others (#2781)

This commit is contained in:
David Sevilla Martín
2021-05-11 19:14:26 -04:00
committed by GitHub
parent 9a26b2bef4
commit 4e0fdb4c77
11 changed files with 420 additions and 311 deletions

View File

@@ -90,7 +90,7 @@ export default class ForumApplication extends Application {
*
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
this.discussions = new DiscussionListState({});
}
/**

View File

@@ -3,6 +3,7 @@ import DiscussionListItem from './DiscussionListItem';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Placeholder from '../../common/components/Placeholder';
import Discussion from '../../common/models/Discussion';
/**
* The `DiscussionList` component displays a list of discussions.
@@ -13,24 +14,27 @@ import Placeholder from '../../common/components/Placeholder';
*/
export default class DiscussionList extends Component {
view() {
/**
* @type DiscussionListState
*/
const state = this.attrs.state;
const params = state.getParams();
let loading;
if (state.isLoading()) {
if (state.isInitialLoading() || state.isLoadingNext()) {
loading = <LoadingIndicator />;
} else if (state.moreResults) {
} else if (state.hasNext()) {
loading = Button.component(
{
className: 'Button',
onclick: state.loadMore.bind(state),
onclick: state.loadNext.bind(state),
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
}
if (state.empty()) {
if (state.isEmpty()) {
const text = app.translator.trans('core.forum.discussion_list.empty_text');
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
}
@@ -38,12 +42,12 @@ export default class DiscussionList extends Component {
return (
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
<ul className="DiscussionList-discussions">
{state.discussions.map((discussion) => {
return (
{state.getPages().map((pg) => {
return pg.items.map((discussion) => (
<li key={discussion.id()} data-id={discussion.id()}>
{DiscussionListItem.component({ discussion, params })}
</li>
);
));
})}
</ul>
<div className="DiscussionList-loadMore">{loading}</div>

View File

@@ -37,7 +37,7 @@ export default class IndexPage extends Page {
app.discussions.clear();
}
app.discussions.refreshParams(app.search.params());
app.discussions.refreshParams(app.search.params(), m.route.param('page'));
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));

View File

@@ -12,7 +12,7 @@ import Discussion from '../../common/models/Discussion';
export default class NotificationList extends Component {
view() {
const state = this.attrs.state;
const pages = state.getNotificationPages();
const pages = state.getPages();
return (
<div className="NotificationList">
@@ -30,12 +30,12 @@ export default class NotificationList extends Component {
</div>
<div className="NotificationList-content">
{pages.length
? pages.map((notifications) => {
{state.hasItems()
? pages.map((page) => {
const groups = [];
const discussions = {};
notifications.forEach((notification) => {
page.items.forEach((notification) => {
const subject = notification.subject();
if (typeof subject === 'undefined') return;
@@ -84,7 +84,7 @@ export default class NotificationList extends Component {
})
: ''}
{state.isLoading() ? (
<LoadingIndicator />
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
) : (
@@ -124,8 +124,8 @@ export default class NotificationList extends Component {
// by a fraction of a pixel, so we compensate for that.
const atBottom = Math.abs(scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) <= 1;
if (state.hasMoreResults() && !state.isLoading() && atBottom) {
state.loadMore();
if (state.hasNext() && !state.isLoadingNext() && atBottom) {
state.loadNext();
}
}

View File

@@ -1,196 +0,0 @@
export default class DiscussionListState {
constructor(params = {}, app = window.app) {
this.params = params;
this.app = app;
this.discussions = [];
this.moreResults = false;
this.loading = false;
}
/**
* Get the parameters that should be passed in the API request to get
* discussion results.
*
* @api
*/
requestParams() {
const params = { include: ['user', 'lastPostedUser'], filter: {} };
params.sort = this.sortMap()[this.params.sort];
if (this.params.q) {
params.filter.q = this.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*/
sortMap() {
const map = {};
if (this.params.q) {
map.relevance = '';
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
}
/**
* Get the search parameters.
*/
getParams() {
return this.params;
}
/**
* Clear cached discussions.
*/
clear() {
this.discussions = [];
m.redraw();
}
/**
* If there are no cached discussions or the new params differ from the
* old ones, update params and refresh the discussion list from the database.
*/
refreshParams(newParams) {
if (!this.hasDiscussions() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) {
this.params = newParams;
this.refresh();
}
}
/**
* Clear and reload the discussion list. Passing the option `deferClear: true`
* will clear discussions only after new data has been received.
* This can be used to refresh discussions without loading animations.
*/
refresh({ deferClear = false } = {}) {
this.loading = true;
if (!deferClear) {
this.clear();
}
return this.loadResults().then(
(results) => {
// This ensures that any changes made while waiting on this request
// are ignored. Otherwise, we could get duplicate discussions.
// We don't use `this.clear()` to avoid an unnecessary redraw.
this.discussions = [];
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
}
/**
* Load a new page of discussion results.
*
* @param offset The index to start the page at.
*/
loadResults(offset) {
const preloadedDiscussions = this.app.preloadedApiDocument();
if (preloadedDiscussions) {
return Promise.resolve(preloadedDiscussions);
}
const params = this.requestParams();
params.page = { offset };
params.include = params.include.join(',');
return this.app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*/
loadMore() {
this.loading = true;
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the discussion list.
*/
parseResults(results) {
this.discussions.push(...results);
this.loading = false;
this.moreResults = !!results.payload.links && !!results.payload.links.next;
m.redraw();
return results;
}
/**
* Remove a discussion from the list if it is present.
*/
removeDiscussion(discussion) {
const index = this.discussions.indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
m.redraw();
}
/**
* Add a discussion to the top of the list.
*/
addDiscussion(discussion) {
this.discussions.unshift(discussion);
m.redraw();
}
/**
* Are there discussions stored in the discussion list state?
*/
hasDiscussions() {
return this.discussions.length > 0;
}
/**
* Are discussions currently being loaded?
*/
isLoading() {
return this.loading;
}
/**
* In the last request, has the user searched for a discussion?
*/
isSearchResults() {
return !!this.params.q;
}
/**
* Have the search results come up empty?
*/
empty() {
return !this.hasDiscussions() && !this.isLoading();
}
}

View File

@@ -0,0 +1,119 @@
import PaginatedListState, { Page } from '../../common/states/PaginatedListState';
import Discussion from '../../common/models/Discussion';
export default class DiscussionListState extends PaginatedListState<Discussion> {
protected extraDiscussions: Discussion[] = [];
constructor(params: any, page: number) {
super(params, page, 20);
}
get type(): string {
return 'discussions';
}
requestParams() {
const params: any = { include: ['user', 'lastPostedUser'], filter: {} };
params.sort = this.sortMap()[this.params.sort];
if (this.params.q) {
params.filter.q = this.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
protected loadPage(page: number = 1): any {
const preloadedDiscussions = app.preloadedApiDocument();
if (preloadedDiscussions) {
this.initialLoading = false;
return Promise.resolve(preloadedDiscussions);
}
return super.loadPage(page);
}
clear() {
super.clear();
this.extraDiscussions = [];
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*/
sortMap() {
const map: any = {};
if (this.params.q) {
map.relevance = '';
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
}
/**
* In the last request, has the user searched for a discussion?
*/
isSearchResults() {
return !!this.params.q;
}
removeDiscussion(discussion: Discussion) {
for (const page of this.pages) {
const index = page.items.indexOf(discussion);
if (index !== -1) {
page.items.splice(index, 1);
break;
}
}
const index = this.extraDiscussions.indexOf(discussion);
if (index !== -1) {
this.extraDiscussions.splice(index);
}
m.redraw();
}
/**
* Add a discussion to the top of the list.
*/
addDiscussion(discussion: Discussion) {
this.removeDiscussion(discussion);
this.extraDiscussions.unshift(discussion);
m.redraw();
}
protected getAllItems(): Discussion[] {
return this.extraDiscussions.concat(super.getAllItems());
}
public getPages(): Page<Discussion>[] {
const pages = super.getPages();
if (this.extraDiscussions.length) {
return [
{
number: -1,
items: this.extraDiscussions,
},
...pages,
];
}
return pages;
}
}

View File

@@ -1,98 +0,0 @@
export default class NotificationListState {
constructor(app) {
this.app = app;
this.notificationPages = [];
this.loading = false;
this.moreResults = false;
}
clear() {
this.notificationPages = [];
}
getNotificationPages() {
return this.notificationPages;
}
isLoading() {
return this.loading;
}
hasMoreResults() {
return this.moreResults;
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (this.app.session.user.newNotificationCount()) {
this.notificationPages = [];
}
if (this.notificationPages.length > 0) {
return;
}
this.app.session.user.pushAttributes({ newNotificationCount: 0 });
this.loadMore();
}
/**
* Load the next page of notification results.
*
* @public
*/
loadMore() {
this.loading = true;
m.redraw();
const params = this.notificationPages.length > 0 ? { page: { offset: this.notificationPages.length * 10 } } : null;
return this.app.store
.find('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
/**
* Parse results and append them to the notification list.
*
* @param {Notification[]} results
* @return {Notification[]}
*/
parseResults(results) {
if (results.length) this.notificationPages.push(results);
this.moreResults = !!results.payload.links.next;
return results;
}
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (this.notificationPages.length === 0) return;
this.app.session.user.pushAttributes({ unreadNotificationCount: 0 });
this.notificationPages.forEach((notifications) => {
notifications.forEach((notification) => notification.pushAttributes({ isRead: true }));
});
this.app.request({
url: this.app.forum.attribute('apiUrl') + '/notifications/read',
method: 'POST',
});
}
}

View File

@@ -0,0 +1,48 @@
import PaginatedListState from '../../common/states/PaginatedListState';
import Notification from '../../common/models/Notification';
export default class NotificationListState extends PaginatedListState<Notification> {
constructor() {
super({}, 1, 10);
}
get type(): string {
return 'notifications';
}
/**
* Load the next page of notification results.
*/
load(): Promise<void> {
if (app.session.user.newNotificationCount()) {
this.pages = [];
this.location = { page: 1 };
}
if (this.pages.length > 0) {
return Promise.resolve();
}
app.session.user.pushAttributes({ newNotificationCount: 0 });
return super.loadNext();
}
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (this.pages.length === 0) return;
app.session.user.pushAttributes({ unreadNotificationCount: 0 });
this.pages.forEach((page) => {
page.items.forEach((notification) => notification.pushAttributes({ isRead: true }));
});
return app.request({
url: app.forum.attribute('apiUrl') + '/notifications/read',
method: 'POST',
});
}
}