UNPKG

@teipublisher/pb-components

Version:
483 lines (443 loc) 13.1 kB
import { LitElement, html, css } from 'lit-element'; import { pbMixin, waitOnce } from './pb-mixin.js'; import { translate } from './pb-i18n.js'; import { typesetMath } from './pb-formula.js'; import { registry } from './urls.js'; import '@polymer/iron-ajax'; import './pb-dialog.js'; import { themableMixin } from './theming.js'; /** * Dynamically load data by calling a server-side script, optionally triggered by an event. * This is used for e.g. the document list on the start page or the table * of contents. * * @slot - default unnamed slot for content * @fires pb-start-update - Fired before the element updates its content * @fires pb-end-update - Fired after the element has finished updating its content * @fires pb-results-received - Fired when the component received content from the server * @fires pb-toggle - When received, changes the state of the feature */ export class PbLoad extends themableMixin(pbMixin(LitElement)) { static get properties() { return { ...super.properties, /** The URL for the AJAX request. If a relative URL is passed, it will be resolved * against the current API endpoint. */ url: { type: String, }, /** * If set to true, the `url` property will be interpreted as a template string * containing placeholders for parameters in `{parameter-name}`. The placeholders * will be replaced by the actual parameters when building the final URL. Each parameter * used in the URL template will be removed from the request parameter list. */ expand: { type: Boolean, }, /** ID of the pb-document this element is connected to. The document path to * load will be taken from the pb-document. */ src: { type: String, }, /** * The container element into which the results returned by * the AJAX request will be loaded. */ container: { type: String, }, /** * Should content be loaded immediately when the component is initialized? */ auto: { type: Boolean, }, /** * Only load content once, not every time a `pb-load` event is received. */ loadOnce: { type: Boolean, }, /** * If set, relative links (img, a) will be made absolute. */ fixLinks: { type: Boolean, attribute: 'fix-links', }, /** * Start offset to use for showing paginated content. */ start: { type: Number, }, /** * If set, a parameter "language" will be added to the parameter list. * Also, a refresh will be triggered if a `pb-i18n-update` event is received, * e.g. due to the user selecting a different interface language. * * Also requires `requireLanguage` to be set on the surrounding `pb-page`. * See there for more information. */ useLanguage: { type: Boolean, attribute: 'use-language', }, /** * Indicates whether or not cross-site Access-Control requests should be made. * Default is false. */ noCredentials: { type: Boolean, attribute: 'no-credentials', }, /** * Indicates if the parameters of the request made should be saved to the browser * history and URL. Default: false. */ history: { type: Boolean, }, /** * The event which will trigger a new request to be sent. Default is 'pb-load'. */ event: { type: String, }, /** * Additional, user-defined parameters to be sent with the request. */ userParams: { type: Object, }, /** * If set, silently ignore errors when sending the request. */ silent: { type: Boolean, }, /** * Do not add internal parameters like 'start' or 'language' to the URL * but leave it untouched. */ plain: { type: Boolean, }, }; } constructor() { super(); this.auto = false; this.loadOnce = false; this.history = false; this.event = 'pb-load'; this.loaded = false; this.language = null; this.noCredentials = false; this.silent = false; } connectedCallback() { super.connectedCallback(); this.subscribeTo(this.event, ev => { waitOnce('pb-page-ready', () => { if (this.history) { if (ev.detail && ev.detail.params) { const { start } = ev.detail.params; if (start) { registry.commit(this, { start }); } } this.userParams = registry.state; registry.subscribe(this, state => { if (state.start) { this.start = state.start; this.load(); } }); registry.replace(this, this.userParams); } this.load(ev); }); }); this.subscribeTo('pb-toggle', ev => { this.toggleFeature(ev); }); this.subscribeTo( 'pb-i18n-update', ev => { const needsRefresh = this.language && this.language !== ev.detail.language; this.language = ev.detail.language; if (this.useLanguage && needsRefresh) { this.load(); } }, [], ); if (this.history) { registry.subscribe(this, state => { this.start = state.start; this.userParams = state; this.load(); }); } this.signalReady(); } firstUpdated() { if (this.auto) { this.start = registry.state.start || 1; waitOnce('pb-page-ready', data => { if (data && data.language) { this.language = data.language; } this.wait(() => this.load()); }); } else { waitOnce('pb-page-ready', data => { if (data && data.language) { this.language = data.language; } }); } } attributeChangedCallback(name, oldValue, newValue) { super.attributeChangedCallback(name, oldValue, newValue); if (oldValue && oldValue !== newValue) { switch (name) { case 'url': case 'userParams': case 'start': if (this.auto && this.loader) { waitOnce('pb-page-ready', () => { this.wait(() => this.load()); }); } break; default: break; } } } render() { return html` <slot></slot> <iron-ajax id="loadContent" verbose handle-as="text" method="get" ?with-credentials="${!this.noCredentials}" @response="${this._handleContent}" @error="${this._handleError}" ></iron-ajax> <pb-dialog id="errorDialog" title="${translate('dialogs.error')}"> <p id="errorMessage"></p> <div slot="footer"> <button rel="prev">${translate('dialogs.close')}</button> </div> </pb-dialog> `; } static get styles() { return css` :host { display: block; } `; } toggleFeature(ev) { this.userParams = registry.getState(this); console.log('<pb-load> toggle feature %o', this.userParams); if (ev.detail.refresh) { if (this.history) { registry.commit(this, this.userParams); } this.load(); } } getURL(params) { let { url } = this; if (this.expand) { url = url.replace(/{([^})]+)}/g, (match, key) => { if (!params[key]) { return ''; } const param = encodeURIComponent(params[key] || key); delete params[key]; return param; }); } return this.toAbsoluteURL(url); } load(ev) { if (!this.url) { return; } if (this.loadOnce && this.loaded) { return; } this.emitTo('pb-start-update'); let params = {}; if (ev) { if (ev instanceof Event) { if (ev.detail && ev.detail.params) { params = ev.detail.params; } } else { params = ev; } } const doc = this.getDocument(); if (!this.plain) { if (doc) { params.doc = doc.path; } // set start parameter to start property, but only if not provided otherwise already if (this.start && !params.start) { params.start = this.start; } if (this.language) { params.language = this.language; } } params = this.prepareParameters(params); // filter null values for (const [k, v] of Object.entries(params)) { if (v === null) { delete params[k]; } } const url = this.getURL(params); console.log('<pb-load> Loading %s with parameters %o', url, params); const loader = this.shadowRoot.getElementById('loadContent'); loader.params = params; loader.url = url; loader.generateRequest(); if (this.loadOnce) { this.loaded = true; } } /** * Allow subclasses to set parameters before the request is being sent. * * @param params Map of parameters * @return new or modified parameters map */ prepareParameters(params) { if (this.userParams) { return Object.assign(params, this.userParams); } return params; } _handleContent(ev) { const resp = this.shadowRoot.getElementById('loadContent').lastResponse; if (this.container) { this.style.display = 'none'; document.querySelectorAll(this.container).forEach(elem => { elem.innerHTML = resp; this._parseHeaders(ev.detail.xhr, elem); this._fixLinks(elem); this._onLoad(elem); }); } else { this.style.display = ''; this._clearContent(); const div = document.createElement('div'); div.innerHTML = resp; this._parseHeaders(ev.detail.xhr, div); div.slot = ''; this.appendChild(div); this._fixLinks(div); this._onLoad(div); } this.emitTo('pb-end-update'); } _clearContent() { const contentSlot = this.shadowRoot.querySelector('slot:not([name])'); if (contentSlot) { // clear content from slot contentSlot.assignedNodes().forEach(node => node.parentNode.removeChild(node)); } } _handleError() { this.emitTo('pb-end-update'); const loader = this.shadowRoot.getElementById('loadContent'); const { response } = loader.lastError; if (this.silent) { console.error('Request failed: %s', response ? response.description : ''); return; } let message; if (response) { message = response.description; } else { message = '<pb-i18n key="dialogs.serverError"></pb-i18n>'; } const dialog = this.shadowRoot.getElementById('errorDialog'); const messageElement = this.shadowRoot.getElementById('errorMessage'); messageElement.innerHTML = `<pb-i18n key="dialogs.serverError"></pb-i18n>: ${message}`; dialog.openDialog(); } _parseHeaders(xhr, content) { // Try to determine number of pages and current position // Search for data-pagination-* attributes first and if they // can't be found, check HTTP headers // // However, if noCredentials is set, we won't be able to access the headers function getPaginationParam(type, noHeaders) { const elem = content.querySelector(`[data-pagination-${type}]`); if (elem) { return elem.getAttribute(`data-pagination-${type}`); } return noHeaders ? 0 : xhr.getResponseHeader(`pb-${type}`); } const total = getPaginationParam('total', this.noCredentials); const start = getPaginationParam('start', this.noCredentials); if (this.start !== start) { this.start = parseInt(start); } this.emitTo('pb-results-received', { count: total ? parseInt(total, 10) : 0, start: this.start, content, params: this.shadowRoot.getElementById('loadContent').params, }); } _fixLinks(content) { typesetMath(content); if (this.fixLinks) { content.querySelectorAll('img').forEach(image => { const oldSrc = image.getAttribute('src'); const src = new URL(oldSrc, `${this.getEndpoint()}/`); image.src = src; }); content.querySelectorAll('a').forEach(link => { const oldHref = link.getAttribute('href'); const href = new URL(oldHref, `${this.getEndpoint()}/`); link.href = href; }); } } _onLoad(content) {} /** * Fired before the element updates its content * * @event pb-start-update * @param {object} Parameters to be passed to the request */ /** * Fired after the element has finished updating its content * * @event pb-end-update */ /** * Fired after the element has received content from the server * * @event pb-results-received * @param {int} count number of results received (according to `pb-total` header) * @param {int} start offset into the result set (according to `pb-start` header) */ } customElements.define('pb-load', PbLoad);