diff --git a/framework/core/js/src/common/compat.js b/framework/core/js/src/common/compat.js index 68abc38d1..cc615ae38 100644 --- a/framework/core/js/src/common/compat.js +++ b/framework/core/js/src/common/compat.js @@ -78,6 +78,7 @@ import userOnline from './helpers/userOnline'; import listItems from './helpers/listItems'; import Fragment from './Fragment'; import DefaultResolver from './resolvers/DefaultResolver'; +import PaginatedListState from './states/PaginatedListState'; export default { extend: extend, @@ -160,4 +161,5 @@ export default { 'helpers/userOnline': userOnline, 'helpers/listItems': listItems, 'resolvers/DefaultResolver': DefaultResolver, + 'states/PaginatedListState': PaginatedListState, }; diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts new file mode 100644 index 000000000..e152f591c --- /dev/null +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -0,0 +1,230 @@ +import Model from '../Model'; + +export interface Page { + number: number; + items: TModel[]; + + hasPrev?: boolean; + hasNext?: boolean; +} + +export interface PaginationLocation { + page: number; + startIndex?: number; + endIndex?: number; +} + +export default abstract class PaginatedListState { + protected location!: PaginationLocation; + protected pageSize: number; + + protected pages: Page[] = []; + protected params: any = {}; + + protected initialLoading: boolean = false; + protected loadingPrev: boolean = false; + protected loadingNext: boolean = false; + + protected constructor(params: any = {}, page: number = 1, pageSize: number = 20) { + this.params = params; + + this.location = { page }; + this.pageSize = pageSize; + } + + abstract get type(): string; + + public clear() { + this.pages = []; + + m.redraw(); + } + + public loadPrev(): Promise { + if (this.loadingPrev || this.getLocation().page === 1) return Promise.resolve(); + + this.loadingPrev = true; + + const page: number = this.getPrevPageNumber(); + + return this.loadPage(page) + .then(this.parseResults.bind(this, page)) + .finally(() => (this.loadingPrev = false)); + } + + public loadNext(): Promise { + if (this.loadingNext) return Promise.resolve(); + + this.loadingNext = true; + + const page: number = this.getNextPageNumber(); + + return this.loadPage(page) + .then(this.parseResults.bind(this, page)) + .finally(() => (this.loadingNext = false)); + } + + protected parseResults(pg: number, results: T[]) { + const pageNum = Number(pg); + + const links = results.payload?.links || {}; + const page = { + number: pageNum, + items: results, + hasNext: !!links.next, + hasPrev: !!links.prev, + }; + + if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) { + this.pages.push(page); + } else { + this.pages.unshift(page); + } + + this.location = { page: pageNum }; + + m.redraw(); + } + + /** + * Load a new page of results. + */ + protected loadPage(page = 1): Promise { + const params = this.requestParams(); + params.page = { offset: this.pageSize * (page - 1) }; + + if (Array.isArray(params.include)) { + params.include = params.include.join(','); + } + + return app.store.find(this.type, params); + } + + /** + * Get the parameters that should be passed in the API request. + * Do not include page offset unless subclass overrides loadPage. + * + * @abstract + * @see loadPage + */ + protected requestParams(): any { + return this.params; + } + + /** + * Update the `this.params` object, calling `refresh` if they have changed. + * Use `requestParams` for converting `this.params` into API parameters + * + * @param newParams + * @param page + * @see requestParams + */ + public refreshParams(newParams, page: number) { + if (this.isEmpty() || this.paramsChanged(newParams)) { + this.params = newParams; + + return this.refresh(page); + } + } + + public refresh(page: number = 1) { + this.initialLoading = true; + this.loadingPrev = false; + this.loadingNext = false; + + this.clear(); + + this.location = { page }; + + return this.loadPage() + .then((results: T[]) => { + this.pages = []; + this.parseResults(this.location.page, results); + }) + .finally(() => (this.initialLoading = false)); + } + + public getPages() { + return this.pages; + } + public getLocation(): PaginationLocation { + return this.location; + } + + public isLoading(): boolean { + return this.initialLoading || this.loadingNext || this.loadingPrev; + } + public isInitialLoading(): boolean { + return this.initialLoading; + } + public isLoadingPrev(): boolean { + return this.loadingPrev; + } + public isLoadingNext(): boolean { + return this.loadingNext; + } + + /** + * Returns true when the number of items across all loaded pages is not 0. + * + * @see isEmpty + */ + public hasItems(): boolean { + return !!this.getAllItems().length; + } + + /** + * Returns true when there aren't any items *and* the state has already done its initial loading. + * If you want to know whether there are items regardless of load state, use `hasItems()` instead + * + * @see hasItems + */ + public isEmpty(): boolean { + return !this.isInitialLoading() && !this.hasItems(); + } + + public hasPrev(): boolean { + return !!this.pages[0]?.hasPrev; + } + public hasNext(): boolean { + return !!this.pages[this.pages.length - 1]?.hasNext; + } + + /** + * Stored state parameters. + */ + public getParams(): any { + return this.params; + } + + protected getNextPageNumber(): number { + const pg = this.pages[this.pages.length - 1]?.number; + + if (pg && !isNaN(pg)) { + return pg + 1; + } else { + return this.location.page; + } + } + protected getPrevPageNumber(): number { + const pg = this.pages[0]?.number; + + if (pg && !isNaN(pg)) { + // If the calculated page number is less than 1, + // return 1 as the prev page (first possible page number) + return Math.max(pg - 1, 1); + } else { + return this.location.page; + } + } + + protected paramsChanged(newParams): boolean { + return Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key]); + } + + protected getAllItems(): T[] { + return this.getPages() + .map((pg) => pg.items) + .flat(); + } +} diff --git a/framework/core/js/src/forum/ForumApplication.js b/framework/core/js/src/forum/ForumApplication.js index b39fcb7a7..6c3ad79f1 100644 --- a/framework/core/js/src/forum/ForumApplication.js +++ b/framework/core/js/src/forum/ForumApplication.js @@ -90,7 +90,7 @@ export default class ForumApplication extends Application { * * @type {DiscussionListState} */ - this.discussions = new DiscussionListState({}, this); + this.discussions = new DiscussionListState({}); } /** diff --git a/framework/core/js/src/forum/components/DiscussionList.js b/framework/core/js/src/forum/components/DiscussionList.js index 8ceee8266..913a2664b 100644 --- a/framework/core/js/src/forum/components/DiscussionList.js +++ b/framework/core/js/src/forum/components/DiscussionList.js @@ -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 = ; - } 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
{Placeholder.component({ text })}
; } @@ -38,12 +42,12 @@ export default class DiscussionList extends Component { return (
    - {state.discussions.map((discussion) => { - return ( + {state.getPages().map((pg) => { + return pg.items.map((discussion) => (
  • {DiscussionListItem.component({ discussion, params })}
  • - ); + )); })}
{loading}
diff --git a/framework/core/js/src/forum/components/IndexPage.js b/framework/core/js/src/forum/components/IndexPage.js index 428ee05e7..4b6e83d3f 100644 --- a/framework/core/js/src/forum/components/IndexPage.js +++ b/framework/core/js/src/forum/components/IndexPage.js @@ -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')); diff --git a/framework/core/js/src/forum/components/NotificationList.js b/framework/core/js/src/forum/components/NotificationList.js index 67eb46b2e..22a337efa 100644 --- a/framework/core/js/src/forum/components/NotificationList.js +++ b/framework/core/js/src/forum/components/NotificationList.js @@ -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 (
@@ -30,12 +30,12 @@ export default class NotificationList extends Component {
- {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() ? ( - + ) : 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(); } } diff --git a/framework/core/js/src/forum/states/DiscussionListState.js b/framework/core/js/src/forum/states/DiscussionListState.js deleted file mode 100644 index 57f4396a8..000000000 --- a/framework/core/js/src/forum/states/DiscussionListState.js +++ /dev/null @@ -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(); - } -} diff --git a/framework/core/js/src/forum/states/DiscussionListState.ts b/framework/core/js/src/forum/states/DiscussionListState.ts new file mode 100644 index 000000000..b27510991 --- /dev/null +++ b/framework/core/js/src/forum/states/DiscussionListState.ts @@ -0,0 +1,119 @@ +import PaginatedListState, { Page } from '../../common/states/PaginatedListState'; +import Discussion from '../../common/models/Discussion'; + +export default class DiscussionListState extends PaginatedListState { + 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[] { + const pages = super.getPages(); + + if (this.extraDiscussions.length) { + return [ + { + number: -1, + items: this.extraDiscussions, + }, + ...pages, + ]; + } + + return pages; + } +} diff --git a/framework/core/js/src/forum/states/NotificationListState.js b/framework/core/js/src/forum/states/NotificationListState.js deleted file mode 100644 index 31648d49d..000000000 --- a/framework/core/js/src/forum/states/NotificationListState.js +++ /dev/null @@ -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', - }); - } -} diff --git a/framework/core/js/src/forum/states/NotificationListState.ts b/framework/core/js/src/forum/states/NotificationListState.ts new file mode 100644 index 000000000..d2203dba8 --- /dev/null +++ b/framework/core/js/src/forum/states/NotificationListState.ts @@ -0,0 +1,48 @@ +import PaginatedListState from '../../common/states/PaginatedListState'; +import Notification from '../../common/models/Notification'; + +export default class NotificationListState extends PaginatedListState { + constructor() { + super({}, 1, 10); + } + + get type(): string { + return 'notifications'; + } + + /** + * Load the next page of notification results. + */ + load(): Promise { + 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', + }); + } +} diff --git a/framework/core/js/tsconfig.json b/framework/core/js/tsconfig.json index 6503899f8..3c77655c1 100644 --- a/framework/core/js/tsconfig.json +++ b/framework/core/js/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "node", "target": "es6", "jsx": "preserve", - "lib": ["es2015", "es2017", "dom"], + "lib": ["es2015", "es2017", "es2018.promise", "dom"], "allowSyntheticDefaultImports": true } }