diff --git a/framework/core/js/src/admin/compat.js b/framework/core/js/src/admin/compat.ts similarity index 100% rename from framework/core/js/src/admin/compat.js rename to framework/core/js/src/admin/compat.ts diff --git a/framework/core/js/src/common/compat.js b/framework/core/js/src/common/compat.ts similarity index 98% rename from framework/core/js/src/common/compat.js rename to framework/core/js/src/common/compat.ts index 47cd56cb09..27c5549f90 100644 --- a/framework/core/js/src/common/compat.js +++ b/framework/core/js/src/common/compat.ts @@ -1,3 +1,4 @@ +// @ts-expect-error We need to explicitly use the prefix to distinguish between the extend folder. import * as extend from './extend.ts'; import Session from './Session'; import Store from './Store'; diff --git a/framework/core/js/src/common/components/AlertManager.js b/framework/core/js/src/common/components/AlertManager.js deleted file mode 100644 index 90aa2e93db..0000000000 --- a/framework/core/js/src/common/components/AlertManager.js +++ /dev/null @@ -1,31 +0,0 @@ -import Component from '../Component'; - -/** - * The `AlertManager` component provides an area in which `Alert` components can - * be shown and dismissed. - */ -export default class AlertManager extends Component { - oninit(vnode) { - super.oninit(vnode); - - this.state = this.attrs.state; - } - - view() { - return ( -
- {Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => { - const urgent = alert.attrs.type === 'error'; - - return ( - - ); - })} -
- ); - } -} diff --git a/framework/core/js/src/common/components/AlertManager.tsx b/framework/core/js/src/common/components/AlertManager.tsx new file mode 100644 index 0000000000..868ad11e15 --- /dev/null +++ b/framework/core/js/src/common/components/AlertManager.tsx @@ -0,0 +1,42 @@ +import Component, { ComponentAttrs } from '../Component'; +import AlertManagerState from '../states/AlertManagerState'; +import type Mithril from 'mithril'; + +export interface IAlertManagerAttrs extends ComponentAttrs { + state: AlertManagerState; +} + +/** + * The `AlertManager` component provides an area in which `Alert` components can + * be shown and dismissed. + */ +export default class AlertManager extends Component { + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.state = this.attrs.state; + } + + view() { + const activeAlerts = this.state.getActiveAlerts(); + + return ( +
+ {Object.keys(activeAlerts) + .map(Number) + .map((key) => { + const alert = activeAlerts[key]; + const urgent = alert.attrs.type === 'error'; + + return ( + + ); + })} +
+ ); + } +} diff --git a/framework/core/js/src/common/components/Page.tsx b/framework/core/js/src/common/components/Page.tsx index 588435d864..b47183d439 100644 --- a/framework/core/js/src/common/components/Page.tsx +++ b/framework/core/js/src/common/components/Page.tsx @@ -13,7 +13,7 @@ export interface IPageAttrs { * * @abstract */ -export default abstract class Page extends Component { +export default abstract class Page extends Component { /** * A class name to apply to the body while the route is active. */ diff --git a/framework/core/js/src/common/states/AlertManagerState.ts b/framework/core/js/src/common/states/AlertManagerState.ts index f254ea5024..3551d78f40 100644 --- a/framework/core/js/src/common/states/AlertManagerState.ts +++ b/framework/core/js/src/common/states/AlertManagerState.ts @@ -6,6 +6,8 @@ import Alert, { AlertAttrs } from '../components/Alert'; */ export type AlertIdentifier = number; +export type AlertArray = { [id: AlertIdentifier]: AlertState }; + export interface AlertState { componentClass: typeof Alert; attrs: AlertAttrs; @@ -13,8 +15,8 @@ export interface AlertState { } export default class AlertManagerState { - protected activeAlerts: { [id: number]: AlertState } = {}; - protected alertId = 0; + protected activeAlerts: AlertArray = {}; + protected alertId: AlertIdentifier = 0; getActiveAlerts() { return this.activeAlerts; diff --git a/framework/core/js/src/forum/compat.js b/framework/core/js/src/forum/compat.ts similarity index 100% rename from framework/core/js/src/forum/compat.js rename to framework/core/js/src/forum/compat.ts diff --git a/framework/core/js/src/forum/components/DiscussionsUserPage.js b/framework/core/js/src/forum/components/DiscussionsUserPage.tsx similarity index 64% rename from framework/core/js/src/forum/components/DiscussionsUserPage.js rename to framework/core/js/src/forum/components/DiscussionsUserPage.tsx index c4f4b056d7..fcba3ab175 100644 --- a/framework/core/js/src/forum/components/DiscussionsUserPage.js +++ b/framework/core/js/src/forum/components/DiscussionsUserPage.tsx @@ -1,19 +1,21 @@ -import UserPage from './UserPage'; +import UserPage, { IUserPageAttrs } from './UserPage'; import DiscussionList from './DiscussionList'; import DiscussionListState from '../states/DiscussionListState'; +import type Mithril from 'mithril'; +import type User from '../../common/models/User'; /** * The `DiscussionsUserPage` component shows a discussion list inside of a user * page. */ -export default class DiscussionsUserPage extends UserPage { - oninit(vnode) { +export default class DiscussionsUserPage extends UserPage { + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); this.loadUser(m.route.param('username')); } - show(user) { + show(user: User): void { super.show(user); this.state = new DiscussionListState({ diff --git a/framework/core/js/src/forum/components/IndexPage.js b/framework/core/js/src/forum/components/IndexPage.tsx similarity index 80% rename from framework/core/js/src/forum/components/IndexPage.js rename to framework/core/js/src/forum/components/IndexPage.tsx index a9bd9f3451..e859d6ee7d 100644 --- a/framework/core/js/src/forum/components/IndexPage.js +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Page from '../../common/components/Page'; +import Page, { IPageAttrs } from '../../common/components/Page'; import ItemList from '../../common/utils/ItemList'; import listItems from '../../common/helpers/listItems'; import DiscussionList from './DiscussionList'; @@ -11,15 +11,21 @@ import Dropdown from '../../common/components/Dropdown'; import Button from '../../common/components/Button'; import LinkButton from '../../common/components/LinkButton'; import SelectDropdown from '../../common/components/SelectDropdown'; +import extractText from '../../common/utils/extractText'; +import type Mithril from 'mithril'; +import type Discussion from '../../common/models/Discussion'; + +export interface IIndexPageAttrs extends IPageAttrs {} /** * The `IndexPage` component displays the index page, including the welcome * hero, the sidebar, and the discussion list. */ -export default class IndexPage extends Page { +export default class IndexPage extends Page { static providesInitialSearch = true; + lastDiscussion?: Discussion; - oninit(vnode) { + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); // If the user is returning from a discussion page, then take note of which @@ -37,9 +43,9 @@ export default class IndexPage extends Page { app.discussions.clear(); } - app.discussions.refreshParams(app.search.params(), m.route.param('page')); + app.discussions.refreshParams(app.search.params(), Number(m.route.param('page'))); - app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip')); + app.history.push('index', extractText(app.translator.trans('core.forum.header.back_to_index_tooltip'))); this.bodyClass = 'App--index'; this.scrollTopOnCreate = false; @@ -68,11 +74,11 @@ export default class IndexPage extends Page { } setTitle() { - app.setTitle(app.translator.trans('core.forum.index.meta_title_text')); + app.setTitle(extractText(app.translator.trans('core.forum.index.meta_title_text'))); app.setTitleCount(0); } - oncreate(vnode) { + oncreate(vnode: Mithril.VnodeDOM) { super.oncreate(vnode); this.setTitle(); @@ -80,11 +86,11 @@ export default class IndexPage extends Page { // Work out the difference between the height of this hero and that of the // previous hero. Maintain the same scroll position relative to the bottom // of the hero so that the sidebar doesn't jump around. - const oldHeroHeight = app.cache.heroHeight; + const oldHeroHeight = app.cache.heroHeight as number; const heroHeight = (app.cache.heroHeight = this.$('.Hero').outerHeight() || 0); - const scrollTop = app.cache.scrollTop; + const scrollTop = app.cache.scrollTop as number; - $('#app').css('min-height', $(window).height() + heroHeight); + $('#app').css('min-height', ($(window).height() || 0) + heroHeight); // Let browser handle scrolling on page reload. if (app.previous.type == null) return; @@ -104,10 +110,11 @@ export default class IndexPage extends Page { const $discussion = this.$(`li[data-id="${this.lastDiscussion.id()}"] .DiscussionListItem`); if ($discussion.length) { - const indexTop = $('#header').outerHeight(); - const indexBottom = $(window).height(); - const discussionTop = $discussion.offset().top; - const discussionBottom = discussionTop + $discussion.outerHeight(); + const indexTop = $('#header').outerHeight() || 0; + const indexBottom = $(window).height() || 0; + const discussionOffset = $discussion.offset(); + const discussionTop = (discussionOffset && discussionOffset.top) || 0; + const discussionBottom = discussionTop + ($discussion.outerHeight() || 0); if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) { $(window).scrollTop(discussionTop - indexTop); @@ -116,7 +123,7 @@ export default class IndexPage extends Page { } } - onbeforeremove(vnode) { + onbeforeremove(vnode: Mithril.VnodeDOM) { super.onbeforeremove(vnode); // Save the scroll position so we can restore it when we return to the @@ -124,7 +131,7 @@ export default class IndexPage extends Page { app.cache.scrollTop = $(window).scrollTop(); } - onremove(vnode) { + onremove(vnode: Mithril.VnodeDOM) { super.onremove(vnode); $('#app').css('min-height', ''); @@ -132,8 +139,6 @@ export default class IndexPage extends Page { /** * Get the component to display as the hero. - * - * @return {import('mithril').Children} */ hero() { return WelcomeHero.component(); @@ -143,11 +148,9 @@ export default class IndexPage extends Page { * Build an item list for the sidebar of the index page. By default this is a * "New Discussion" button, and then a DropdownSelect component containing a * list of navigation items. - * - * @return {ItemList} */ sidebarItems() { - const items = new ItemList(); + const items = new ItemList(); const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user; items.add( @@ -176,7 +179,7 @@ export default class IndexPage extends Page { className: 'App-titleControl', accessibleToggleLabel: app.translator.trans('core.forum.index.toggle_sidenav_dropdown_accessible_label'), }, - this.navItems(this).toArray() + this.navItems().toArray() ) ); @@ -186,11 +189,9 @@ export default class IndexPage extends Page { /** * Build an item list for the navigation in the sidebar of the index page. By * default this is just the 'All Discussions' link. - * - * @return {ItemList} */ navItems() { - const items = new ItemList(); + const items = new ItemList(); const params = app.search.stickyParams(); items.add( @@ -212,14 +213,12 @@ export default class IndexPage extends Page { * Build an item list for the part of the toolbar which is concerned with how * the results are displayed. By default this is just a select box to change * the way discussions are sorted. - * - * @return {ItemList} */ viewItems() { - const items = new ItemList(); + const items = new ItemList(); const sortMap = app.discussions.sortMap(); - const sortOptions = Object.keys(sortMap).reduce((acc, sortId) => { + const sortOptions = Object.keys(sortMap).reduce((acc: any, sortId) => { acc[sortId] = app.translator.trans(`core.forum.index_sort.${sortId}_button`); return acc; }, {}); @@ -254,11 +253,9 @@ export default class IndexPage extends Page { /** * Build an item list for the part of the toolbar which is about taking action * on the results. By default this is just a "mark all as read" button. - * - * @return {ItemList} */ actionItems() { - const items = new ItemList(); + const items = new ItemList(); items.add( 'refresh', @@ -269,7 +266,7 @@ export default class IndexPage extends Page { onclick: () => { app.discussions.refresh(); if (app.session.user) { - app.store.find('users', app.session.user.id()); + app.store.find('users', app.session.user.id()!); m.redraw(); } }, @@ -293,10 +290,8 @@ export default class IndexPage extends Page { /** * Open the composer for a new discussion or prompt the user to login. - * - * @return {Promise} */ - newDiscussionAction() { + newDiscussionAction(): Promise { return new Promise((resolve, reject) => { if (app.session.user) { app.composer.load(DiscussionComposer, { user: app.session.user }); @@ -315,10 +310,10 @@ export default class IndexPage extends Page { * Mark all discussions as read. */ markAllAsRead() { - const confirmation = confirm(app.translator.trans('core.forum.index.mark_all_as_read_confirmation')); + const confirmation = confirm(extractText(app.translator.trans('core.forum.index.mark_all_as_read_confirmation'))); if (confirmation) { - app.session.user.save({ markedAllAsReadAt: new Date() }); + app.session.user?.save({ markedAllAsReadAt: new Date() }); } } } diff --git a/framework/core/js/src/forum/components/PostsUserPage.js b/framework/core/js/src/forum/components/PostsUserPage.tsx similarity index 73% rename from framework/core/js/src/forum/components/PostsUserPage.js rename to framework/core/js/src/forum/components/PostsUserPage.tsx index 5c8f1901a8..920a99ec5d 100644 --- a/framework/core/js/src/forum/components/PostsUserPage.js +++ b/framework/core/js/src/forum/components/PostsUserPage.tsx @@ -1,46 +1,41 @@ import app from '../../forum/app'; -import UserPage from './UserPage'; +import UserPage, { IUserPageAttrs } from './UserPage'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import Button from '../../common/components/Button'; import Link from '../../common/components/Link'; import Placeholder from '../../common/components/Placeholder'; import CommentPost from './CommentPost'; +import type Post from '../../common/models/Post'; +import type Mithril from 'mithril'; +import type User from '../../common/models/User'; /** * The `PostsUserPage` component shows a user's activity feed inside of their * profile. */ export default class PostsUserPage extends UserPage { - oninit(vnode) { - super.oninit(vnode); + /** + * Whether or not the activity feed is currently loading. + */ + loading: boolean = true; - /** - * Whether or not the activity feed is currently loading. - * - * @type {Boolean} - */ - this.loading = true; + /** + * Whether or not there are any more activity items that can be loaded. + */ + moreResults: boolean = false; - /** - * Whether or not there are any more activity items that can be loaded. - * - * @type {Boolean} - */ - this.moreResults = false; - - /** - * The Post models in the feed. - * - * @type {Post[]} - */ - this.posts = []; + /** + * The Post models in the feed. + */ + posts: Post[] = []; - /** - * The number of activity items to load per request. - * - * @type {number} - */ - this.loadLimit = 20; + /** + * The number of activity items to load per request. + */ + loadLimit: number = 20; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); this.loadUser(m.route.param('username')); } @@ -92,7 +87,7 @@ export default class PostsUserPage extends UserPage { * Initialize the component with a user, and trigger the loading of their * activity feed. */ - show(user) { + show(user: User): void { super.show(user); this.refresh(); @@ -113,14 +108,12 @@ export default class PostsUserPage extends UserPage { /** * Load a new page of the user's activity feed. * - * @param {number} [offset] The position to start getting results from. - * @return {Promise} * @protected */ - loadResults(offset) { - return app.store.find('posts', { + loadResults(offset = 0) { + return app.store.find('posts', { filter: { - author: this.user.username(), + author: this.user!.username(), type: 'comment', }, page: { offset, limit: this.loadLimit }, @@ -138,11 +131,8 @@ export default class PostsUserPage extends UserPage { /** * Parse results and append them to the activity feed. - * - * @param {import('../../common/models/Post').default[]} results - * @return {import('../../common/models/Post').default[]} */ - parseResults(results) { + parseResults(results: Post[]): Post[] { this.loading = false; this.posts.push(...results); diff --git a/framework/core/js/src/forum/components/UserPage.js b/framework/core/js/src/forum/components/UserPage.tsx similarity index 81% rename from framework/core/js/src/forum/components/UserPage.js rename to framework/core/js/src/forum/components/UserPage.tsx index 3b4b1863dc..133c936c39 100644 --- a/framework/core/js/src/forum/components/UserPage.js +++ b/framework/core/js/src/forum/components/UserPage.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Page from '../../common/components/Page'; +import Page, { IPageAttrs } from '../../common/components/Page'; import ItemList from '../../common/utils/ItemList'; import UserCard from './UserCard'; import LoadingIndicator from '../../common/components/LoadingIndicator'; @@ -8,6 +8,10 @@ import LinkButton from '../../common/components/LinkButton'; import Separator from '../../common/components/Separator'; import listItems from '../../common/helpers/listItems'; import AffixedSidebar from './AffixedSidebar'; +import type User from '../../common/models/User'; +import type Mithril from 'mithril'; + +export interface IUserPageAttrs extends IPageAttrs {} /** * The `UserPage` component shows a user's profile. It can be extended to show @@ -16,24 +20,20 @@ import AffixedSidebar from './AffixedSidebar'; * * @abstract */ -export default class UserPage extends Page { - oninit(vnode) { - super.oninit(vnode); +export default class UserPage extends Page { + /** + * The user this page is for. + */ + user: User | null = null; - /** - * The user this page is for. - * - * @type {User} - */ - this.user = null; + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); this.bodyClass = 'App--user'; } /** * Base view template for the user page. - * - * @return {import('mithril').Children} */ view() { return ( @@ -64,19 +64,16 @@ export default class UserPage extends Page { /** * Get the content to display in the user page. - * - * @return {import('mithril').Children} */ - content() {} + content(): Mithril.Children | void {} /** * Initialize the component with a user, and trigger the loading of their * activity feed. * - * @param {import('../../common/models/User').default} user * @protected */ - show(user) { + show(user: User): void { this.user = user; app.current.set('user', user); @@ -89,10 +86,8 @@ export default class UserPage extends Page { /** * Given a username, load the user's profile from the store, or make a request * if we don't have it yet. Then initialize the profile page with that user. - * - * @param {string} username */ - loadUser(username) { + loadUser(username: string) { const lowercaseUsername = username.toLowerCase(); // Load the preloaded user object, if any, into the global app store @@ -100,25 +95,25 @@ export default class UserPage extends Page { // instead of the parsed models app.preloadedApiDocument(); - app.store.all('users').some((user) => { + app.store.all('users').some((user) => { if ((user.username().toLowerCase() === lowercaseUsername || user.id() === username) && user.joinTime()) { this.show(user); return true; } + + return false; }); if (!this.user) { - app.store.find('users', username, { bySlug: true }).then(this.show.bind(this)); + app.store.find('users', username, { bySlug: true }).then(this.show.bind(this)); } } /** * Build an item list for the content of the sidebar. - * - * @return {ItemList} */ sidebarItems() { - const items = new ItemList(); + const items = new ItemList(); items.add( 'nav', @@ -132,12 +127,10 @@ export default class UserPage extends Page { /** * Build an item list for the navigation in the sidebar. - * - * @return {ItemList} */ navItems() { - const items = new ItemList(); - const user = this.user; + const items = new ItemList(); + const user = this.user!; items.add( 'posts',