1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 08:27:42 +02:00

Add ModalManager & LogInModal, add bidi attribute, fix Translator issues with text and vnodes

This commit is contained in:
David Sevilla Martin
2019-12-16 18:22:48 -05:00
parent b885346029
commit 48dccda707
19 changed files with 561 additions and 30924 deletions

11733
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

19251
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -19,12 +19,13 @@ import Notification from './models/Notification';
import RequestError from './utils/RequestError';
import Alert from './components/Alert';
import ModalManager from './components/ModalManager';
export type ApplicationData = {
apiDocument: any;
locale: string;
locales: any;
resources: Array<any>;
resources: any[];
session: any;
};
@@ -42,7 +43,7 @@ export default abstract class Application {
/**
* The app's session.
*/
session?: Session;
session: Session;
/**
* The app's data store.
@@ -58,6 +59,8 @@ export default abstract class Application {
drawer = new Drawer();
modal: ModalManager;
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
@@ -76,7 +79,8 @@ export default abstract class Application {
private requestError: Alert = null;
mount(basePath = '') {
// this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
m.mount(document.getElementById('modal'), new ModalManager());
// this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));

View File

@@ -9,9 +9,9 @@ export type ComponentProps = {
}
export default class Component<T extends ComponentProps = any> {
protected element: HTMLElement;
element: HTMLElement;
protected props = <T> {};
props = <T> {};
view(vnode) {
throw new Error('Component#view must be implemented by subclass');

View File

@@ -1,3 +1,5 @@
import User from './models/User';
/**
* The `Session` class defines the current user session. It stores a reference
* to the current authenticated user, and provides methods to log in/out.
@@ -22,11 +24,11 @@ export default class Session {
/**
* Attempt to log in a user.
*/
login(data: { identification: string, password: string }, options = {}): Promise {
login(body: { identification: string, password: string }, options = {}) {
return app.request(Object.assign({
method: 'POST',
url: app.forum.attribute('baseUrl') + '/login',
data
body
}, options));
}
@@ -36,6 +38,6 @@ export default class Session {
* @public
*/
logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

View File

@@ -77,7 +77,7 @@ export default class Store {
* @param query
* @param options
*/
find<T extends Model = Model>(type: string, id?: number|Array<number>|any, query = {}, options = {}): Promise<T[]> {
find<T extends Model = Model>(type: string, id?: number|number[]|any, query = {}, options = {}): Promise<T[]> {
let data = query;
let url = `${app.forum.attribute('apiUrl')}/${type}`;

View File

@@ -65,7 +65,7 @@ export default class Translator {
}
}
} else {
open[0].push(part);
open[0].push({ tag: 'span', text: part });
}
});

View File

@@ -54,7 +54,7 @@ export default class Button<T extends ButtonProps = ButtonProps> extends Compone
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent(attrs.icon, attrs.loading, children)}</button>;
return <button {...attrs}>{this.getButtonContent(iconName, attrs.loading, children)}</button>;
}
/**

View File

@@ -0,0 +1,116 @@
import Component, {ComponentProps} from '../Component';
import Alert from './Alert';
import Button from './Button';
import RequestError from "../utils/RequestError";
/**
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
* should implement the `className`, `title`, and `content` methods.
*/
export default abstract class Modal<T extends ComponentProps = ComponentProps> extends Component<T> {
/**
* An alert component to show below the header.
*/
alert: Alert;
loading: boolean;
view() {
if (this.alert) {
this.alert.props.dismissible = false;
}
return (
<div className={`Modal modal-dialog ${this.className()}`}>
<div className="Modal-content">
{this.isDismissible() ? (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
onclick: this.hide.bind(this),
className: 'Button Button--icon Button--link'
})}
</div>
) : ''}
<form onsubmit={this.onsubmit.bind(this)}>
<div className="Modal-header">
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>
{alert && <div className="Modal-alert">{this.alert}</div>}
{this.content()}
</form>
</div>
</div>
);
}
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*/
isDismissible(): boolean {
return true;
}
/**
* Get the class name to apply to the modal.
*/
abstract className(): string;
/**
* Get the title of the modal dialog.
*/
abstract title(): string;
/**
* Get the content of the modal.
*/
abstract content();
/**
* Handle the modal form's submit event.
*/
onsubmit(e: Event) {}
/**
* Focus on the first input when the modal is ready to be used.
*/
onready() {
this.$('form').find('input, select, textarea').first().focus().select();
}
onhide() {}
/**
* Hide the modal.
*/
hide() {
app.modal.close();
}
/**
* Stop loading.
*/
loaded() {
this.loading = false;
m.redraw();
}
/**
* Show an alert describing an error returned from the API, and give focus to
* the first relevant field.
*/
onerror(error: RequestError) {
this.alert = error.alert;
m.redraw();
if (error.status === 422 && error.response.errors) {
this.$(`form [name="${error.response.errors[0].source.pointer.replace('/data/attributes/', '')}"]`).select();
} else {
this.onready();
}
}
}

View File

@@ -0,0 +1,115 @@
import MicroModal from 'micromodal';
import Component from "../Component";
import Modal from './Modal';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
* can be shown at once; loading a new component into the ModalManager will
* overwrite the previous one.
*/
export default class ModalManager extends Component {
showing: boolean;
component: Modal;
hideTimeout: number;
oncreate(vnode) {
super.oncreate(vnode);
app.modal = this;
window.vnode = vnode;
}
view() {
return (
<div className="ModalManager modal" id="Modal" onclick={this.onclick.bind(this)} key="modal">
{this.component && m(this.component)}
</div>
);
}
/**
* Show a modal dialog.
*/
show(component: Modal) {
if (!(component instanceof Modal)) {
throw new Error('The ModalManager component can only show Modal components');
}
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
// if (app.current) app.current.retain = true;
m.redraw(true);
if (!$('.modal-backdrop').length) {
$('<div />').addClass('modal-backdrop')
.appendTo('body');
}
MicroModal.show('Modal', {
awaitCloseAnimation: true,
onClose: () => {
$('.modal-backdrop').fadeOut(200, function () {
this.remove();
});
this.showing = false;
}
});
this.onready();
}
onclick(e) {
if (e.target === this.element) {
this.close();
}
}
/**
* Close the modal dialog.
*/
close() {
if (!this.showing) return;
// Don't hide the modal immediately, because if the consumer happens to call
// the `show` method straight after to show another modal dialog, it will
// cause the new modal dialog to disappear. Instead we will wait for a tiny
// bit to give the `show` method the opportunity to prevent this from going
// ahead.
this.hideTimeout = setTimeout(() => MicroModal.close('Modal'));
}
/**
* Clear content from the modal area.
*
* @protected
*/
clear() {
if (this.component) {
this.component.onhide();
}
this.component = null;
app.current.retain = false;
m.lazyRedraw();
}
/**
* When the modal dialog is ready to be used, tell it!
*
* @protected
*/
onready() {
if (this.component && this.component.onready) {
this.component.onready(this.$());
}
}
}

View File

@@ -2,12 +2,21 @@ import Mithril from "mithril";
import Alert from "../components/Alert";
export interface RequestErrorResponse extends JSON {
errors?: {
code: string;
source?: {
pointer: string;
};
}[];
}
export default class RequestError {
status: number;
responseText: string;
options: Mithril.RequestOptions;
xhr: XMLHttpRequest;
response?: JSON;
response?: RequestErrorResponse;
alert?: Alert;
constructor(status, responseText, options, xhr) {

View File

@@ -6,7 +6,7 @@
* @param {function} compute The function which computes the value using the
* dependent values.
*/
export default function computed(...dependentKeys: Array<string|Function>): () => any {
export default function computed(...dependentKeys: string[]|Function[]): () => any {
const keys = <string[]> dependentKeys.slice(0, -1);
const compute = <Function> dependentKeys.slice(-1)[0];

View File

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

View File

@@ -1,9 +1,34 @@
import prop from 'mithril/stream';
export default () => {
m.withAttr = (key: string, cb: Function) => function () {
cb(this.getAttribute(key) || this[key]);
};
const mo = global.m;
m.prop = prop;
const m = function (comp, ...args) {
const node = mo.apply(this, arguments);
if (!node.attrs) node.attrs = {};
if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi);
}
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.tag = m.route.Link;
delete node.attrs.route;
}
return node;
};
Object.keys(mo).forEach(key => m[key] = mo[key]);
m.withAttr = (key: string, cb: Function) => function () {
cb(this.getAttribute(key) || this[key]);
};
m.prop = prop;
global.m = m;
}

View File

@@ -1,6 +1,6 @@
import Component from '../../common/Component';
import Button from '../../common/components/Button';
// import LogInModal from './LogInModal';
import LogInModal from './LogInModal';
// import SignUpModal from './SignUpModal';
import SessionDropdown from './SessionDropdown';
import SelectDropdown from '../../common/components/SelectDropdown';

View File

@@ -0,0 +1,186 @@
import Stream from 'mithril/stream';
import Modal from '../../common/components/Modal';
import {ComponentProps} from '../../common/Component';
import ItemList from "../../common/utils/ItemList";
import Button from "../../common/components/Button";
export interface LogInModalProps extends ComponentProps {
username?: string;
password?: string;
remember?: boolean;
}
/**
* The `LogInModal` component displays a modal dialog with a login form.
*
* ### Props
*
* - `identification`
* - `password`
* - `remember`
*/
export default class LogInModal extends Modal<LogInModalProps> {
/**
* The value of the identification input.
*/
identification: Stream<string>;
/**
* The value of the password input.
*/
password: Stream<string>;
/**
* The value of the remember me input.
*/
remember: Stream<string>;
oninit(vnode) {
super.oninit(vnode);
console.log('#oninit');
this.identification = m.prop(this.props.identification || '');
this.password = m.prop(this.props.password || '');
this.remember = m.prop(!!this.props.remember);
}
className(): string {
return 'LogInModal Modal--small';
}
title(): string {
return app.translator.transText('core.forum.log_in.title');
}
content() {
return [
<div className="Modal-body">
{this.body()}
</div>,
<div className="Modal-footer">
{this.footer()}
</div>
];
}
body() {
return [
// <LogInButtons/>,
<div className="Form Form--centered">
{this.fields().toArray()}
</div>
];
}
fields() {
const items = new ItemList();
items.add('identification', <div className="Form-group">
<input className="FormControl" name="identification" type="text" placeholder={app.translator.transText('core.forum.log_in.username_or_email_placeholder')}
bidi={this.identification}
disabled={this.loading} />
</div>, 30);
items.add('password', <div className="Form-group">
<input className="FormControl" name="password" type="password" placeholder={app.translator.transText('core.forum.log_in.password_placeholder')}
bidi={this.password}
disabled={this.loading} />
</div>, 20);
items.add('remember', <div className="Form-group">
<div>
<label className="checkbox">
<input type="checkbox" bidi={this.remember} disabled={this.loading} />
{app.translator.trans('core.forum.log_in.remember_me_label')}
</label>
</div>
</div>, 10);
items.add('submit', <div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button')
})}
</div>, -10);
return items;
}
footer() {
return [
<p className="LogInModal-forgotPassword">
<a onclick={this.forgotPassword.bind(this)}>{app.translator.trans('core.forum.log_in.forgot_password_link')}</a>
</p>,
app.forum.attribute('allowSignUp') && (
<p className="LogInModal-signUp">
{app.translator.trans('core.forum.log_in.sign_up_text', {a: <a onclick={this.signUp.bind(this)}/> })}
</p>
)
];
}
/**
* Open the forgot password modal, prefilling it with an email if the user has
* entered one.
*
* @public
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? {email} : undefined;
app.modal.show(new ForgotPasswordModal(props));
}
/**
* Open the sign up modal, prefilling it with an email/username/password if
* the user has entered one.
*
* @public
*/
signUp() {
const props = {password: this.password()};
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
// app.modal.show(new SignUpModal(props));
}
oncreate(vnode) {
super.oncreate(vnode);
this.$(`[name="${this.identification() ? 'password' : 'identification'}"]`).select();
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
const identification = this.identification();
const password = this.password();
const remember = this.remember();
app.session.login({identification, password, remember}, {errorHandler: this.onerror.bind(this)})
.then(
() => window.location.reload(),
this.loaded.bind(this)
);
}
onerror(error) {
if (error.status === 401) {
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);
}
}