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 Session from './Session';
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
*/
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.
*/

View File

@ -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;

View File

@ -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<IUserPageAttrs, DiscussionListState> {
oninit(vnode: Mithril.Vnode<IUserPageAttrs, this>) {
super.oninit(vnode);
this.loadUser(m.route.param('username'));
}
show(user) {
show(user: User): void {
super.show(user);
this.state = new DiscussionListState({

View File

@ -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<CustomAttrs extends IIndexPageAttrs = IIndexPageAttrs, CustomState = {}> extends Page<CustomAttrs, CustomState> {
static providesInitialSearch = true;
lastDiscussion?: Discussion;
oninit(vnode) {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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<CustomAttrs, this>) {
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<CustomAttrs, this>) {
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<CustomAttrs, this>) {
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<import('mithril').Children>}
*/
sidebarItems() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
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<import('mithril').Children>}
*/
navItems() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
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<import('mithril').Children>}
*/
viewItems() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
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<import('mithril').Children>}
*/
actionItems() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
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<void>}
*/
newDiscussionAction() {
newDiscussionAction(): Promise<unknown> {
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() });
}
}
}

View File

@ -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.
*
* @type {Boolean}
*/
this.loading = true;
loading: boolean = true;
/**
* 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.
*
* @type {Post[]}
*/
this.posts = [];
posts: Post[] = [];
/**
* 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'));
}
@ -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<import('../../common/models/Post').default[]>}
* @protected
*/
loadResults(offset) {
return app.store.find('posts', {
loadResults(offset = 0) {
return app.store.find<Post[]>('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);

View File

@ -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<CustomAttrs extends IUserPageAttrs = IUserPageAttrs, CustomState = undefined> extends Page<CustomAttrs, CustomState> {
/**
* 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';
}
/**
* 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<User>('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<User>('users', username, { bySlug: true }).then(this.show.bind(this));
}
}
/**
* Build an item list for the content of the sidebar.
*
* @return {ItemList<import('mithril').Children>}
*/
sidebarItems() {
const items = new ItemList();
const items = new ItemList<Mithril.Children>();
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<import('mithril').Children>}
*/
navItems() {
const items = new ItemList();
const user = this.user;
const items = new ItemList<Mithril.Children>();
const user = this.user!;
items.add(
'posts',