1
0
mirror of https://github.com/flarum/core.git synced 2025-07-11 20:06:23 +02:00

Mithril 2 update (#2255)

* Update frontend to Mithril 2

- Update Mithril version to v2.0.4
- Add Typescript typings for Mithril
- Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr".
- Use Mithril 2's new lifecycle hooks
- SubtreeRetainer has been rewritten to be more useful for the new system
- Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util)
- Other mechanical changes, following the upgrade guide
- Remove some of the custom stuff in our Component base class
- Introduce "fragments" for non-components that control their own DOM
- Remove Mithril patches, introduce a few new ones (route attrs in <a>; 
- Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)`
- The `affixedSidebar` util has been replaced with an `AffixedSidebar` component

Challenges:
- `children` and `tag` are now reserved, and can not be used as attr names
- Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples)
- Native Promise rejections are shown on console when not handled
- Instances of components can no longer be stored. The state pattern should be used instead.

Refs #1821.

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
This commit is contained in:
David Sevilla Martín
2020-09-23 22:40:37 -04:00
committed by GitHub
parent 1321b8cc28
commit 71f3379fcc
127 changed files with 2411 additions and 2074 deletions

11
js/package-lock.json generated
View File

@ -1075,6 +1075,11 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/mithril": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -3807,9 +3812,9 @@
}
},
"mithril": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
},
"mixin-deep": {
"version": "1.3.2",

View File

@ -3,6 +3,7 @@
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
@ -13,7 +14,7 @@
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8",
"mithril": "^2.0.4",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",

48
js/shims.d.ts vendored Normal file
View File

@ -0,0 +1,48 @@
// Mithril
import * as Mithril from 'mithril';
import Stream from 'mithril/stream';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
/**
* Helpers that flarum/core patches into Mithril
*/
interface m extends Mithril.Static {
prop: typeof Stream;
}
/**
* Export Mithril typings globally.
*
* This lets us use these typings without an extra import everywhere we use
* Mithril in a TypeScript file.
*/
export as namespace Mithril;
/**
* flarum/core exposes several extensions globally:
*
* - jQuery for convenient DOM manipulation
* - Mithril for VDOM and components
* - dayjs for date/time operations
*
* Since these are already part of the global namespace, extensions won't need
* to (and should not) bundle these themselves.
*/
declare global {
const $: typeof _$;
const m: m;
const dayjs: typeof _dayjs;
}
/**
* All global variables owned by flarum/core.
*/
declare global {
const app: Application;
}

View File

@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount();
// If an extension has just been enabled, then we will run its settings

View File

@ -10,11 +10,7 @@
import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton {
getButtonContent() {
const content = super.getButtonContent();
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
getButtonContent(children) {
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
}
}

View File

@ -31,62 +31,74 @@ export default class AdminNav extends Component {
items.add(
'dashboard',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'),
})
},
app.translator.trans('core.admin.nav.dashboard_button')
)
);
items.add(
'basics',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.admin.nav.basics_button'),
description: app.translator.trans('core.admin.nav.basics_text'),
})
},
app.translator.trans('core.admin.nav.basics_button')
)
);
items.add(
'mail',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('mail'),
icon: 'fas fa-envelope',
children: app.translator.trans('core.admin.nav.email_button'),
description: app.translator.trans('core.admin.nav.email_text'),
})
},
app.translator.trans('core.admin.nav.email_button')
)
);
items.add(
'permissions',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('permissions'),
icon: 'fas fa-key',
children: app.translator.trans('core.admin.nav.permissions_button'),
description: app.translator.trans('core.admin.nav.permissions_text'),
})
},
app.translator.trans('core.admin.nav.permissions_button')
)
);
items.add(
'appearance',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
children: app.translator.trans('core.admin.nav.appearance_button'),
description: app.translator.trans('core.admin.nav.appearance_text'),
})
},
app.translator.trans('core.admin.nav.appearance_button')
)
);
items.add(
'extensions',
AdminLinkButton.component({
AdminLinkButton.component(
{
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
children: app.translator.trans('core.admin.nav.extensions_button'),
description: app.translator.trans('core.admin.nav.extensions_text'),
})
},
app.translator.trans('core.admin.nav.extensions_button')
)
);
return items;

View File

@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class AppearancePage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
}
view() {
@ -27,40 +28,34 @@ export default class AppearancePage extends Page {
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
</div>
{Switch.component({
{Switch.component(
{
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode,
})}
},
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{Switch.component({
{Switch.component(
{
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader,
})}
},
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{Button.component({
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading,
})}
},
app.translator.trans('core.admin.appearance.submit_button')
)}
</fieldset>
</form>
@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component({
{Button.component(
{
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(EditCustomHeaderModal),
})}
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component({
{Button.component(
{
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(EditCustomFooterModal),
})}
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component({
{Button.component(
{
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(EditCustomCssModal),
})}
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>
</div>
</div>

View File

@ -5,10 +5,11 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
export default class BasicsPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
@ -25,7 +26,7 @@ export default class BasicsPage extends Page {
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
@ -49,45 +50,51 @@ export default class BasicsPage extends Page {
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
})}
},
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
<textarea className="FormControl" bidi={this.values.forum_description} />,
]
)}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
},
[
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component({
Switch.component(
{
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
},
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
: ''}
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
@ -98,51 +105,52 @@ export default class BasicsPage extends Page {
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)}
onclick={withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
],
})}
]
)}
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
</div>,
],
})}
]
)}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component({
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
children: [
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
value: this.values.display_name_driver(),
onchange: this.values.display_name_driver,
bidi: this.values.display_name_driver,
}),
],
})
]
)
: ''}
{Button.component({
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading,
disabled: !this.changed(),
})}
},
app.translator.trans('core.admin.basics.submit_button')
)}
</form>
</div>
</div>
@ -185,10 +193,7 @@ export default class BasicsPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
})
.catch(() => {})
.then(() => {

View File

@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
init() {
this.group = this.props.group || app.store.createRecord('groups');
oninit(vnode) {
super.oninit(vnode);
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
this.isHidden = m.prop(this.group.isHidden() || false);
this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = m.stream(this.group.nameSingular() || '');
this.namePlural = m.stream(this.group.namePlural() || '');
this.icon = m.stream(this.group.icon() || '');
this.color = m.stream(this.group.color() || '');
this.isHidden = m.stream(this.group.isHidden() || false);
}
className() {
@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
</div>
</div>,
30
@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>,
20
);
@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>,
10
);
@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
items.add(
'hidden',
<div className="Form-group">
{Switch.component({
{Switch.component(
{
state: !!Number(this.isHidden()),
children: app.translator.trans('core.admin.edit_group.hide_label'),
onchange: this.isHidden,
})}
},
app.translator.trans('core.admin.edit_group.hide_label')
)}
</div>,
10
);
@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
{Button.component(
{
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'),
})}
},
app.translator.trans('core.admin.edit_group.submit_button')
)}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}

View File

@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component({
children: app.translator.trans('core.admin.extensions.add_button'),
{Button.component(
{
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
})}
},
app.translator.trans('core.admin.extensions.add_button')
)}
</div>
</div>
@ -72,20 +74,22 @@ export default class ExtensionsPage extends Page {
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component({
Button.component(
{
icon: 'fas fa-cog',
children: app.translator.trans('core.admin.extensions.settings_button'),
onclick: app.extensionSettings[name],
})
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component({
Button.component(
{
icon: 'far fa-trash-alt',
children: app.translator.trans('core.admin.extensions.uninstall_button'),
onclick: () => {
app
.request({
@ -96,7 +100,9 @@ export default class ExtensionsPage extends Page {
app.modal.show(LoadingModal);
},
})
},
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
}
@ -116,7 +122,7 @@ export default class ExtensionsPage extends Page {
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
data: { enabled: !enabled },
body: { enabled: !enabled },
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);

View File

@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*

View File

@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class MailPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.saving = false;
this.sendingTest = false;
@ -24,7 +25,7 @@ export default class MailPage extends Page {
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
app
.request({
@ -39,7 +40,7 @@ export default class MailPage extends Page {
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = m.prop(settings[field]);
this.values[field] = m.stream(settings[field]);
}
}
@ -69,23 +70,27 @@ export default class MailPage extends Page {
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
<input className="FormControl" bidi={this.values.mail_from} />
</label>
</div>,
],
})}
]
)}
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
@ -96,20 +101,24 @@ export default class MailPage extends Page {
/>
</label>
</div>,
],
})}
]
)}
{this.status.sending ||
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
Alert.component(
{
dismissible: false,
})}
},
app.translator.trans('core.admin.email.not_sending_message')
)}
{fieldKeys.length > 0 &&
FieldSet.component({
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
},
[
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
@ -119,31 +128,37 @@ export default class MailPage extends Page {
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
],
})}
]
)}
<FieldSet>
{Button.component({
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
},
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet>
{FieldSet.component({
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
children: [
},
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component({
Button.component(
{
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
}),
],
})}
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
]
)}
</form>
</div>
</div>
@ -156,7 +171,7 @@ export default class MailPage extends Page {
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
return <input className="FormControl" bidi={prop} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
@ -179,10 +194,7 @@ export default class MailPage extends Page {
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
})
.catch((error) => {
this.sendingTest = false;
@ -205,10 +217,7 @@ export default class MailPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
})
.catch(() => {})
.then(() => {

View File

@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
}
export default class PermissionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text';
attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text';
}
view() {
this.props.children = [];
view(vnode) {
const children = [];
let groupIds = app.data.permissions[this.props.permission] || [];
let groupIds = app.data.permissions[this.attrs.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) {
this.props.label = Badge.component({ icon: 'fas fa-globe' });
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) {
this.props.label = Badge.component({ icon: 'fas fa-user' });
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
} else {
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
}
if (this.showing) {
if (this.props.allowGuest) {
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
if (this.attrs.allowGuest) {
children.push(
Button.component(
{
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
})
},
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
)
);
}
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
children.push(
Button.component(
{
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
}),
},
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
),
Separator.component(),
Button.component({
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
Button.component(
{
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
})
},
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
)
);
[].push.apply(
this.props.children,
children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) =>
Button.component({
children: [badgeForId(group.id()), ' ', group.namePlural()],
Button.component(
{
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
})
},
[badgeForId(group.id()), ' ', group.namePlural()]
)
)
);
}
return super.view();
return super.view({ ...vnode, children });
}
save(groupIds) {
const permission = this.props.permission;
const permission = this.attrs.permission;
app.data.permissions[permission] = groupIds;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission',
data: { permission, groupIds },
body: { permission, groupIds },
});
}
toggle(groupId) {
const permission = this.props.permission;
const permission = this.attrs.permission;
let groupIds = app.data.permissions[permission] || [];
@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
}
}

View File

@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}

View File

@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
}
getButtonContent() {
@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
items.add(
'logOut',
Button.component({
Button.component(
{
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
},
app.translator.trans('core.admin.header.log_out_button')
),
-100
);

View File

@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingDropdown extends SelectDropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'SettingDropdown';
props.buttonClassName = 'Button Button--text';
props.caretIcon = 'fas fa-caret-down';
props.defaultLabel = 'Custom';
attrs.className = 'SettingDropdown';
attrs.buttonClassName = 'Button Button--text';
attrs.caretIcon = 'fas fa-caret-down';
attrs.defaultLabel = 'Custom';
}
props.children = props.options.map(({ value, label }) => {
const active = app.data.settings[props.key] === value;
view(vnode) {
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.key] === value;
return Button.component({
children: label,
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [props.key]: value }),
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
active,
});
},
label
);
}),
});
}
}

View File

@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
init() {
oninit(vnode) {
super.oninit(vnode);
this.settings = {};
this.loading = false;
}
@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
return this.settings[key];
}

View File

@ -1,32 +1,28 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
init() {
this.loading = false;
}
loading = false;
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
view(vnode) {
this.attrs.loading = this.loading;
this.attrs.className = (this.attrs.className || '') + ' Button';
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
if (app.data.settings[this.attrs.name + '_path']) {
this.attrs.onclick = this.remove.bind(this);
return (
<div>
<p>
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
</p>
<p>{super.view()}</p>
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
</div>
);
} else {
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
this.attrs.onclick = this.upload.bind(this);
}
return super.view();
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
}
/**
@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
.hide()
.click()
.on('change', (e) => {
const data = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]);
const body = new FormData();
body.append(this.attrs.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
data,
body,
})
.then(this.success.bind(this), this.failure.bind(this));
});
@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.props.name;
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
}
/**

View File

@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
*/
export default function (app) {
app.routes = {
dashboard: { path: '/', component: DashboardPage.component() },
basics: { path: '/basics', component: BasicsPage.component() },
permissions: { path: '/permissions', component: PermissionsPage.component() },
appearance: { path: '/appearance', component: AppearancePage.component() },
extensions: { path: '/extensions', component: ExtensionsPage.component() },
mail: { path: '/mail', component: MailPage.component() },
dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage },
};
}

View File

@ -7,7 +7,7 @@ export default function saveSettings(settings) {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
data: settings,
body: settings,
})
.catch((error) => {
app.data.settings = oldSettings;

View File

@ -189,8 +189,9 @@ export default class Application {
}
mount(basePath = '') {
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.drawer = new Drawer();
@ -263,7 +264,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}
@ -342,16 +343,14 @@ export default class Application {
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
const deferred = m.deferred();
m.request(options).then(
(response) => deferred.resolve(response),
return m.request(options).then(
(response) => response,
(error) => {
let children;
let content;
switch (error.status) {
case 422:
children = error.response.errors
content = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
@ -359,30 +358,31 @@ export default class Application {
case 401:
case 403:
children = app.translator.trans('core.lib.error.permission_denied_message');
content = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
children = app.translator.trans('core.lib.error.not_found_message');
content = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
children = app.translator.trans('core.lib.error.generic_message');
content = app.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n'
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
error.alert = {
type: 'error',
children,
content,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
@ -404,14 +404,12 @@ export default class Application {
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert);
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
}
deferred.reject(error);
return Promise.reject(error);
}
);
return deferred.promise;
}
/**
@ -434,9 +432,19 @@ export default class Application {
* @public
*/
route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}

View File

@ -1,221 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* The `Component` class defines a user interface 'building block'. A component
* can generate a virtual DOM to be rendered on each redraw.
*
* An instance's virtual DOM can be retrieved directly using the {@link
* Component#render} method.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* Alternatively, components can be nested, letting Mithril take care of
* instance persistence. For this, the static {@link Component.component} method
* can be used.
*
* @example
* return m('div', MyComponent.component({foo: 'bar'));
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @abstract
*/
export default class Component {
/**
* @param {Object} props
* @param {Array|Object} children
* @public
*/
constructor(props = {}, children = null) {
if (children) props.children = children;
this.constructor.initProps(props);
/**
* The properties passed into the component.
*
* @type {Object}
*/
this.props = props;
/**
* The root DOM element for the component.
*
* @type DOMElement
* @public
*/
this.element = null;
/**
* Whether or not to retain the component's subtree on redraw.
*
* @type {boolean}
* @public
*/
this.retain = false;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {}
/**
* Called when the component is destroyed, i.e. after a redraw where it is no
* longer a part of the view.
*
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
* @param {Object} e
* @public
*/
onunload() {}
/**
* Get the renderable virtual DOM that represents the component's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Component#view instead.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* @returns {Object}
* @final
* @public
*/
render() {
const vdom = this.retain ? { subtree: 'retain' } : this.view();
// Override the root element's config attribute with our own function, which
// will set the component instance's element property to the root DOM
// element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
const originalConfig = vdom.attrs.config;
vdom.attrs.config = (...args) => {
this.element = args[0];
this.config.apply(this, args.slice(1));
if (originalConfig) originalConfig.apply(this, args);
};
return vdom;
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
* @public
*/
$(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Called after the component's root element is redrawn. This hook can be used
* to perform any actions on the DOM, both on the initial draw and any
* subsequent redraws. See Mithril's documentation for more information.
*
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
* @param {Boolean} isInitialized
* @param {Object} context
* @param {Object} vdom
* @public
*/
config() {}
/**
* Get the virtual DOM that represents the component's view.
*
* @return {Object} The virtual DOM
* @protected
*/
view() {
throw new Error('Component#view must be implemented by subclass');
}
/**
* Get a Mithril component object for this component, preloaded with props.
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @param {Object} [props] Properties to set on the component
* @param children
* @return {Object} The Mithril component object
* @property {function} controller
* @property {function} view
* @property {Object} component The class of this component
* @property {Object} props The props that were passed to the component
* @public
*/
static component(props = {}, children = null) {
const componentProps = Object.assign({}, props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = componentProps;
return component.render();
};
// Mithril uses this property on the view function to cache component
// controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
// Our output object consists of a controller constructor + a view function
// which Mithril will use to instantiate and render the component. We also
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, componentProps),
view: view,
props: componentProps,
component: this,
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (componentProps.key) {
output.attrs = { key: componentProps.key };
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {}
}

136
js/src/common/Component.ts Normal file
View File

@ -0,0 +1,136 @@
import * as Mithril from 'mithril';
export type ComponentAttrs = {
className?: string;
[key: string]: any;
};
/**
* The `Component` class defines a user interface 'building block'. A component
* generates a virtual DOM to be rendered on each redraw.
*
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
*
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
* This allows us to use attrs across components without having to pass the vnode to every single
* method.
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
* the attrs that have been passed into a component.
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
*
* As with other Mithril components, components extending Component can be initialized
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
* be used.
*
* @example
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
*
* @example
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
*
* @see https://mithril.js.org/components.html
*/
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
/**
* The root DOM element for the component.
*/
protected element!: Element;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*/
protected attrs!: T;
/**
* @inheritdoc
*/
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
/**
* @inheritdoc
*/
oninit(vnode: Mithril.Vnode<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
this.element = vnode.dom;
}
/**
* @inheritdoc
*/
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
protected $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Convenience method to attach a component without JSX.
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
*
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
*/
static component(attrs = {}, children = null): Mithril.Vnode {
const componentAttrs = Object.assign({}, attrs);
return m(this as any, componentAttrs, children);
}
/**
* Saves a reference to the vnode attrs after running them through initAttrs,
* and checking for common issues.
*/
private setAttrs(attrs: T = {} as T): void {
(this.constructor as typeof Component).initAttrs(attrs);
if (attrs) {
if ('children' in attrs) {
throw new Error(
`[${
(this.constructor as any).name
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
);
}
if ('tag' in attrs) {
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
}
}
this.attrs = attrs;
}
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
}

74
js/src/common/Fragment.ts Normal file
View File

@ -0,0 +1,74 @@
import * as Mithril from 'mithril';
/**
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
* over control of its own DOM and lifecycle.
*
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
* do not offer Mithril's lifecycle hooks.
*
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
* everything through Mithril.
*
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
* this or `Component, you probably need `Component`.
*/
export default abstract class Fragment {
/**
* The root DOM element for the fragment.
*/
protected element!: Element;
/**
* Returns a jQuery object for this fragment's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `fragment.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* fragment.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
public $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Get the renderable virtual DOM that represents the fragment's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Fragment#view instead.
*
* @example
* const fragment = new MyFragment();
* m.render(document.body, fragment.render());
*
* @final
*/
public render(): Mithril.Vnode<Mithril.Attributes, this> {
const vdom = this.view();
vdom.attrs = vdom.attrs || {};
const originalOnCreate = vdom.attrs.oncreate;
vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom;
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
};
return vdom;
}
/**
* Creates a view out of virtual elements.
*/
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
}

View File

@ -161,7 +161,7 @@ export default class Model {
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: request,
body: request,
},
options
)
@ -180,7 +180,7 @@ export default class Model {
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.lazyRedraw();
m.redraw();
throw response;
}
);
@ -189,13 +189,13 @@ export default class Model {
/**
* Send a request to delete the resource.
*
* @param {Object} data Data to send along with the DELETE request.
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(data, options = {}) {
if (!this.exists) return m.deferred().resolve().promise;
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
return app
.request(
@ -203,7 +203,7 @@ export default class Model {
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data,
body,
},
options
)

View File

@ -30,13 +30,13 @@ export default class Session {
* @return {Promise}
* @public
*/
login(data, options = {}) {
login(body, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: app.forum.attribute('baseUrl') + '/login',
data,
url: `${app.forum.attribute('baseUrl')}/login`,
body,
},
options
)
@ -49,6 +49,6 @@ export default class Session {
* @public
*/
logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

View File

@ -82,13 +82,13 @@ export default class Store {
* @public
*/
find(type, id, query = {}, options = {}) {
let data = query;
let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
data = id;
params = id;
} else if (id) {
url += '/' + id;
}
@ -99,7 +99,7 @@ export default class Store {
{
method: 'GET',
url,
data,
params,
},
options
)

View File

@ -1,4 +1,3 @@
import User from './models/User';
import username from './helpers/username';
import extract from './utils/extract';
@ -71,18 +70,34 @@ export default class Translator {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});

View File

@ -13,6 +13,7 @@ import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
@ -22,6 +23,7 @@ import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@ -62,6 +64,7 @@ import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
export default {
extend: extend,
@ -83,11 +86,13 @@ export default {
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
@ -95,6 +100,7 @@ export default {
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,

View File

@ -7,7 +7,7 @@ import extract from '../utils/extract';
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* The alert may have the following special props:
* ### Attrs
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
@ -15,16 +15,16 @@ import extract from '../utils/extract';
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
view(vnode) {
const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children');
const content = extract(attrs, 'content') || vnode.children;
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
@ -40,7 +40,7 @@ export default class Alert extends Component {
return (
<div {...attrs}>
<span className="Alert-body">{children}</span>
<span className="Alert-body">{content}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);

View File

@ -6,8 +6,10 @@ import Alert from './Alert';
* be shown and dismissed.
*/
export default class AlertManager extends Component {
init() {
this.state = this.props.state;
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
}
view() {
@ -15,17 +17,12 @@ export default class AlertManager extends Component {
<div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
))}
</div>
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
}

View File

@ -6,18 +6,18 @@ import extract from '../utils/extract';
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special props:
* A badge may have the following special attrs:
*
* - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge.
* - `label`
*
* All other props will be assigned as attributes on the badge element.
* All other attrs will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view() {
const attrs = Object.assign({}, this.props);
const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
@ -27,9 +27,9 @@ export default class Badge extends Component {
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
if (this.props.label) this.$().tooltip();
if (this.attrs.label) this.$().tooltip();
}
}

View File

@ -1,12 +1,15 @@
import Component from '../Component';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
/**
* The `Button` component defines an element which, when clicked, performs an
* action. The button may have the following special props:
* action.
*
* ### Attrs
*
* - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
* removed.
* - `loading` Whether or not the button should be in a disabled loading state.
*
* All other props will be assigned as attributes on the button element.
* All other attrs will be assigned as attributes on the button element.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button extends Component {
view() {
const attrs = Object.assign({}, this.props);
view(vnode) {
const attrs = Object.assign({}, this.attrs);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !this.props.children) {
if (attrs.title && !vnode.children) {
attrs['aria-label'] = attrs.title;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent()}</button>;
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
}
/**
@ -58,13 +58,13 @@ export default class Button extends Component {
* @return {*}
* @protected
*/
getButtonContent() {
const iconName = this.props.icon;
getButtonContent(children) {
const iconName = this.attrs.icon;
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
children ? <span className="Button-label">{children}</span> : '',
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
];
}
}

View File

@ -1,11 +1,13 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Props
* ### Attrs
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
@ -15,19 +17,24 @@ import icon from '../helpers/icon';
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
view() {
view(vnode) {
// Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced
if (this.props.state === '0') this.props.state = false;
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.props.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
if (this.attrs.state === '0') this.attrs.state = false;
const className = classList([
'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
return (
<label className={className}>
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children}
{vnode.children}
</label>
);
}
@ -39,7 +46,7 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
}
/**
@ -49,6 +56,6 @@ export default class Checkbox extends Component {
* @protected
*/
onchange(checked) {
if (this.props.onchange) this.props.onchange(checked, this);
if (this.attrs.onchange) this.attrs.onchange(checked, this);
}
}

View File

@ -5,7 +5,7 @@ import Component from '../Component';
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Props
* ### Attrs
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
@ -17,21 +17,24 @@ import Component from '../Component';
*
*/
export default class ConfirmDocumentUnload extends Component {
config(isInitialized, context) {
if (isInitialized) return;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
handler() {
return this.attrs.when() || undefined;
}
view() {
oncreate(vnode) {
super.oncreate(vnode);
this.boundHandler = this.handler.bind(this);
$(window).on('beforeunload', this.boundHandler);
}
onremove() {
$(window).off('beforeunload', this.boundHandler);
}
view(vnode) {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return this.props.children[0];
return vnode.children[0];
}
}

View File

@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Props
* ### Attrs
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
attrs.menuClassName = attrs.menuClassName || '';
attrs.label = attrs.label || '';
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
}
init() {
oninit(vnode) {
super.oninit(vnode);
this.showing = false;
}
view() {
const items = this.props.children ? listItems(this.props.children) : [];
view(vnode) {
const items = vnode.children ? listItems(vnode.children) : [];
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()}
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children)}
{this.getMenu(items)}
</div>
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
@ -53,8 +53,8 @@ export default class Dropdown extends Component {
this.$().on('shown.bs.dropdown', () => {
this.showing = true;
if (this.props.onshow) {
this.props.onshow();
if (this.attrs.onshow) {
this.attrs.onshow();
}
m.redraw();
@ -76,8 +76,8 @@ export default class Dropdown extends Component {
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.props.onhide) {
this.props.onhide();
if (this.attrs.onhide) {
this.attrs.onhide();
}
m.redraw();
@ -90,10 +90,10 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButton() {
getButton(children) {
return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
{this.getButtonContent()}
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
{this.getButtonContent(children)}
</button>
);
}
@ -104,15 +104,15 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButtonContent() {
getButtonContent(children) {
return [
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.attrs.label}</span>,
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}
}

View File

@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
* The children should be an array of items to show in the fieldset.
*/
export default class FieldSet extends Component {
view() {
view(vnode) {
return (
<fieldset className={this.props.className}>
<legend>{this.props.label}</legend>
<ul>{listItems(this.props.children)}</ul>
<fieldset className={this.attrs.className}>
<legend>{this.attrs.label}</legend>
<ul>{listItems(vnode.children)}</ul>
</fieldset>
);
}

View File

@ -1,16 +1,16 @@
import Badge from './Badge';
export default class GroupBadge extends Badge {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
if (props.group) {
props.icon = props.group.icon();
props.style = { backgroundColor: props.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = 'group--' + props.group.id();
if (attrs.group) {
attrs.icon = attrs.group.icon();
attrs.style = { backgroundColor: attrs.group.color() };
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
attrs.type = 'group--' + attrs.group.id();
delete props.group;
delete attrs.group;
}
}
}

View File

@ -3,9 +3,9 @@ import Button from './Button';
/**
* The `LinkButton` component defines a `Button` which links to a route.
*
* ### Props
* ### Attrs
*
* All of the props accepted by `Button`, plus:
* All of the attrs accepted by `Button`, plus:
*
* - `active` Whether or not the page that this button links to is currently
* active.
@ -13,26 +13,28 @@ import Button from './Button';
* the `active` prop will automatically be set to true.
*/
export default class LinkButton extends Button {
static initProps(props) {
props.active = this.isActive(props);
props.config = props.config || m.route;
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.active = this.isActive(attrs);
}
view() {
const vdom = super.view();
view(vnode) {
const vdom = super.view(vnode);
vdom.tag = 'a';
vdom.tag = m.route.Link;
vdom.attrs.active = String(vdom.attrs.active);
return vdom;
}
/**
* Determine whether a component with the given props is 'active'.
* Determine whether a component with the given attrs is 'active'.
*
* @param {Object} props
* @param {Object} attrs
* @return {Boolean}
*/
static isActive(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
static isActive(attrs) {
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
}
}

View File

@ -2,16 +2,17 @@ import Component from '../Component';
import { Spinner } from 'spin.js';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
* may have the following special props:
* The `LoadingIndicator` component displays a loading spinner with spin.js.
*
* ### Attrs
*
* - `size` The spin.js size preset to use. Defaults to 'small'.
*
* All other props will be assigned as attributes on the element.
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class LoadingIndicator extends Component {
view() {
const attrs = Object.assign({}, this.props);
const attrs = Object.assign({}, this.attrs);
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.props.size) {
switch (this.attrs.size) {
case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break;

View File

@ -14,23 +14,21 @@ export default class Modal extends Component {
*/
static isDismissible = true;
init() {
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
this.alertAttrs = null;
alertAttrs = null;
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.onshow(() => this.onready());
}
config(isInitialized, context) {
if (isInitialized) return;
this.props.onshow(() => this.onready());
context.onunload = () => {
this.props.onhide();
};
onremove() {
this.attrs.onhide();
}
view() {
@ -109,7 +107,7 @@ export default class Modal extends Component {
* Hide the modal.
*/
hide() {
this.props.onhide();
this.attrs.onhide();
}
/**

View File

@ -6,12 +6,8 @@ import Component from '../Component';
* overwrite the previous one.
*/
export default class ModalManager extends Component {
init() {
this.state = this.props.state;
}
view() {
const modal = this.state.modal;
const modal = this.attrs.state.modal;
return (
<div className="ModalManager modal fade">
@ -20,22 +16,17 @@ export default class ModalManager extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
oncreate(vnode) {
super.oncreate(vnode);
// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
}
animateShow(readyCallback) {
const dismissible = !!this.state.modal.componentClass.isDismissible;
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
this.$()
.one('shown.bs.modal', readyCallback)

View File

@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following props:
* Accepts the following attrs:
*
* - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if
@ -23,7 +23,7 @@ export default class Navigation extends Component {
return (
<div
className={'Navigation ButtonGroup ' + (this.props.className || '')}
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)}
>
@ -32,13 +32,6 @@ export default class Navigation extends Component {
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Get the back button.
*
@ -54,7 +47,6 @@ export default class Navigation extends Component {
href: history.backUrl(),
icon: 'fas fa-chevron-left',
title: previous.title,
config: () => {},
onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
@ -88,7 +80,7 @@ export default class Navigation extends Component {
* @protected
*/
getDrawerButton() {
if (!this.props.drawer) return '';
if (!this.attrs.drawer) return '';
const { drawer } = app;
const user = app.session.user;

View File

@ -7,10 +7,14 @@ import PageState from '../states/PageState';
* @abstract
*/
export default class Page extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
app.previous = app.current;
app.current = new PageState(this.constructor);
this.onNewRoute();
app.drawer.hide();
app.modal.close();
@ -22,13 +26,27 @@ export default class Page extends Component {
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
/**
* A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
app.current.set('routeName', this.attrs.routeName);
}
oncreate(vnode) {
super.oncreate(vnode);
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
}
context.onunload = () => $('#app').removeClass(this.bodyClass);
onremove() {
if (this.bodyClass) {
$('#app').removeClass(this.bodyClass);
}
}
}

View File

@ -4,7 +4,7 @@ import Component from '../Component';
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### Props
* ### Attrs
*
* - `text`
*/
@ -12,7 +12,7 @@ export default class Placeholder extends Component {
view() {
return (
<div className="Placeholder">
<p>{this.props.text}</p>
<p>{this.attrs.text}</p>
</div>
);
}

View File

@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
}
title() {
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
}
content() {
const { error, formattedError } = this.props;
const { error, formattedError } = this.attrs;
let responseText;
@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
return (
<div className="Modal-body">
<pre>
{this.props.error.options.method} {this.props.error.options.url}
{this.attrs.error.options.method} {this.attrs.error.options.url}
<br />
<br />
{responseText}

View File

@ -1,9 +1,10 @@
import Component from '../Component';
import icon from '../helpers/icon';
import withAttr from '../utils/withAttr';
/**
* The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following props:
* elements for styling. It accepts the following attrs:
*
* - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed.
@ -12,13 +13,13 @@ import icon from '../helpers/icon';
*/
export default class Select extends Component {
view() {
const { options, onchange, value, disabled } = this.props;
const { options, onchange, value, disabled } = this.attrs;
return (
<span className="Select">
<select
className="Select-input FormControl"
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
value={value}
disabled={disabled}
>

View File

@ -1,31 +1,49 @@
import Dropdown from './Dropdown';
import icon from '../helpers/icon';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode) {
const tag = vnode.tag;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
}
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*
* ### Props
* ### Attrs
*
* - `caretIcon`
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown {
static initProps(props) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
static initAttrs(attrs) {
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
super.initProps(props);
super.initAttrs(attrs);
props.className += ' Dropdown--select';
attrs.className += ' Dropdown--select';
}
getButtonContent() {
const activeChild = this.props.children.filter((child) => child.props.active)[0];
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
getButtonContent(children) {
const activeChild = children.find(isActive);
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
}
}

View File

@ -7,25 +7,25 @@ import icon from '../helpers/icon';
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right';
attrs.className += ' Dropdown--split';
attrs.menuClassName += ' Dropdown-menu--right';
}
getButton() {
// Make a copy of the props of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as
getButton(children) {
// Make a copy of the attrs of the first child component. We will assign
// these attrs to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild();
const buttonProps = Object.assign({}, firstChild.props);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
const firstChild = this.getFirstChild(children);
const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
return [
Button.component(buttonProps),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })}
Button.component(buttonAttrs, firstChild.children),
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
{icon(this.attrs.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>,
];
@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
* @return {*}
* @protected
*/
getFirstChild() {
let firstChild = this.props.children;
getFirstChild(children) {
let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0];

View File

@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
* a tick/cross one.
*/
export default class Switch extends Checkbox {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = (props.className || '') + ' Checkbox--switch';
attrs.className = (attrs.className || '') + ' Checkbox--switch';
}
getDisplay() {
return this.props.loading ? super.getDisplay() : '';
return this.attrs.loading ? super.getDisplay() : '';
}
}

View File

@ -2,14 +2,14 @@ import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item) {
return item && item.component === Separator;
return item.tag === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.forEach((item, i) => {
items.filter(Boolean).forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
@ -30,21 +30,27 @@ export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.component && item.component.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props);
const className = item.props ? item.props.itemClassName : item.itemClassName;
const isListItem = item.tag && item.tag.isListItem;
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
}
return isListItem ? (
const node = isListItem ? (
item
) : (
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
<li
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
key={(item.attrs && item.attrs.key) || item.itemName}
>
{item}
</li>
);
return node;
});
}

View File

@ -13,7 +13,21 @@ export default class AlertManagerState {
/**
* Show an Alert in the alerts area.
*/
show(attrs, componentClass = Alert) {
show(arg1, arg2, arg3) {
let componentClass = Alert;
let attrs = {};
let children;
if (arguments.length == 1) {
children = arg1;
} else if (arguments.length == 2) {
attrs = arg1;
children = arg2;
} else if (arguments.length == 3) {
componentClass = arg1;
attrs = arg2;
children = arg3;
}
// Breaking Change Compliance Warning, Remove in Beta 15.
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
if (attrs === Alert || attrs instanceof Alert) {
@ -22,7 +36,7 @@ export default class AlertManagerState {
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
}
// End Change Compliance Warning, Remove in Beta 15
this.activeAlerts[++this.alertId] = { attrs, componentClass };
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
m.redraw();
return this.alertId;

View File

@ -32,7 +32,7 @@ export default class ModalManagerState {
this.modal = { componentClass, attrs };
m.redraw(true);
m.redraw.sync();
}
/**
@ -50,7 +50,7 @@ export default class ModalManagerState {
// ahead.
this.closeTimeout = setTimeout(() => {
this.modal = null;
m.lazyRedraw();
m.redraw();
});
}
}

View File

@ -1,20 +1,25 @@
/**
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
* keeps track of a number of pieces of data, allowing the subtree to be
* retained if none of them have changed.
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
* comparing the values of these pieces at every iteration.
*
* This is useful for preventing redraws to relatively static (or huge)
* components whose VDOM only depends on very few values, when none of them
* have changed.
*
* @example
* // constructor
* // Check two callbacks for changes on each update
* this.subtree = new SubtreeRetainer(
* () => this.props.post.freshness,
* () => this.attrs.post.freshness,
* () => this.showing
* );
* this.subtree.check(() => this.props.user.freshness);
*
* // view
* this.subtree.retain() || 'expensive expression'
* // Add more callbacks to be checked for updates
* this.subtree.check(() => this.attrs.user.freshness);
*
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
* // In a component's onbeforeupdate() method:
* return this.subtree.needsRebuild()
*
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
*/
export default class SubtreeRetainer {
/**
@ -26,13 +31,13 @@ export default class SubtreeRetainer {
}
/**
* Return a virtual DOM directive that will retain a subtree if no data has
* changed since the last check.
* Return whether any data has changed since the last check.
* If so, Mithril needs to re-diff the vnode and its children.
*
* @return {Object|false}
* @return {boolean}
* @public
*/
retain() {
needsRebuild() {
let needsRebuild = false;
this.callbacks.forEach((callback, i) => {
@ -44,7 +49,7 @@ export default class SubtreeRetainer {
}
});
return needsRebuild ? false : { subtree: 'retain' };
return needsRebuild;
}
/**

View File

@ -8,7 +8,7 @@ export default function extractText(vdom) {
if (vdom instanceof Array) {
return vdom.map((element) => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return extractText(vdom.children);
return vdom.children ? extractText(vdom.children) : vdom.text;
} else {
return vdom;
}

View File

@ -2,7 +2,7 @@
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril.
*
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
* @see https://mithril.js.org/route.html#signature
* @param {Object} routes
* @param {String} [basePath]
* @return {Object}
@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
for (const key in routes) {
const route = routes[key];
if (route.component) route.component.props.routeName = key;
map[basePath + route.path] = route.component;
map[basePath + route.path] = {
render() {
return m(route.component, { routeName: key });
},
};
}
return map;

View File

@ -1,27 +1,60 @@
import Component from '../Component';
import Stream from 'mithril/stream';
import extract from './extract';
export default function patchMithril(global) {
const mo = global.m;
const defaultMithril = global.m;
const m = function (comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) {
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
/**
* If the href URL of the link is the same as the current page path
* we will not add a new entry to the browser history.
*
* This allows us to still refresh the Page component
* without adding endless history entries.
*
* We also add the `force` attribute that adds a custom state key
* for when you want to force a complete refresh of the Page
*/
const defaultLinkView = defaultMithril.route.Link.view;
const modifiedLink = {
view: function (vnode) {
let { href, options = {} } = vnode.attrs;
if (href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
}
return comp.component(args[0], children);
if (extract(vnode.attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
}
const node = mo.apply(this, arguments);
vnode.attrs.options = options;
return defaultLinkView(vnode);
},
};
const modifiedMithril = function (comp, ...args) {
const node = defaultMithril.apply(this, arguments);
if (!node.attrs) node.attrs = {};
// Allows the use of the bidi attr.
if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi);
modifiedMithril.bidi(node, node.attrs.bidi);
}
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
// supports linking to other pages in the SPA without refreshing the document.
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.config = m.route;
node.tag = modifiedLink;
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
if (node.text) {
node.children = { tag: '#', children: node.text };
}
delete node.attrs.route;
}
@ -29,17 +62,11 @@ export default function patchMithril(global) {
return node;
};
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
/**
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function () {
m.startComputation();
m.endComputation();
};
modifiedMithril.stream = Stream;
global.m = m;
modifiedMithril.route.Link = modifiedLink;
global.m = modifiedMithril;
}

View File

@ -0,0 +1,15 @@
import Mithril from 'mithril';
/**
* Mithril 2 does not completely rerender the page if a route change leads to the same route
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
*
* @see https://mithril.js.org/route.html#key-parameter
*/
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
const newOptions = { ...options };
newOptions.state = newOptions.state || {};
newOptions.state.key = Date.now();
m.route.set(route, params, newOptions);
}

View File

@ -0,0 +1,15 @@
/**
* An event handler factory that makes it simpler to implement data binding
* for component event listeners.
*
* The handler created by this factory passes the DOM element's attribute
* identified by the first argument to the callback (usually a bidirectional
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
*
* Replaces m.withAttr for Mithril 2.0.
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
*/
export default (key: string, cb: Function) =>
function (this: Element) {
cb(this.getAttribute(key) || this[key]);
};

View File

@ -115,15 +115,15 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app'));
m.route.mode = 'pathname';
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged
* @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` attr indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/

View File

@ -3,7 +3,6 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber';
@ -77,7 +78,6 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History,
'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation,
@ -89,6 +89,7 @@ export default Object.assign(compat, {
'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
@ -135,6 +136,7 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber,

View File

@ -0,0 +1,51 @@
import Component from '../../common/Component';
/**
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
* sidebar navigation at the top of the viewport when scrolling.
*
* ### Children
*
* The component must wrap an element that itself wraps an <ul> element, which
* will be "affixed".
*
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
*/
export default class AffixedSidebar extends Component {
view(vnode) {
return vnode.children[0];
}
oncreate(vnode) {
super.oncreate(vnode);
// Register the affix plugin to execute on every window resize (and trigger)
this.boundOnresize = this.onresize.bind(this);
$(window).on('resize', this.boundOnresize).resize();
}
onremove() {
$(window).off('resize', this.boundOnresize);
}
onresize() {
const $sidebar = this.$();
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
}
}

View File

@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
* The `AvatarEditor` component displays a user's avatar along with a dropdown
* menu which allows the user to upload/remove the avatar.
*
* ### Props
* ### Attrs
*
* - `className`
* - `user`
*/
export default class AvatarEditor extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not an avatar upload is in progress.
*
@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
this.isDraggedOver = false;
}
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.props.user;
const user = this.attrs.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
{avatar(user)}
<a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>
@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
items.add(
'upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this),
})
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
{app.translator.trans('core.forum.user.avatar_upload_button')}
</Button>
);
items.add(
'remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this),
})
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
{app.translator.trans('core.forum.user.avatar_remove_button')}
</Button>
);
return items;
@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
if (!this.attrs.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
$input
@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
upload(file) {
if (this.loading) return;
const user = this.props.user;
const user = this.attrs.user;
const data = new FormData();
data.append('avatar', file);
@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: (raw) => raw,
data,
body: data,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
const user = this.attrs.user;
this.loading = true;
m.redraw();
@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
delete this.attrs.user.avatarColor;
this.loading = false;
m.redraw();

View File

@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
* to change their email address.
*/
export default class ChangeEmailModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not the email has been changed successfully.
@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
*
* @type {function}
*/
this.email = m.prop(app.session.user.email());
this.email = m.stream(app.session.user.email());
/**
* The value of the password input.
*
* @type {function}
*/
this.password = m.prop('');
this.password = m.stream('');
}
className() {
@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
/>
</div>
<div className="Form-group">
{Button.component({
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_email.submit_button'),
})}
},
app.translator.trans('core.forum.change_email.submit_button')
)}
</div>
</div>
</div>
@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);

View File

@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group">
{Button.component({
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_password.send_button'),
})}
},
app.translator.trans('core.forum.change_password.send_button')
)}
</div>
</div>
</div>
@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: app.session.user.email() },
body: { email: app.session.user.email() },
})
.then(this.hide.bind(this), this.loaded.bind(this));
}

View File

@ -1,5 +1,3 @@
/*global s9e, hljs*/
import Post from './Post';
import classList from '../../common/utils/classList';
import PostUser from './PostUser';
@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
import ComposerPostPreview from './ComposerPostPreview';
/**
* The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class CommentPost extends Post {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* If the post has been hidden, then this flag determines whether or not its
@ -41,48 +40,46 @@ export default class CommentPost extends Post {
this.subtree.check(
() => this.cardVisible,
() => this.isEditing()
() => this.isEditing(),
() => this.revealContent
);
}
content() {
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
return super.content().concat([
<header className="Post-header">
<ul>{listItems(this.headerItems().toArray())}</ul>
</header>,
<div className="Post-body">
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>,
]);
}
config(isInitialized, context) {
super.config(...arguments);
onupdate(vnode) {
super.onupdate();
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through
// all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (context.contentHtml !== contentHtml) {
if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
eval.call(window, $(this).text());
});
}
context.contentHtml = contentHtml;
this.contentHtml = contentHtml;
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}
attrs() {
const post = this.props.post;
const attrs = super.attrs();
elementAttrs() {
const post = this.attrs.post;
const attrs = super.elementAttrs();
attrs.className =
(attrs.className || '') +
@ -98,27 +95,6 @@ export default class CommentPost extends Post {
return attrs;
}
configPreview(element, isInitialized, context) {
if (isInitialized) return;
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.fields.content();
if (preview === content) return;
preview = content;
s9e.TextFormatter.preview(preview || '', element);
};
updatePreview();
const updateInterval = setInterval(updatePreview, 50);
context.onunload = () => clearInterval(updateInterval);
}
/**
* Toggle the visibility of a hidden post's content.
*/
@ -133,7 +109,7 @@ export default class CommentPost extends Post {
*/
headerItems() {
const items = new ItemList();
const post = this.props.post;
const post = this.attrs.post;
items.add(
'user',

View File

@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
export default class Composer extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* The composer's "state".
*
* @type {ComposerState}
*/
this.state = this.props.state;
this.state = this.attrs.state;
/**
* Whether or not the composer currently has focus.
@ -45,7 +47,7 @@ export default class Composer extends Component {
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} />
<div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}>
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
@ -54,7 +56,7 @@ export default class Composer extends Component {
);
}
config(isInitialized, context) {
onupdate() {
if (this.state.position === this.prevPosition) {
// Set the height of the Composer element and its contents on each redraw,
// so that they do not lose it if their DOM elements are recreated.
@ -64,12 +66,10 @@ export default class Composer extends Component {
this.prevPosition = this.state.position;
}
}
if (isInitialized) return;
// Since this component is a part of the global UI that persists between
// routes, we will flag the DOM to be retained across route changes.
context.retain = true;
oncreate(vnode) {
super.oncreate(vnode);
this.initializeHeight();
this.$().hide().css('bottom', -this.state.computedHeight());
@ -84,36 +84,31 @@ export default class Composer extends Component {
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.state.close());
const handlers = {};
this.handlers = {};
$(window)
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
.resize();
$(document)
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
context.onunload = () => {
$(window).off('resize', handlers.onresize);
onremove() {
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup);
};
$(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
}
/**
* Add the necessary event handlers to the composer's handle so that it can
* be used to resize the composer.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/
configHandle(element, isInitialized) {
if (isInitialized) return;
configHandle(vnode) {
const composer = this;
$(element)
$(vnode.dom)
.css('cursor', 'row-resize')
.bind('dragstart mousedown', (e) => e.preventDefault())
.mousedown(function (e) {

View File

@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
* composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`.
*
* ### Props
* ### Attrs
*
* - `composer`
* - `originalContent`
@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
* @abstract
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
oninit(vnode) {
super.oninit(vnode);
this.composer = this.attrs.composer;
/**
* Whether or not the component is loading.
@ -37,11 +39,11 @@ export default class ComposerBody extends Component {
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
if (this.attrs.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
view() {
return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className={'ComposerBody ' + (this.attrs.className || '')}>
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">
{TextEditor.component({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
disabled: this.loading || this.props.disabled,
submitLabel: this.attrs.submitLabel,
placeholder: this.attrs.placeholder,
disabled: this.loading || this.attrs.disabled,
composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content,
@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
hasChanges() {
const content = this.composer.fields.content();
return content && content !== this.props.originalContent;
return content && content !== this.attrs.originalContent;
}
/**

View File

@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
* controls.
*/
export default class ComposerButton extends Button {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link';
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
}

View File

@ -0,0 +1,54 @@
/*global s9e*/
import Component from '../../common/Component';
/**
* The `ComposerPostPreview` component renders Markdown as HTML using the
* TextFormatter library, polling a data source for changes every 50ms. This is
* done to prevent expensive redraws on e.g. every single keystroke, while
* still retaining the perception of live updates for the user.
*
* ### Attrs
*
* - `composer` The state of the composer controlling this preview.
* - `className` A CSS class for the element surrounding the preview.
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
*/
export default class ComposerPostPreview extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.surround = attrs.surround || ((preview) => preview());
}
view() {
return <div className={this.attrs.className} />;
}
oncreate(vnode) {
super.oncreate(vnode);
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!this.attrs.composer.isVisible()) return;
const content = this.attrs.composer.fields.content();
if (preview === content) return;
preview = content;
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
};
updatePreview();
this.updateInterval = setInterval(updatePreview, 50);
}
onremove() {
clearInterval(this.updateInterval);
}
}

View File

@ -7,16 +7,26 @@ import extractText from '../../common/utils/extractText';
* enter the title of their discussion. It also overrides the `submit` and
* `willExit` actions to account for the title.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `titlePlaceholder`
*/
export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
static initAttrs(attrs) {
super.initAttrs(attrs);
this.composer.fields.title = this.composer.fields.title || m.prop('');
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
attrs.className = 'ComposerBody--discussion';
}
oninit(vnode) {
super.oninit(vnode);
this.composer.fields.title = this.composer.fields.title || m.stream('');
/**
* The value of the title input.
@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
this.title = this.composer.fields.title;
}
static initProps(props) {
super.initProps(props);
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
props.className = 'ComposerBody--discussion';
}
headerItems() {
const items = super.headerItems();
@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
<h3>
<input
className="FormControl"
value={this.title()}
oninput={m.withAttr('value', this.title)}
placeholder={this.props.titlePlaceholder}
disabled={!!this.props.disabled}
bidi={this.title}
placeholder={this.attrs.titlePlaceholder}
disabled={!!this.attrs.disabled}
onkeydown={this.onkeydown.bind(this)}
/>
</h3>
@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
this.composer.editor.moveCursorTo(0);
}
m.redraw.strategy('none');
e.redraw = false;
}
hasChanges() {
@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.then((discussion) => {
this.composer.hide();
app.discussions.refresh();
m.route(app.route.discussion(discussion));
m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this));
}
}

View File

@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
/**
* The `DiscussionHero` component displays the hero on a discussion page.
*
* ### Props
* ### attrs
*
* - `discussion`
*/
@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
*/
items() {
const items = new ItemList();
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const badges = discussion.badges().toArray();
if (badges.length) {

View File

@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
/**
* The `DiscussionList` component displays a list of discussions.
*
* ### Props
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionList extends Component {
init() {
this.state = this.props.state;
}
view() {
const state = this.state;
const state = this.attrs.state;
const params = state.getParams();
let loading;
@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
if (state.isLoading()) {
loading = LoadingIndicator.component();
} else if (state.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
loading = Button.component(
{
className: 'Button',
onclick: state.loadMore.bind(state),
});
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
}
if (state.empty()) {

View File

@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
import abbreviateNumber from '../../common/utils/abbreviateNumber';
import Dropdown from '../../common/components/Dropdown';
import TerminalPost from './TerminalPost';
import PostPreview from './PostPreview';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable';
@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `params`
*/
export default class DiscussionListItem extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in.
@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness,
() => this.attrs.discussion.freshness,
() => {
const time = app.session.user && app.session.user.markedAllAsReadAt();
return time && time.getTime();
@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
);
}
attrs() {
elementAttrs() {
return {
className: classList([
'DiscussionListItem',
this.active() ? 'active' : '',
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
]),
};
}
view() {
const retain = this.subtree.retain();
if (retain) return retain;
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const user = discussion.user();
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread;
let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs();
const attrs = this.elementAttrs();
if (this.props.params.q) {
if (this.attrs.params.q) {
const post = discussion.mostRelevantPost();
if (post) {
jumpTo = post.number();
}
const phrase = escapeRegExp(this.props.params.q);
const phrase = escapeRegExp(this.attrs.params.q);
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
} else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
return (
<div {...attrs}>
{controls.length
? Dropdown.component({
? Dropdown.component(
{
icon: 'fas fa-ellipsis-v',
children: controls,
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
})
},
controls
)
: ''}
<a
@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a
href={user ? app.route.user(user) : '#'}
route={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author"
title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
)}
config={function (element) {
$(element).tooltip({ placement: 'right' });
m.route.apply(this, arguments);
oncreate={function (vnode) {
$(vnode.dom).tooltip({ placement: 'right' });
}}
>
{avatar(user, { title: '' })}
@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main">
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a>
@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
// If we're on a touch device, set up the discussion row to be slidable.
// This allows the user to drag the row to either side of the screen to
@ -144,6 +142,12 @@ export default class DiscussionListItem extends Component {
}
}
onbeforeupdate(vnode, old) {
super.onbeforeupdate(vnode, old);
return this.subtree.needsRebuild();
}
/**
* Determine whether or not the discussion is currently being viewed.
*
@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id();
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
}
/**
@ -163,7 +167,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showFirstPost() {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
}
/**
@ -173,14 +177,14 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showRepliesCount() {
return this.props.params.sort === 'replies';
return this.attrs.params.sort === 'replies';
}
/**
* Mark the discussion as read.
*/
markAsRead() {
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
if (discussion.isUnread()) {
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
infoItems() {
const items = new ItemList();
if (this.props.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
if (this.attrs.params.q) {
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
items.add(
'terminalPost',
TerminalPost.component({
discussion: this.props.discussion,
discussion: this.attrs.discussion,
lastPost: !this.showFirstPost(),
})
);

View File

@ -0,0 +1,67 @@
import DiscussionList from './DiscussionList';
import Component from '../../common/Component';
const hotEdge = (e) => {
if (e.pageX < 10) app.pane.show();
};
/**
* The `DiscussionListPane` component displays the list of previously viewed
* discussions in a panel that can be displayed by moving the mouse to the left
* edge of the screen, where it can also be pinned in place.
*
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionListPane extends Component {
view() {
if (!this.attrs.state.hasDiscussions()) {
return;
}
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
const $list = $(vnode.dom);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
$(document).on('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
onremove() {
$(document).off('mousemove', hotEdge);
}
/**
* Are we on a device that's larger than we consider "mobile"?
*
* @returns {boolean}
*/
enoughSpace() {
return !$('.App-navigation').is(':visible');
}
}

View File

@ -1,13 +1,13 @@
import Page from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import DiscussionListPane from './DiscussionListPane';
import PostStream from './PostStream';
import PostStreamScrubber from './PostStreamScrubber';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState';
/**
@ -15,8 +15,8 @@ import PostStreamState from '../states/PostStreamState';
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The discussion that is being viewed.
@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
if (app.discussions.hasDiscussions()) {
app.pane.enable();
app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
m.redraw.strategy('diff');
}
}
app.history.push('discussion');
this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
}
onunload(e) {
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
e.preventDefault();
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = null;
return;
}
}
onremove() {
// If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case
@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
return (
<div className="DiscussionPage">
{app.discussions.hasDiscussions() ? (
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
</div>
) : (
''
)}
<DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-discussion">
{discussion
? [
@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
if (m.route.get() !== this.prevRoute) {
this.prevRoute = m.route.get();
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
app.setTitle(this.discussion.title());
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
this.oninit(vnode);
}
}
}
}
@ -149,7 +139,7 @@ export default class DiscussionPage extends Page {
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
}
m.lazyRedraw();
m.redraw();
}
/**
@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
this.discussion = discussion;
app.history.push('discussion', discussion.title());
app.setTitle(this.discussion.title());
app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of
@ -209,48 +200,6 @@ export default class DiscussionPage extends Page {
app.current.set('stream', this.stream);
}
/**
* Configure the discussion list pane.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
* @param {Object} context
*/
configPane(element, isInitialized, context) {
if (isInitialized) return;
context.retain = true;
const $list = $(element);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
const hotEdge = (e) => {
if (e.pageX < 10) pane.show();
};
$(document).on('mousemove', hotEdge);
context.onunload = () => $(document).off('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
/**
* Build an item list for the contents of the sidebar.
*
@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
items.add(
'controls',
SplitDropdown.component({
children: DiscussionControls.controls(this.discussion, this).toArray(),
SplitDropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary',
})
},
DiscussionControls.controls(this.discussion, this).toArray()
)
);
items.add(
@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber));
m.route(url, true);
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());

View File

@ -4,9 +4,9 @@ import Notification from './Notification';
* The `DiscussionRenamedNotification` component displays a notification which
* indicates that a discussion has had its title changed.
*
* ### Props
* ### Attrs
*
* - All of the props for Notification
* - All of the attrs for Notification
*/
export default class DiscussionRenamedNotification extends Notification {
icon() {
@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
}
href() {
const notification = this.props.notification;
const notification = this.attrs.notification;
return app.route.discussion(notification.subject(), notification.content().postNumber);
}
content() {
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.props.notification.fromUser() });
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
}
}

View File

@ -5,9 +5,9 @@ import extractText from '../../common/utils/extractText';
* The `DiscussionRenamedPost` component displays a discussion event post
* indicating that the discussion has been renamed.
*
* ### Props
* ### Attrs
*
* - All of the props for EventPost
* - All of the attrs for EventPost
*/
export default class DiscussionRenamedPost extends EventPost {
icon() {
@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
}
descriptionData() {
const post = this.props.post;
const post = this.attrs.post;
const oldTitle = post.content()[0];
const newTitle = post.content()[1];

View File

@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
{LinkButton.component({
{LinkButton.component(
{
icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
href: app.route('index', { q: query }),
})}
},
app.translator.trans('core.forum.search.all_discussions_button', { query })
)}
</li>,
results.map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost();
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}>
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a>

View File

@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
* page.
*/
export default class DiscussionsUserPage extends UserPage {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.loadUser(m.route.param('username'));
}

View File

@ -14,38 +14,32 @@ function minimizeComposerIfFullScreen(e) {
* post. It sets the initial content to the content of the post that is being
* edited, and adds a header control to indicate which post is being edited.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
props.originalContent = props.originalContent || props.post.content();
props.user = props.user || props.post.user();
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
attrs.originalContent = attrs.originalContent || attrs.post.content();
attrs.user = attrs.user || attrs.post.user();
props.post.editedContent = props.originalContent;
attrs.post.editedContent = attrs.originalContent;
}
headerItems() {
const items = super.headerItems();
const post = this.props.post;
const routeAndMinimize = function (element, isInitialized) {
if (isInitialized) return;
$(element).on('click', minimizeComposerIfFullScreen);
m.route.apply(this, arguments);
};
const post = this.attrs.post;
items.add(
'title',
<h3>
{icon('fas fa-pencil-alt')}{' '}
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
</a>
</h3>
@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
m.route.set(app.route.post(this.attrs.post));
}
/**
@ -75,13 +69,13 @@ export default class EditPostComposer extends ComposerBody {
}
onsubmit() {
const discussion = this.props.post.discussion();
const discussion = this.attrs.post.discussion();
this.loading = true;
const data = this.data();
this.props.post.save(data).then((post) => {
this.attrs.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
@ -91,19 +85,23 @@ export default class EditPostComposer extends ComposerBody {
// their edit has been made, containing a button which will
// transition to their edited post when clicked.
let alert;
const viewButton = Button.component({
const viewButton = Button.component(
{
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => {
m.route(app.route.post(post));
m.route.set(app.route.post(post));
app.alerts.dismiss(alert);
},
});
alert = app.alerts.show({
},
app.translator.trans('core.forum.composer_edit.view_button')
);
alert = app.alerts.show(
{
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
});
},
app.translator.trans('core.forum.composer_edit.edited_message')
);
}
this.composer.hide();

View File

@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
* The `EditUserModal` component displays a modal dialog with a login form.
*/
export default class EditUserModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
const user = this.props.user;
const user = this.attrs.user;
this.username = m.prop(user.username() || '');
this.email = m.prop(user.email() || '');
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false);
this.setPassword = m.prop(false);
this.password = m.prop(user.password() || '');
this.username = m.stream(user.username() || '');
this.email = m.stream(user.email() || '');
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
this.setPassword = m.stream(false);
this.password = m.stream(user.password() || '');
this.groups = {};
app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1)));
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
}
className() {
@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
40
);
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
items.add(
'email',
<div className="Form-group">
@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
</div>
{!this.isEmailConfirmed() ? (
<div>
{Button.component({
{Button.component(
{
className: 'Button Button--block',
children: app.translator.trans('core.forum.edit_user.activate_button'),
loading: this.loading,
onclick: this.activate.bind(this),
})}
},
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div>
) : (
''
@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
type="checkbox"
onchange={(e) => {
this.setPassword(e.target.checked);
m.redraw(true);
m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select();
m.redraw.strategy('none');
e.redraw = false;
}}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
<input
type="checkbox"
bidi={this.groups[group.id()]}
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
/>
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.edit_user.submit_button'),
})}
},
app.translator.trans('core.forum.edit_user.submit_button')
)}
</div>,
-10
);
@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
username: this.username(),
isEmailConfirmed: true,
};
this.props.user
this.attrs.user
.save(data, { errorHandler: this.onerror.bind(this) })
.then(() => {
this.isEmailConfirmed(true);
@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
relationships: { groups },
};
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
data.email = this.email();
}
@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
this.loading = true;
this.props.user
this.attrs.user
.save(this.data(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {

View File

@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
* event, like a discussion being renamed or stickied. Subclasses must implement
* the `icon` and `description` methods.
*
* ### Props
* ### Attrs
*
* - All of the props for `Post`
* - All of the attrs for `Post`
*
* @abstract
*/
export default class EventPost extends Post {
attrs() {
const attrs = super.attrs();
elementAttrs() {
const attrs = super.elementAttrs();
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
return attrs;
}
content() {
const user = this.props.post.user();
const user = this.attrs.post.user();
const username = usernameHelper(user);
const data = Object.assign(this.descriptionData(), {
user,
username: user ? (
<a className="EventPost-user" href={app.route.user(user)} config={m.route}>
<a className="EventPost-user" route={app.route.user(user)}>
{username}
</a>
) : (

View File

@ -1,5 +1,4 @@
import Modal from '../../common/components/Modal';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText';
@ -7,20 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password.
*
* ### Props
* ### Attrs
*
* - `email`
*/
export default class ForgotPasswordModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
this.email = m.stream(this.attrs.email || '');
/**
* Whether or not the password reset email was sent successfully.
@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
name="email"
type="email"
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', this.email)}
bidi={this.email}
disabled={this.loading}
/>
</div>
<div className="Form-group">
{Button.component({
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.forgot_password.submit_button'),
})}
},
app.translator.trans('core.forum.forgot_password.submit_button')
)}
</div>
</div>
</div>
@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: this.email() },
body: { email: this.email() },
errorHandler: this.onerror.bind(this),
})
.then(() => {
@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) {
if (error.status === 404) {
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
}
super.onerror(error);

View File

@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*

View File

@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
@ -41,9 +34,9 @@ export default class HeaderSecondary extends Component {
for (const locale in app.data.locales) {
locales.push(
Button.component({
Button.component(
{
active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
@ -53,16 +46,20 @@ export default class HeaderSecondary extends Component {
window.location.reload();
}
},
})
},
app.data.locales[locale]
)
);
}
items.add(
'locale',
SelectDropdown.component({
children: locales,
SelectDropdown.component(
{
buttonClassName: 'Button Button--link',
}),
},
locales
),
20
);
}
@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
if (app.forum.attribute('allowSignUp')) {
items.add(
'signUp',
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
}),
},
app.translator.trans('core.forum.header.sign_up_link')
),
10
);
}
items.add(
'logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
}),
},
app.translator.trans('core.forum.header.log_in_link')
),
0
);
}

View File

@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
export default class IndexPage extends Page {
static providesInitialSearch = true;
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
// If the user is returning from a discussion page, then take note of which
// discussion they have just visited. After the view is rendered, we will
@ -42,12 +42,26 @@ export default class IndexPage extends Page {
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
this.currentPath = m.route.get();
}
onunload() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
const curPath = m.route.get();
if (this.currentPath !== curPath) {
this.onNewRoute();
app.discussions.clear();
app.discussions.refreshParams(app.search.params());
this.currentPath = curPath;
this.setTitle();
}
}
view() {
@ -72,15 +86,15 @@ export default class IndexPage extends Page {
);
}
config(isInitialized, context) {
super.config(...arguments);
if (isInitialized) return;
extend(context, 'onunload', () => $('#app').css('min-height', ''));
setTitle() {
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitleCount(0);
}
oncreate(vnode) {
super.oncreate(vnode);
this.setTitle();
// 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
@ -117,6 +131,16 @@ export default class IndexPage extends Page {
}
}
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/**
* Get the component to display as the hero.
*
@ -139,25 +163,31 @@ export default class IndexPage extends Page {
items.add(
'newDiscussion',
Button.component({
children: app.translator.trans(
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
),
Button.component(
{
icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl',
onclick: this.newDiscussionAction.bind(this),
onclick: () => {
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
return this.newDiscussionAction().catch(() => {});
},
disabled: !canStartDiscussion,
})
},
app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button')
)
);
items.add(
'nav',
SelectDropdown.component({
children: this.navItems(this).toArray(),
SelectDropdown.component(
{
buttonClassName: 'Button',
className: 'App-titleControl',
})
},
this.navItems(this).toArray()
)
);
return items;
@ -175,11 +205,13 @@ export default class IndexPage extends Page {
items.add(
'allDiscussions',
LinkButton.component({
LinkButton.component(
{
href: app.route('index', params),
children: app.translator.trans('core.forum.index.all_discussions_link'),
icon: 'far fa-comments',
}),
},
app.translator.trans('core.forum.index.all_discussions_link')
),
100
);
@ -204,21 +236,25 @@ export default class IndexPage extends Page {
items.add(
'sort',
Dropdown.component({
Dropdown.component(
{
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => {
},
Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({
children: label,
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
active: active,
});
}),
},
label
);
})
)
);
return items;
@ -270,20 +306,18 @@ export default class IndexPage extends Page {
* @return {Promise}
*/
newDiscussionAction() {
const deferred = m.deferred();
return new Promise((resolve, reject) => {
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
deferred.resolve(app.composer);
return resolve(app.composer);
} else {
deferred.reject();
app.modal.show(LogInModal);
}
return deferred.promise;
return reject();
}
});
}
/**

View File

@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
* The `LogInButton` component displays a social login button which will open
* a popup window containing the specified path.
*
* ### Props
* ### Attrs
*
* - `path`
*/
export default class LogInButton extends Button {
static initProps(props) {
props.className = (props.className || '') + ' LogInButton';
static initAttrs(attrs) {
attrs.className = (attrs.className || '') + ' LogInButton';
props.onclick = function () {
attrs.onclick = function () {
const width = 580;
const height = 400;
const $window = $(window);
window.open(
app.forum.attribute('baseUrl') + props.path,
app.forum.attribute('baseUrl') + attrs.path,
'logInPopup',
`width=${width},` +
`height=${height},` +
@ -28,6 +28,6 @@ export default class LogInButton extends Button {
);
};
super.initProps(props);
super.initAttrs(attrs);
}
}

View File

@ -9,35 +9,35 @@ import ItemList from '../../common/utils/ItemList';
/**
* The `LogInModal` component displays a modal dialog with a login form.
*
* ### Props
* ### Attrs
*
* - `identification`
* - `password`
*/
export default class LogInModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the identification input.
*
* @type {Function}
*/
this.identification = m.prop(this.props.identification || '');
this.identification = m.stream(this.attrs.identification || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
this.password = m.stream(this.attrs.password || '');
/**
* The value of the remember me input.
*
* @type {Function}
*/
this.remember = m.prop(!!this.props.remember);
this.remember = m.stream(!!this.attrs.remember);
}
className() {
@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button'),
})}
},
app.translator.trans('core.forum.log_in.submit_button')
)}
</div>,
-10
);
@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? { email } : undefined;
const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, props);
app.modal.show(ForgotPasswordModal, attrs);
}
/**
@ -152,11 +154,11 @@ export default class LogInModal extends Modal {
* @public
*/
signUp() {
const props = { password: this.password() };
const attrs = { password: this.password() };
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(SignUpModal, props);
app.modal.show(SignUpModal, attrs);
}
onready() {
@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);

View File

@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `Notification` component abstract displays a single notification.
* Subclasses should implement the `icon`, `href`, and `content` methods.
*
* ### Props
* ### Attrs
*
* - `notification`
*
@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
*/
export default class Notification extends Component {
view() {
const notification = this.props.notification;
const notification = this.attrs.notification;
const href = this.href();
const linkAttrs = {};
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
return (
<a
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
config={function (element, isInitialized) {
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
}}
{...linkAttrs}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&
Button.component({
@ -86,10 +85,10 @@ export default class Notification extends Component {
* Mark the notification as read.
*/
markAsRead() {
if (this.props.notification.isRead()) return;
if (this.attrs.notification.isRead()) return;
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
this.props.notification.save({ isRead: true });
this.attrs.notification.save({ isRead: true });
}
}

View File

@ -7,12 +7,14 @@ import ItemList from '../../common/utils/ItemList';
* The `NotificationGrid` component displays a table of notification types and
* methods, allowing the user to toggle each combination.
*
* ### Props
* ### Attrs
*
* - `user`
*/
export default class NotificationGrid extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Information about the available notification methods.
*
@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
}
view() {
const preferences = this.props.user.preferences();
const preferences = this.attrs.user.preferences();
return (
<table className="NotificationGrid">
@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
return (
<td className="NotificationGrid-checkbox">
{Checkbox.component({
state: !!preferences[key],
loading: this.loading[key],
disabled: !(key in preferences),
onchange: () => this.toggle([key]),
})}
<Checkbox
state={!!preferences[key]}
loading={this.loading[key]}
disabled={!(key in preferences)}
onchange={this.toggle.bind(this, [key])}
/>
</td>
);
})}
@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
const i = parseInt($(this).index(), 10) + 1;
@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
* @param {Array} keys
*/
toggle(keys) {
const user = this.props.user;
const user = this.attrs.user;
const preferences = user.preferences();
const enabled = !preferences[keys[0]];
@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
* @param {String} method
*/
toggleMethod(method) {
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}
@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
* @param {String} type
*/
toggleType(type) {
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}

View File

@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
* notifications, grouped by discussion.
*/
export default class NotificationList extends Component {
init() {
this.state = this.props.state;
}
view() {
const pages = this.state.getNotificationPages();
const state = this.attrs.state;
const pages = state.getNotificationPages();
return (
<div className="NotificationList">
@ -24,7 +21,7 @@ export default class NotificationList extends Component {
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.state.markAllAsRead.bind(this.state),
onclick: state.markAllAsRead.bind(state),
})}
</div>
@ -66,7 +63,7 @@ export default class NotificationList extends Component {
return (
<div className="NotificationGroup">
{group.discussion ? (
<a className="NotificationGroup-header" href={app.route.discussion(group.discussion)} config={m.route}>
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</a>
@ -85,7 +82,7 @@ export default class NotificationList extends Component {
});
})
: ''}
{this.state.isLoading() ? (
{state.isLoading() ? (
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
@ -97,27 +94,31 @@ export default class NotificationList extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const $notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
this.$notifications = this.$('.NotificationList-content');
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
const scrollHandler = () => {
const scrollTop = $scrollParent.scrollTop();
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.state.loadMore();
this.boundScrollHandler = this.scrollHandler.bind(this);
this.$scrollParent.on('scroll', this.boundScrollHandler);
}
};
$scrollParent.on('scroll', scrollHandler);
onremove() {
this.$scrollParent.off('scroll', this.boundScrollHandler);
}
context.onunload = () => {
$scrollParent.off('scroll', scrollHandler);
};
scrollHandler() {
const state = this.attrs.state;
const scrollTop = this.$scrollParent.scrollTop();
const viewportHeight = this.$scrollParent.height();
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
const contentHeight = this.$notifications[0].scrollHeight;
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
state.loadMore();
}
}
}

View File

@ -3,21 +3,21 @@ import icon from '../../common/helpers/icon';
import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown {
static initProps(props) {
props.className = props.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
props.icon = props.icon || 'fas fa-bell';
static initAttrs(attrs) {
attrs.className = attrs.className || 'NotificationsDropdown';
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
attrs.icon = attrs.icon || 'fas fa-bell';
super.initProps(props);
super.initAttrs(attrs);
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
vdom.attrs.title = this.props.label;
vdom.attrs.title = this.attrs.label;
vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.onclick = this.onclick.bind(this);
@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, { className: 'Button-icon' }),
icon(this.attrs.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>,
<span className="Button-label">{this.attrs.label}</span>,
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
</div>
);
}
@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.props.state.load();
this.attrs.state.load();
}
}
goToRoute() {
m.route(app.route('notifications'));
m.route.set(app.route('notifications'));
}
getUnreadCount() {

View File

@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
* used on mobile devices where the notifications dropdown is within the drawer.
*/
export default class NotificationsPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
app.history.push('notifications');

View File

@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
* includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods.
*
* ### Props
* ### Attrs
*
* - `post`
*
* @abstract
*/
export default class Post extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
/**
@ -27,9 +29,9 @@ export default class Post extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.attrs.post.freshness,
() => {
const user = this.props.post.user();
const user = this.attrs.post.user();
return user && user.freshness;
},
() => this.controlsOpen
@ -37,17 +39,14 @@ export default class Post extends Component {
}
view() {
const attrs = this.attrs();
const attrs = this.elementAttrs();
attrs.className = this.classes(attrs.className).join(' ');
return (
<article {...attrs}>
{this.subtree.retain() ||
(() => {
const controls = PostControls.controls(this.props.post, this).toArray();
const controls = PostControls.controls(this.attrs.post, this).toArray();
return (
<article {...attrs}>
<div>
{this.content()}
<aside className="Post-actions">
@ -75,13 +74,17 @@ export default class Post extends Component {
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
);
})()}
</article>
);
}
config(isInitialized) {
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.needsRebuild();
}
onupdate() {
const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls');
@ -93,7 +96,7 @@ export default class Post extends Component {
*
* @return {Object}
*/
attrs() {
elementAttrs() {
return {};
}
@ -115,8 +118,8 @@ export default class Post extends Component {
classes(existing) {
let classes = (existing || '').split(' ').concat(['Post']);
const user = this.props.post.user();
const discussion = this.props.post.discussion();
const user = this.attrs.post.user();
const discussion = this.attrs.post.discussion();
if (this.loading) {
classes.push('Post--loading');

View File

@ -6,18 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `PostEdited` component displays information about when and by whom a post
* was edited.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostEdited extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.shouldUpdateTooltip = false;
this.oldEditedInfo = null;
}
view() {
const post = this.props.post;
const post = this.attrs.post;
const editedUser = post.editedUser();
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
if (editedInfo !== this.oldEditedInfo) {
@ -32,7 +34,17 @@ export default class PostEdited extends Component {
);
}
config(isInitialized) {
oncreate(vnode) {
super.oncreate(vnode);
this.rebuildTooltip();
}
onupdate() {
this.rebuildTooltip();
}
rebuildTooltip() {
if (this.shouldUpdateTooltip) {
this.$().tooltip('destroy').tooltip();
this.shouldUpdateTooltip = false;

View File

@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
* a dropdown containing more information about the post (number, full time,
* permalink).
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostMeta extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const time = post.createdAt();
const permalink = this.getPermalink(post);
const touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
const selectPermalink = function () {
const selectPermalink = function (e) {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
m.redraw.strategy('none');
e.redraw = false;
};
return (

View File

@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
* The `PostPreview` component shows a link to a post containing the avatar and
* username of the author, and a short excerpt of the post's content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostPreview extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
return (
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content">
{avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>

View File

@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `PostStream` component displays an infinitely-scrollable wall of posts in
* a discussion. Posts that have not loaded will be displayed as placeholders.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `stream`
@ -16,9 +16,11 @@ import Button from '../../common/components/Button';
* - `onPositionChange`
*/
export default class PostStream extends Component {
init() {
this.discussion = this.props.discussion;
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.discussion = this.attrs.discussion;
this.stream = this.attrs.stream;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
}
@ -96,29 +98,33 @@ export default class PostStream extends Component {
return <div className="PostStream">{items}</div>;
}
config(isInitialized, context) {
onupdate() {
this.triggerScroll();
}
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.triggerScroll();
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
}
context.onunload = () => {
onremove() {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
if (!this.props.targetPost) return;
if (!this.attrs.targetPost) return;
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
const newTarget = this.attrs.targetPost;
if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
@ -265,7 +271,7 @@ export default class PostStream extends Component {
});
if (startNumber) {
this.props.onPositionChange(startNumber || 1, endNumber, startNumber);
this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber);
}
}
@ -348,7 +354,7 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
this.updateScrubber();
const index = $item.data('index');
m.redraw(true);
m.redraw.sync();
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll);
this.calculatePosition();

View File

@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
* The `PostStreamScrubber` component displays a scrubber which can be used to
* navigate/scrub through a post stream.
*
* ### Props
* ### Attrs
*
* - `stream`
* - `className`
*/
export default class PostStreamScrubber extends Component {
init() {
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.stream = this.attrs.stream;
this.handlers = {};
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
@ -32,23 +34,23 @@ export default class PostStreamScrubber extends Component {
const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
function styleUnread(element, isInitialized, context) {
const $element = $(element);
function styleUnread(vnode) {
const $element = $(vnode.dom);
const newStyle = {
top: 100 - unreadPercent * 100 + '%',
height: unreadPercent * 100 + '%',
};
if (context.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle);
if (vnode.state.oldStyle) {
$element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
} else {
$element.css(newStyle);
}
context.oldStyle = newStyle;
vnode.state.oldStyle = newStyle;
}
const classNames = ['PostStreamScrubber', 'Dropdown'];
if (this.props.className) classNames.push(this.props.className);
if (this.attrs.className) classNames.push(this.attrs.className);
return (
<div className={classNames.join(' ')}>
@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{this.stream.description}</span>
<span className="Scrubber-description"></span>
</div>
</div>
<div className="Scrubber-after" />
<div className="Scrubber-unread" config={styleUnread}>
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
</div>
</div>
@ -87,11 +89,12 @@ export default class PostStreamScrubber extends Component {
);
}
config(isInitialized, context) {
onupdate() {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
if (isInitialized) return;
}
context.onunload = this.ondestroy.bind(this);
oncreate(vnode) {
super.oncreate(vnode);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
@ -133,6 +136,15 @@ export default class PostStreamScrubber extends Component {
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
setTimeout(() => this.scrollListener.start());
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
onremove() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
/**
@ -196,13 +208,6 @@ export default class PostStreamScrubber extends Component {
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
onresize() {
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.

Some files were not shown because too many files have changed in this diff Show More