refactor: convert AlertManager IndexPage and UserPage components to TS (#3536)

* chore: convert `AlertManager` component to TypeScript
* chore: `compat.js` to `compat.ts`
* chore: convert `IndexPage` component to TypeScript
* chore: convert `UserPage` component and inheritors to TypeScript
* chore: `yarn format`
* chore: import types instead
This commit is contained in:
Sami Mazouz 2022-07-15 23:27:47 +01:00 committed by GitHub
parent 5721a2f487
commit 0c017c2aa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 139 additions and 145 deletions

View File

@ -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 * as extend from './extend.ts';
import Session from './Session'; import Session from './Session';
import Store from './Store'; import Store from './Store';

View File

@ -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 (
<div class="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => {
const urgent = alert.attrs.type === 'error';
return (
<div class="AlertManager-alert" role="alert" aria-live={urgent ? 'assertive' : 'polite'}>
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
);
})}
</div>
);
}
}

View File

@ -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<CustomAttrs extends IAlertManagerAttrs = IAlertManagerAttrs> extends Component<CustomAttrs, AlertManagerState> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.state = this.attrs.state;
}
view() {
const activeAlerts = this.state.getActiveAlerts();
return (
<div class="AlertManager">
{Object.keys(activeAlerts)
.map(Number)
.map((key) => {
const alert = activeAlerts[key];
const urgent = alert.attrs.type === 'error';
return (
<div class="AlertManager-alert" role="alert" aria-live={urgent ? 'assertive' : 'polite'}>
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
);
})}
</div>
);
}
}

View File

@ -13,7 +13,7 @@ export interface IPageAttrs {
* *
* @abstract * @abstract
*/ */
export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs> extends Component<CustomAttrs> { export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs, CustomState = undefined> extends Component<CustomAttrs, CustomState> {
/** /**
* A class name to apply to the body while the route is active. * A class name to apply to the body while the route is active.
*/ */

View File

@ -6,6 +6,8 @@ import Alert, { AlertAttrs } from '../components/Alert';
*/ */
export type AlertIdentifier = number; export type AlertIdentifier = number;
export type AlertArray = { [id: AlertIdentifier]: AlertState };
export interface AlertState { export interface AlertState {
componentClass: typeof Alert; componentClass: typeof Alert;
attrs: AlertAttrs; attrs: AlertAttrs;
@ -13,8 +15,8 @@ export interface AlertState {
} }
export default class AlertManagerState { export default class AlertManagerState {
protected activeAlerts: { [id: number]: AlertState } = {}; protected activeAlerts: AlertArray = {};
protected alertId = 0; protected alertId: AlertIdentifier = 0;
getActiveAlerts() { getActiveAlerts() {
return this.activeAlerts; return this.activeAlerts;

View File

@ -1,19 +1,21 @@
import UserPage from './UserPage'; import UserPage, { IUserPageAttrs } from './UserPage';
import DiscussionList from './DiscussionList'; import DiscussionList from './DiscussionList';
import DiscussionListState from '../states/DiscussionListState'; 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 * The `DiscussionsUserPage` component shows a discussion list inside of a user
* page. * page.
*/ */
export default class DiscussionsUserPage extends UserPage { export default class DiscussionsUserPage extends UserPage<IUserPageAttrs, DiscussionListState> {
oninit(vnode) { oninit(vnode: Mithril.Vnode<IUserPageAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
this.loadUser(m.route.param('username')); this.loadUser(m.route.param('username'));
} }
show(user) { show(user: User): void {
super.show(user); super.show(user);
this.state = new DiscussionListState({ this.state = new DiscussionListState({

View File

@ -1,5 +1,5 @@
import app from '../../forum/app'; 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 ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import DiscussionList from './DiscussionList'; import DiscussionList from './DiscussionList';
@ -11,15 +11,21 @@ import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import LinkButton from '../../common/components/LinkButton'; import LinkButton from '../../common/components/LinkButton';
import SelectDropdown from '../../common/components/SelectDropdown'; 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 * The `IndexPage` component displays the index page, including the welcome
* hero, the sidebar, and the discussion list. * hero, the sidebar, and the discussion list.
*/ */
export default class IndexPage extends Page { export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageAttrs, CustomState = {}> extends Page<CustomAttrs, CustomState> {
static providesInitialSearch = true; static providesInitialSearch = true;
lastDiscussion?: Discussion;
oninit(vnode) { oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
// If the user is returning from a discussion page, then take note of which // 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.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.bodyClass = 'App--index';
this.scrollTopOnCreate = false; this.scrollTopOnCreate = false;
@ -68,11 +74,11 @@ export default class IndexPage extends Page {
} }
setTitle() { 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); app.setTitleCount(0);
} }
oncreate(vnode) { oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode); super.oncreate(vnode);
this.setTitle(); 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 // 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 // previous hero. Maintain the same scroll position relative to the bottom
// of the hero so that the sidebar doesn't jump around. // 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 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. // Let browser handle scrolling on page reload.
if (app.previous.type == null) return; 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`); const $discussion = this.$(`li[data-id="${this.lastDiscussion.id()}"] .DiscussionListItem`);
if ($discussion.length) { if ($discussion.length) {
const indexTop = $('#header').outerHeight(); const indexTop = $('#header').outerHeight() || 0;
const indexBottom = $(window).height(); const indexBottom = $(window).height() || 0;
const discussionTop = $discussion.offset().top; const discussionOffset = $discussion.offset();
const discussionBottom = discussionTop + $discussion.outerHeight(); const discussionTop = (discussionOffset && discussionOffset.top) || 0;
const discussionBottom = discussionTop + ($discussion.outerHeight() || 0);
if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) { if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) {
$(window).scrollTop(discussionTop - indexTop); $(window).scrollTop(discussionTop - indexTop);
@ -116,7 +123,7 @@ export default class IndexPage extends Page {
} }
} }
onbeforeremove(vnode) { onbeforeremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onbeforeremove(vnode); super.onbeforeremove(vnode);
// Save the scroll position so we can restore it when we return to the // 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(); app.cache.scrollTop = $(window).scrollTop();
} }
onremove(vnode) { onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onremove(vnode); super.onremove(vnode);
$('#app').css('min-height', ''); $('#app').css('min-height', '');
@ -132,8 +139,6 @@ export default class IndexPage extends Page {
/** /**
* Get the component to display as the hero. * Get the component to display as the hero.
*
* @return {import('mithril').Children}
*/ */
hero() { hero() {
return WelcomeHero.component(); 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 * 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 * "New Discussion" button, and then a DropdownSelect component containing a
* list of navigation items. * list of navigation items.
*
* @return {ItemList<import('mithril').Children>}
*/ */
sidebarItems() { sidebarItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user; const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user;
items.add( items.add(
@ -176,7 +179,7 @@ export default class IndexPage extends Page {
className: 'App-titleControl', className: 'App-titleControl',
accessibleToggleLabel: app.translator.trans('core.forum.index.toggle_sidenav_dropdown_accessible_label'), 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 * Build an item list for the navigation in the sidebar of the index page. By
* default this is just the 'All Discussions' link. * default this is just the 'All Discussions' link.
*
* @return {ItemList<import('mithril').Children>}
*/ */
navItems() { navItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
const params = app.search.stickyParams(); const params = app.search.stickyParams();
items.add( 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 * 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 results are displayed. By default this is just a select box to change
* the way discussions are sorted. * the way discussions are sorted.
*
* @return {ItemList<import('mithril').Children>}
*/ */
viewItems() { viewItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
const sortMap = app.discussions.sortMap(); 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`); acc[sortId] = app.translator.trans(`core.forum.index_sort.${sortId}_button`);
return acc; 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 * 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. * on the results. By default this is just a "mark all as read" button.
*
* @return {ItemList<import('mithril').Children>}
*/ */
actionItems() { actionItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
items.add( items.add(
'refresh', 'refresh',
@ -269,7 +266,7 @@ export default class IndexPage extends Page {
onclick: () => { onclick: () => {
app.discussions.refresh(); app.discussions.refresh();
if (app.session.user) { if (app.session.user) {
app.store.find('users', app.session.user.id()); app.store.find('users', app.session.user.id()!);
m.redraw(); 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. * Open the composer for a new discussion or prompt the user to login.
*
* @return {Promise<void>}
*/ */
newDiscussionAction() { newDiscussionAction(): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (app.session.user) { if (app.session.user) {
app.composer.load(DiscussionComposer, { user: 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. * Mark all discussions as read.
*/ */
markAllAsRead() { 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) { if (confirmation) {
app.session.user.save({ markedAllAsReadAt: new Date() }); app.session.user?.save({ markedAllAsReadAt: new Date() });
} }
} }
} }

View File

@ -1,46 +1,41 @@
import app from '../../forum/app'; import app from '../../forum/app';
import UserPage from './UserPage'; import UserPage, { IUserPageAttrs } from './UserPage';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link'; import Link from '../../common/components/Link';
import Placeholder from '../../common/components/Placeholder'; import Placeholder from '../../common/components/Placeholder';
import CommentPost from './CommentPost'; 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 * The `PostsUserPage` component shows a user's activity feed inside of their
* profile. * profile.
*/ */
export default class PostsUserPage extends UserPage { export default class PostsUserPage extends UserPage {
oninit(vnode) {
super.oninit(vnode);
/** /**
* Whether or not the activity feed is currently loading. * Whether or not the activity feed is currently loading.
*
* @type {Boolean}
*/ */
this.loading = true; loading: boolean = true;
/** /**
* Whether or not there are any more activity items that can be loaded. * Whether or not there are any more activity items that can be loaded.
*
* @type {Boolean}
*/ */
this.moreResults = false; moreResults: boolean = false;
/** /**
* The Post models in the feed. * The Post models in the feed.
*
* @type {Post[]}
*/ */
this.posts = []; posts: Post[] = [];
/** /**
* The number of activity items to load per request. * The number of activity items to load per request.
*
* @type {number}
*/ */
this.loadLimit = 20; loadLimit: number = 20;
oninit(vnode: Mithril.Vnode<IUserPageAttrs, this>) {
super.oninit(vnode);
this.loadUser(m.route.param('username')); 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 * Initialize the component with a user, and trigger the loading of their
* activity feed. * activity feed.
*/ */
show(user) { show(user: User): void {
super.show(user); super.show(user);
this.refresh(); this.refresh();
@ -113,14 +108,12 @@ export default class PostsUserPage extends UserPage {
/** /**
* Load a new page of the user's activity feed. * Load a new page of the user's activity feed.
* *
* @param {number} [offset] The position to start getting results from.
* @return {Promise<import('../../common/models/Post').default[]>}
* @protected * @protected
*/ */
loadResults(offset) { loadResults(offset = 0) {
return app.store.find('posts', { return app.store.find<Post[]>('posts', {
filter: { filter: {
author: this.user.username(), author: this.user!.username(),
type: 'comment', type: 'comment',
}, },
page: { offset, limit: this.loadLimit }, page: { offset, limit: this.loadLimit },
@ -138,11 +131,8 @@ export default class PostsUserPage extends UserPage {
/** /**
* Parse results and append them to the activity feed. * 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.loading = false;
this.posts.push(...results); this.posts.push(...results);

View File

@ -1,5 +1,5 @@
import app from '../../forum/app'; 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 ItemList from '../../common/utils/ItemList';
import UserCard from './UserCard'; import UserCard from './UserCard';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
@ -8,6 +8,10 @@ import LinkButton from '../../common/components/LinkButton';
import Separator from '../../common/components/Separator'; import Separator from '../../common/components/Separator';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import AffixedSidebar from './AffixedSidebar'; 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 * The `UserPage` component shows a user's profile. It can be extended to show
@ -16,24 +20,20 @@ import AffixedSidebar from './AffixedSidebar';
* *
* @abstract * @abstract
*/ */
export default class UserPage extends Page { export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttrs, CustomState = undefined> extends Page<CustomAttrs, CustomState> {
oninit(vnode) {
super.oninit(vnode);
/** /**
* The user this page is for. * The user this page is for.
*
* @type {User}
*/ */
this.user = null; user: User | null = null;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.bodyClass = 'App--user'; this.bodyClass = 'App--user';
} }
/** /**
* Base view template for the user page. * Base view template for the user page.
*
* @return {import('mithril').Children}
*/ */
view() { view() {
return ( return (
@ -64,19 +64,16 @@ export default class UserPage extends Page {
/** /**
* Get the content to display in the user 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 * Initialize the component with a user, and trigger the loading of their
* activity feed. * activity feed.
* *
* @param {import('../../common/models/User').default} user
* @protected * @protected
*/ */
show(user) { show(user: User): void {
this.user = user; this.user = user;
app.current.set('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 * 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. * 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(); const lowercaseUsername = username.toLowerCase();
// Load the preloaded user object, if any, into the global app store // 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 // instead of the parsed models
app.preloadedApiDocument(); app.preloadedApiDocument();
app.store.all('users').some((user) => { app.store.all<User>('users').some((user) => {
if ((user.username().toLowerCase() === lowercaseUsername || user.id() === username) && user.joinTime()) { if ((user.username().toLowerCase() === lowercaseUsername || user.id() === username) && user.joinTime()) {
this.show(user); this.show(user);
return true; return true;
} }
return false;
}); });
if (!this.user) { if (!this.user) {
app.store.find('users', username, { bySlug: true }).then(this.show.bind(this)); app.store.find<User>('users', username, { bySlug: true }).then(this.show.bind(this));
} }
} }
/** /**
* Build an item list for the content of the sidebar. * Build an item list for the content of the sidebar.
*
* @return {ItemList<import('mithril').Children>}
*/ */
sidebarItems() { sidebarItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
items.add( items.add(
'nav', 'nav',
@ -132,12 +127,10 @@ export default class UserPage extends Page {
/** /**
* Build an item list for the navigation in the sidebar. * Build an item list for the navigation in the sidebar.
*
* @return {ItemList<import('mithril').Children>}
*/ */
navItems() { navItems() {
const items = new ItemList(); const items = new ItemList<Mithril.Children>();
const user = this.user; const user = this.user!;
items.add( items.add(
'posts', 'posts',