1
0
mirror of https://github.com/flarum/core.git synced 2025-07-12 12:26:23 +02:00
Files
php-flarum/js/src/common/Application.js
Alexander Skvortsov 5e465f6051 Extract Composer state (#2161)
Like previous "state PRs", this moves app-wide logic relating to
our "composer" widget to its own "state" class, which can be
referenced and called from all parts of the app. This lets us
avoid storing component instances, which we cannot do any longer
once we update to Mithril v2.

This was not as trivial as some of the other state changes, as we
tried to separate DOM effects (e.g. animations) from actual state
changes (e.g. minimizing or opening the composer).

New features:

- A new `app.screen()` method returns the current responsive screen
  mode. This lets us check what breakpoint we're on in JS land  
  without hardcoding / duplicating the actual breakpoints from CSS.
- A new `SuperTextarea` util exposes useful methods for directly
  interacting with and manipulating the text contents of e.g. our
  post editor.
- A new `ConfirmDocumentUnload` wrapper component encapsulates the
  logic for asking the user for confirmation when trying to close
  the browser window or navigating to another page. This is used in
  the composer to prevent accidentally losing unsaved post content.

There is still potential for future cleanups, but we finally want   
to unblock the Mithril update, so these will have to wait:

- Composer height change logic is very DOM-based, so should maybe
  not sit in the state.
- I would love to experiment with using composition rather than
  inheritance for the `ComposerBody` subclasses.
2020-07-25 00:17:25 +02:00

444 lines
12 KiB
JavaScript

import ItemList from './utils/ItemList';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
import RequestErrorModal from './components/RequestErrorModal';
import Translator from './Translator';
import Store from './Store';
import Session from './Session';
import extract from './utils/extract';
import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
import User from './models/User';
import Discussion from './models/Discussion';
import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
/**
* The `App` class provides a container for an application, as well as various
* utilities for the rest of the app to use.
*/
export default class Application {
/**
* The forum model for this application.
*
* @type {Forum}
* @public
*/
forum = null;
/**
* A map of routes, keyed by a unique route name. Each route is an object
* containing the following properties:
*
* - `path` The path that the route is accessed at.
* - `component` The Mithril component to render when this route is active.
*
* @example
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
*
* @type {Object}
* @public
*/
routes = {};
/**
* An ordered list of initializers to bootstrap the application.
*
* @type {ItemList}
* @public
*/
initializers = new ItemList();
/**
* The app's session.
*
* @type {Session}
* @public
*/
session = null;
/**
* The app's translator.
*
* @type {Translator}
* @public
*/
translator = new Translator();
/**
* The app's data store.
*
* @type {Store}
* @public
*/
store = new Store({
forums: Forum,
users: User,
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification,
});
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
*
* @type {Object}
* @public
*/
cache = {};
/**
* Whether or not the app has been booted.
*
* @type {Boolean}
* @public
*/
booted = false;
/**
* The key for an Alert that was shown as a result of an AJAX request error.
* If present, it will be dismissed on the next successful request.
*
* @type {int}
* @private
*/
requestErrorAlert = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
/*
* An object that manages modal state.
*
* @type {ModalManagerState}
*/
modal = new ModalManagerState();
/**
* An object that manages the state of active alerts.
*
* @type {AlertManagerState}
*/
alerts = new AlertManagerState();
data;
title = '';
titleCount = 0;
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.mount();
}
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
for (const extender of extenders) {
extender.extend(this, { name, exports: extension });
}
});
}
mount(basePath = '') {
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**
* Get the API response document that has been preloaded into the application.
*
* @return {Object|null}
* @public
*/
preloadedApiDocument() {
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
this.data.apiDocument = null;
return results;
}
return null;
}
/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}
/**
* Set the <title> of the page.
*
* @param {String} title
* @public
*/
setTitle(title) {
this.title = title;
this.updateTitle();
}
/**
* Set a number to display in the <title> of the page.
*
* @param {Integer} count
*/
setTitleCount(count) {
this.titleCount = count;
this.updateTitle();
}
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
*/
request(originalOptions) {
const options = Object.assign({}, originalOptions);
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || ((responseText) => responseText);
options.errorHandler =
options.errorHandler ||
((error) => {
throw error;
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = (xhr) => {
let responseText;
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
}
const status = xhr.status;
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
// 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),
(error) => {
let children;
switch (error.status) {
case 422:
children = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401:
case 403:
children = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
children = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
children = 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));
error.alert = {
type: 'error',
children,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
</Button>,
],
};
try {
options.errorHandler(error);
} catch (error) {
if (isDebug && error.xhr) {
const { method, url } = error.options;
const { status = '' } = error.xhr;
console.group(`${method} ${url} ${status}`);
console.error(...(formattedError || [error]));
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert);
}
deferred.reject(error);
}
);
return deferred.promise;
}
/**
* @param {RequestError} error
* @param {string[]} [formattedError]
* @private
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestErrorAlert);
this.modal.show(RequestErrorModal, { error, formattedError });
}
/**
* Construct a URL to the route with the given name.
*
* @param {String} name
* @param {Object} params
* @return {String}
* @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') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}
}