chore: extensibility improvements (#3729)

* chore: improve tags page extensibility
* chore: improve discussion list item extensibility
* chore: improve change password modal extensibility
* chore: item-listify tags page
* chore: item-listify change email modal
* chore: simplify data flow

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
Sami Mazouz 2023-04-16 21:05:23 +01:00 committed by GitHub
parent 8b11fef3ee
commit b89a01c010
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 291 additions and 174 deletions

View File

@ -34,7 +34,8 @@ export default function tagLabel(tag, attrs = {}) {
link ? Link : 'span',
attrs,
<span className="TagLabel-text">
{tag && tag.icon() && tagIcon(tag, {}, { useColor: false })} {tagText}
{tag && tag.icon() && tagIcon(tag, { className: 'TagLabel-icon' }, { useColor: false })}
<span className="TagLabel-name">{tagText}</span>
</span>
);
}

View File

@ -3,6 +3,7 @@ import IndexPage from 'flarum/forum/components/IndexPage';
import Link from 'flarum/common/components/Link';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import listItems from 'flarum/common/helpers/listItems';
import ItemList from 'flarum/common/utils/ItemList';
import humanTime from 'flarum/common/helpers/humanTime';
import textContrastClass from 'flarum/common/helpers/textContrastClass';
import classList from 'flarum/common/utils/classList';
@ -37,69 +38,107 @@ export default class TagsPage extends Page {
});
}
view() {
if (this.loading) {
return <LoadingIndicator />;
}
const pinned = this.tags.filter((tag) => tag.position() !== null);
const cloud = this.tags.filter((tag) => tag.position() === null);
return (
<div className="TagsPage">
{IndexPage.prototype.hero()}
<div className="container">
<nav className="TagsPage-nav IndexPage-nav sideNav">
<ul>{listItems(IndexPage.prototype.sidebarItems().toArray())}</ul>
</nav>
<div className="TagsPage-content sideNavOffset">
<ul className="TagTiles">
{pinned.map((tag) => {
const lastPostedDiscussion = tag.lastPostedDiscussion();
const children = sortTags(tag.children() || []);
return (
<li className={classList('TagTile', { colored: tag.color() }, textContrastClass(tag.color()))} style={{ '--tag-bg': tag.color() }}>
<Link className="TagTile-info" href={app.route.tag(tag)}>
{tag.icon() && tagIcon(tag, {}, { useColor: false })}
<h3 className="TagTile-name">{tag.name()}</h3>
<p className="TagTile-description">{tag.description()}</p>
{children ? (
<div className="TagTile-children">
{children.map((child) => [<Link href={app.route.tag(child)}>{child.name()}</Link>, ' '])}
</div>
) : (
''
)}
</Link>
{lastPostedDiscussion ? (
<Link
className="TagTile-lastPostedDiscussion"
href={app.route.discussion(lastPostedDiscussion, lastPostedDiscussion.lastPostNumber())}
>
<span className="TagTile-lastPostedDiscussion-title">{lastPostedDiscussion.title()}</span>
{humanTime(lastPostedDiscussion.lastPostedAt())}
</Link>
) : (
<span className="TagTile-lastPostedDiscussion" />
)}
</li>
);
})}
</ul>
{cloud.length ? <div className="TagCloud">{cloud.map((tag) => [tagLabel(tag, { link: true }), ' '])}</div> : ''}
</div>
</div>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
app.setTitle(app.translator.trans('flarum-tags.forum.all_tags.meta_title_text'));
app.setTitleCount(0);
}
view() {
return <div className="TagsPage">{this.pageContent().toArray()}</div>;
}
pageContent() {
const items = new ItemList();
items.add('hero', this.hero(), 100);
items.add('main', <div className="container">{this.mainContent().toArray()}</div>, 10);
return items;
}
mainContent() {
const items = new ItemList();
items.add('sidebar', this.sidebar(), 100);
items.add('content', this.content(), 10);
return items;
}
content() {
return <div className="TagsPage-content sideNavOffset">{this.contentItems().toArray()}</div>;
}
contentItems() {
const items = new ItemList();
if (this.loading) {
items.add('loading', <LoadingIndicator />);
} else {
const pinned = this.tags.filter((tag) => tag.position() !== null);
const cloud = this.tags.filter((tag) => tag.position() === null);
items.add('tagTiles', this.tagTileListView(pinned), 100);
if (cloud.length) {
items.add('cloud', this.cloudView(cloud), 10);
}
}
return items;
}
hero() {
return IndexPage.prototype.hero();
}
sidebar() {
return (
<nav className="TagsPage-nav IndexPage-nav sideNav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
);
}
sidebarItems() {
return IndexPage.prototype.sidebarItems();
}
tagTileListView(pinned) {
return <ul className="TagTiles">{pinned.map(this.tagTileView.bind(this))}</ul>;
}
tagTileView(tag) {
const lastPostedDiscussion = tag.lastPostedDiscussion();
const children = sortTags(tag.children() || []);
return (
<li className={classList('TagTile', { colored: tag.color() }, textContrastClass(tag.color()))} style={{ '--tag-bg': tag.color() }}>
<Link className="TagTile-info" href={app.route.tag(tag)}>
{tag.icon() && tagIcon(tag, {}, { useColor: false })}
<h3 className="TagTile-name">{tag.name()}</h3>
<p className="TagTile-description">{tag.description()}</p>
{children ? (
<div className="TagTile-children">{children.map((child) => [<Link href={app.route.tag(child)}>{child.name()}</Link>, ' '])}</div>
) : (
''
)}
</Link>
{lastPostedDiscussion ? (
<Link className="TagTile-lastPostedDiscussion" href={app.route.discussion(lastPostedDiscussion, lastPostedDiscussion.lastPostNumber())}>
<span className="TagTile-lastPostedDiscussion-title">{lastPostedDiscussion.title()}</span>
{humanTime(lastPostedDiscussion.lastPostedAt())}
</Link>
) : (
<span className="TagTile-lastPostedDiscussion" />
)}
</li>
);
}
cloudView(cloud) {
return <div className="TagCloud">{cloud.map((tag) => [tagLabel(tag, { link: true }), ' '])}</div>;
}
}

View File

@ -2,8 +2,9 @@ import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import Mithril from 'mithril';
import type Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
import ItemList from '../../common/utils/ItemList';
/**
* The `ChangeEmailModal` component shows a modal dialog which allows the user
@ -41,60 +42,75 @@ export default class ChangeEmailModal<CustomAttrs extends IInternalModalAttrs =
}
content() {
return (
<div className="Modal-body">
<div className="Form Form--centered">{this.fields().toArray()}</div>
</div>
);
}
fields(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
if (this.success) {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">
{app.translator.trans('core.forum.change_email.confirmation_message', { email: <strong>{this.email()}</strong> })}
</p>
<div className="Form-group">
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
{app.translator.trans('core.forum.change_email.dismiss_button')}
</Button>
</div>
</div>
items.add(
'help',
<p className="helpText">
{app.translator.trans('core.forum.change_email.confirmation_message', {
email: <strong>{this.email()}</strong>,
})}
</p>
);
items.add(
'dismiss',
<div className="Form-group">
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
{app.translator.trans('core.forum.change_email.dismiss_button')}
</Button>
</div>
);
} else {
items.add(
'email',
<div className="Form-group">
<input
type="email"
name="email"
className="FormControl"
placeholder={app.session.user!.email()}
bidi={this.email}
disabled={this.loading}
/>
</div>
);
items.add(
'password',
<div className="Form-group">
<input
type="password"
name="password"
className="FormControl"
autocomplete="current-password"
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
bidi={this.password}
disabled={this.loading}
/>
</div>
);
items.add(
'submit',
<div className="Form-group">
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
{app.translator.trans('core.forum.change_email.submit_button')}
</Button>
</div>
);
}
return (
<div className="Modal-body">
<div className="Form Form--centered">
<div className="Form-group">
<input
type="email"
name="email"
className="FormControl"
placeholder={app.session.user!.email()}
bidi={this.email}
disabled={this.loading}
/>
</div>
<div className="Form-group">
<input
type="password"
name="password"
className="FormControl"
autocomplete="current-password"
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
bidi={this.password}
disabled={this.loading}
/>
</div>
<div className="Form-group">
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_email.submit_button')
)}
</div>
</div>
</div>
);
return items;
}
onsubmit(e: SubmitEvent) {
@ -111,13 +127,10 @@ export default class ChangeEmailModal<CustomAttrs extends IInternalModalAttrs =
this.alertAttrs = null;
app.session
.user!.save(
{ email: this.email() },
{
errorHandler: this.onerror.bind(this),
meta: { password: this.password() },
}
)
.user!.save(this.requestAttributes(), {
errorHandler: this.onerror.bind(this),
meta: { password: this.password() },
})
.then(() => {
this.success = true;
})
@ -125,6 +138,10 @@ export default class ChangeEmailModal<CustomAttrs extends IInternalModalAttrs =
.then(this.loaded.bind(this));
}
requestAttributes() {
return { email: this.email() };
}
onerror(error: RequestError) {
if (error.status === 401 && error.alert) {
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');

View File

@ -1,6 +1,8 @@
import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Mithril from 'mithril';
import ItemList from '../../common/utils/ItemList';
/**
* The `ChangePasswordModal` component shows a modal dialog which allows the
@ -18,23 +20,28 @@ export default class ChangePasswordModal<CustomAttrs extends IInternalModalAttrs
content() {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group">
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_password.send_button')
)}
</div>
</div>
<div className="Form Form--centered">{this.fields().toArray()}</div>
</div>
);
}
fields() {
const fields = new ItemList<Mithril.Children>();
fields.add('help', <p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>);
fields.add(
'submit',
<div className="Form-group">
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
{app.translator.trans('core.forum.change_password.send_button')}
</Button>
</div>
);
return fields;
}
onsubmit(e: SubmitEvent) {
e.preventDefault();
@ -44,8 +51,12 @@ export default class ChangePasswordModal<CustomAttrs extends IInternalModalAttrs
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
body: { email: app.session.user!.email() },
body: this.requestBody(),
})
.then(this.hide.bind(this), this.loaded.bind(this));
}
requestBody() {
return { email: app.session.user!.email() };
}
}

View File

@ -64,13 +64,101 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
view() {
const discussion = this.attrs.discussion;
const user = discussion.user();
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.elementAttrs();
return (
<div {...attrs}>
{this.controlsView(controls)}
{this.slidableUnderneathView()}
{this.contentView()}
</div>
);
}
controlsView(controls: Mithril.ChildArray): Mithril.Children {
return (
(controls.length > 0 &&
Dropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
accessibleToggleLabel: app.translator.trans('core.forum.discussion_controls.toggle_dropdown_accessible_label'),
},
controls
)) ||
null
);
}
slidableUnderneathView(): Mithril.Children {
const discussion = this.attrs.discussion;
const isUnread = discussion.isUnread();
return (
<span
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}
>
{icon('fas fa-check')}
</span>
);
}
contentView(): Mithril.Children {
const discussion = this.attrs.discussion;
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
return (
<div className={classList('DiscussionListItem-content', 'Slidable-content', { unread: isUnread, read: isRead })}>
{this.authorAvatarView()}
{this.badgesView()}
{this.mainView()}
{this.replyCountItem()}
</div>
);
}
authorAvatarView(): Mithril.Children {
const discussion = this.attrs.discussion;
const user = discussion.user();
return (
<Tooltip
text={app.translator.trans('core.forum.discussion_list.started_text', { user, ago: humanTime(discussion.createdAt()) })}
position="right"
>
<Link className="DiscussionListItem-author" href={user ? app.route.user(user) : '#'}>
{avatar(user || null, { title: '' })}
</Link>
</Tooltip>
);
}
badgesView(): Mithril.Children {
const discussion = this.attrs.discussion;
return <ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>;
}
mainView(): Mithril.Children {
const discussion = this.attrs.discussion;
const jumpTo = this.getJumpTo();
return (
<Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h2 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h2>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</Link>
);
}
getJumpTo() {
const discussion = this.attrs.discussion;
let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.elementAttrs();
if (this.attrs.params.q) {
const post = discussion.mostRelevantPost();
@ -84,46 +172,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
jumpTo = Math.min(discussion.lastPostNumber() ?? 0, (discussion.lastReadPostNumber() || 0) + 1);
}
return (
<div {...attrs}>
{controls.length > 0 &&
Dropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
accessibleToggleLabel: app.translator.trans('core.forum.discussion_controls.toggle_dropdown_accessible_label'),
},
controls
)}
<span
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}
>
{icon('fas fa-check')}
</span>
<div className={classList('DiscussionListItem-content', 'Slidable-content', { unread: isUnread, read: isRead })}>
<Tooltip
text={app.translator.trans('core.forum.discussion_list.started_text', { user, ago: humanTime(discussion.createdAt()) })}
position="right"
>
<Link className="DiscussionListItem-author" href={user ? app.route.user(user) : '#'}>
{avatar(user || null, { title: '' })}
</Link>
</Tooltip>
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h2 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h2>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</Link>
{this.replyCountItem()}
</div>
</div>
);
return jumpTo;
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {