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',