Skip to content
Snippets Groups Projects
person-select.js 15.2 KiB
Newer Older
import $ from 'jquery';
import {findObjectInApiResults} from './utils.js';
import select2 from 'select2';
import select2LangDe from './i18n/de/select2';
import select2LangEn from './i18n/en/select2';
import JSONLD from '@dbp-toolkit/common/jsonld';
import {css, html} from 'lit';
import {ScopedElementsMixin} from '@open-wc/scoped-elements';
import {createInstance} from './i18n.js';
import {Icon} from '@dbp-toolkit/common';
import * as commonUtils from '@dbp-toolkit/common/utils';
import * as commonStyles from '@dbp-toolkit/common/styles';
import select2CSSPath from 'select2/dist/css/select2.min.css';
Reiter, Christoph's avatar
Reiter, Christoph committed
import * as errorUtils from '@dbp-toolkit/common/error';
import {AdapterLitElement} from '@dbp-toolkit/provider/src/adapter-lit-element';

const personContext = {
Reiter, Christoph's avatar
Reiter, Christoph committed
    '@id': '@id',
    givenName: 'http://schema.org/givenName',
    familyName: 'http://schema.org/familyName',
    email: 'http://schema.org/email',
};

select2(window, $);

export class PersonSelect extends ScopedElementsMixin(AdapterLitElement) {
    constructor() {
        super();
Bekerle, Patrizio's avatar
Bekerle, Patrizio committed
        Object.assign(PersonSelect.prototype, errorUtils.errorMixin);
        this.auth = {};
        this._i18n = createInstance();
        this.lang = this._i18n.language;
        this.entryPointUrl = '';
        this.jsonld = null;
        this.$select = null;
        this.active = false;
        // For some reason using the same ID on the whole page twice breaks select2 (regardless if they are in different custom elements)
        this.selectId = 'person-select-' + commonUtils.makeId(24);
        this.value = '';
        this.object = null;
        this.ignoreValueUpdate = false;
        this.isSearching = false;
        this.lastResult = {};
        this.showReloadButton = false;
        this.reloadButtonTitle = '';

        this._onDocumentClicked = this._onDocumentClicked.bind(this);
    }

    static get scopedElements() {
        return {
Reiter, Christoph's avatar
Reiter, Christoph committed
            'dbp-icon': Icon,
        };
    }

    $(selector) {
        return $(this.shadowRoot.querySelector(selector));
    }

    static get properties() {
        return {
            ...super.properties,
Reiter, Christoph's avatar
Reiter, Christoph committed
            lang: {type: String},
            active: {type: Boolean, attribute: false},
            entryPointUrl: {type: String, attribute: 'entry-point-url'},
            value: {type: String},
            object: {type: Object, attribute: false},
            showReloadButton: {type: Boolean, attribute: 'show-reload-button'},
            reloadButtonTitle: {type: String, attribute: 'reload-button-title'},
            showDetails: {type: Boolean, attribute: 'show-details'},
            auth: {type: Object},
    }

    clear() {
        this.object = null;
Reiter, Christoph's avatar
Reiter, Christoph committed
        $(this).attr('data-object', '');
        $(this).data('object', null);
        this.value = '';
        // Reset value attribute to really make sure it is empty and will trigger a change event
        // when it is set again with the previous value
Reiter, Christoph's avatar
Reiter, Christoph committed
        $(this).attr('value', '');
        this.$select.val(null).trigger('change').trigger('select2:unselect');
    }

    connectedCallback() {
        super.connectedCallback();
        document.addEventListener('click', this._onDocumentClicked);
Reiter, Christoph's avatar
Reiter, Christoph committed
        this.updateComplete.then(() => {
            this.$select = this.$('#' + this.selectId);
            // try an init when user-interface is loaded
    disconnectedCallback() {
        document.removeEventListener('click', this._onDocumentClicked);
        super.disconnectedCallback();
    }

    _onDocumentClicked(ev) {
        // Close the popup when clicking outside of select2
        if (!ev.composedPath().includes(this)) {
            const $select = this.$('#' + this.selectId);
            if ($select.length && this.select2IsInitialized($select)) {
                $select.select2('close');
            }
    initJSONLD(ignorePreset = false) {
        const that = this;

Reiter, Christoph's avatar
Reiter, Christoph committed
        JSONLD.getInstance(this.entryPointUrl).then(
            function (jsonld) {
                that.jsonld = jsonld;
                that.active = that.authenticated();
Reiter, Christoph's avatar
Reiter, Christoph committed
                // we need to poll because maybe the user interface isn't loaded yet
                // Note: we need to call initSelect2() in a different function so we can access "this" inside initSelect2()
                commonUtils.pollFunc(
                    () => {
                        return that.initSelect2(ignorePreset);
                    },
                    10000,
                    100
                );
            },
            {},
            this.lang
        );
    }

    /**
     * Initializes the Select2 selector
     *
     * @param ignorePreset
     */
    initSelect2(ignorePreset = false) {
        const i18n = this._i18n;
        const that = this;
        const $this = $(this);

        if (this.jsonld === null) {
            return false;
        }

        // find the correct api url for a person
        const apiUrl = this.jsonld.getApiUrlForEntityName("BasePerson");

        if (this.$select === null) {
            return false;
        }

        // we need to destroy Select2 and remove the event listeners before we can initialize it again
        if (this.$select && this.$select.hasClass('select2-hidden-accessible')) {
            this.$select.select2('destroy');
            this.$select.off('select2:select');
            this.$select.off('select2:closing');
        }

Reiter, Christoph's avatar
Reiter, Christoph committed
        this.$select
            .select2({
                width: '100%',
                language: this.lang === 'de' ? select2LangDe() : select2LangEn(),
                minimumInputLength: 2,
                placeholder: this.authenticated()
                    ? i18n.t('person-select.placeholder')
                    : i18n.t('person-select.login-required'),
                dropdownParent: this.$('#person-select-dropdown'),
                ajax: {
                    delay: 500,
                    url: apiUrl,
                    contentType: 'application/ld+json',
                    beforeSend: function (jqXHR) {
                        jqXHR.setRequestHeader('Authorization', 'Bearer ' + that.auth.token);
                        that.isSearching = true;
                    },
                    data: function (params) {
                        return {
                            search: params.term.trim(),
                        };
                    },
                    processResults: function (data) {
                        that.$('#person-select-dropdown').addClass('select2-bug');

                        that.lastResult = data;
                        let transformed = that.jsonld.transformMembers(data, personContext);
                        const results = [];
                        transformed.forEach((person) => {
                            results.push({
                                id: person['@id'],
                                text: that.generateOptionText(person),
                            });
                        });

                        return {
                            results: results,
                        };
                    },
                    error: (jqXHR, textStatus, errorThrown) => {
                        this.handleXhrError(jqXHR, textStatus, errorThrown);
                    },
                    complete: (jqXHR, textStatus) => {
                        that.isSearching = false;
                    },
Reiter, Christoph's avatar
Reiter, Christoph committed
            })
            .on('select2:select', function (e) {
                that.$('#person-select-dropdown').removeClass('select2-bug');

                // set custom element attributes
                const identifier = e.params.data.id;
                that.object = findObjectInApiResults(identifier, that.lastResult);

                $this.attr('data-object', JSON.stringify(that.object));
                $this.data('object', that.object);

                if ($this.attr('value') !== identifier) {
                    that.ignoreValueUpdate = true;
                    $this.attr('value', identifier);

                    // fire a change event
                    that.dispatchEvent(
                        new CustomEvent('change', {
                            detail: {
                                value: identifier,
                            },
                            bubbles: true,
                        })
                    );
Reiter, Christoph's avatar
Reiter, Christoph committed
            })
            .on('select2:closing', (e) => {
                if (that.isSearching) {
                    e.preventDefault();
                }
            });

        // TODO: doesn't work here
        // this.$('.select2-selection__arrow').click(() => {
        //     console.log("click");
        // });

        // preset a person
        if (!ignorePreset && this.value !== '') {
            const apiUrl = this.entryPointUrl + this.value;

            fetch(apiUrl, {
                headers: {
                    'Content-Type': 'application/ld+json',
Reiter, Christoph's avatar
Reiter, Christoph committed
                    Authorization: 'Bearer ' + this.auth.token,
Reiter, Christoph's avatar
Reiter, Christoph committed
                .then((result) => {
                    if (!result.ok) throw result;
                    return result.json();
                })
                .then((person) => {
                    that.object = person;
                    const transformed = that.jsonld.compactMember(
                        that.jsonld.expandMember(person),
                        personContext
                    );
                    const identifier = transformed['@id'];

                    const option = new Option(
                        that.generateOptionText(transformed),
                        identifier,
                        true,
                        true
                    );
                    $this.attr('data-object', JSON.stringify(person));
                    $this.data('object', person);
                    that.$select.append(option).trigger('change');

                    // fire a change event
                    that.dispatchEvent(
                        new CustomEvent('change', {
                            detail: {
                                value: identifier,
                            },
                            bubbles: true,
                        })
                    );
                })
                .catch((e) => {
                    console.log(e);
                    that.clear();
                });
        }

        return true;
    }

    generateOptionText(person) {
Reiter, Christoph's avatar
Reiter, Christoph committed
        let text = person['givenName'] ?? '';
        if (person['familyName']) {
            text += ` ${person['familyName']}`;

        // add birth date to name if present
Reiter, Christoph's avatar
Reiter, Christoph committed
        if (this.showDetails && person['email'] !== undefined && person['email'] !== null) {
            text += ` (${person['email']})`;
        }

        return text;
    }

    update(changedProperties) {
        changedProperties.forEach((oldValue, propName) => {
            switch (propName) {
Reiter, Christoph's avatar
Reiter, Christoph committed
                case 'lang':
                    this._i18n.changeLanguage(this.lang);

                    if (this.select2IsInitialized()) {
                        // no other way to set an other language at runtime did work
                        this.initSelect2(true);
                    }
                    break;
Reiter, Christoph's avatar
Reiter, Christoph committed
                case 'value':
                    if (!this.ignoreValueUpdate && this.select2IsInitialized()) {
                        this.initSelect2();
                    }

                    this.ignoreValueUpdate = false;
                    break;
Reiter, Christoph's avatar
Reiter, Christoph committed
                case 'entryPointUrl':
                    // we don't need to preset the selector if the entry point url changes
                    this.initJSONLD(true);
                    break;
Reiter, Christoph's avatar
Reiter, Christoph committed
                case 'auth':
                    this.active = this.authenticated();
            }
        });

        super.update(changedProperties);
    }

    select2IsInitialized() {
Reiter, Christoph's avatar
Reiter, Christoph committed
        return this.$select !== null && this.$select.hasClass('select2-hidden-accessible');
    }

    reloadClick() {
        if (this.object === null) {
            return;
        }

        // fire a change event
Reiter, Christoph's avatar
Reiter, Christoph committed
        this.dispatchEvent(
            new CustomEvent('change', {
                detail: {
                    value: this.value,
                },
                bubbles: true,
            })
        );
    authenticated() {
        return (this.auth.token || '') !== '';
    }

    static get styles() {
        return [
            commonStyles.getThemeCSS(),
            commonStyles.getGeneralCSS(),
            commonStyles.getButtonCSS(),
            commonStyles.getFormAddonsCSS(),
            commonStyles.getSelect2CSS(),
            css`
Reiter, Christoph's avatar
Reiter, Christoph committed
                .select2-control.control {
                    width: 100%;
                }
Reiter, Christoph's avatar
Reiter, Christoph committed
                .field .button.control {
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    border: var(--dbp-border);
                    border-color: var(--dbp-muted);
Reiter, Christoph's avatar
Reiter, Christoph committed
                    -moz-border-radius-topright: var(--dbp-border-radius);
                    -moz-border-radius-bottomright: var(--dbp-border-radius);
                    line-height: 100%;
                }
Reiter, Christoph's avatar
Reiter, Christoph committed
                .field .button.control dbp-icon {
                    top: 0;
                }
Reiter, Christoph's avatar
Reiter, Christoph committed
                /* https://github.com/select2/select2/issues/5457 */
                .select2-bug .loading-results {
                    display: none !important;
                }
            `,
        const i18n = this._i18n;
        const select2CSS = commonUtils.getAssetURL(select2CSSPath);
        return html`
Reiter, Christoph's avatar
Reiter, Christoph committed
            <link rel="stylesheet" href="${select2CSS}" />
            <style>
                #${this.selectId} {
                    width: 100%;
                }
            </style>

            <div class="select">
                <div class="field has-addons">
                    <div class="select2-control control">
                        <!-- https://select2.org-->
Reiter, Christoph's avatar
Reiter, Christoph committed
                        <select
                            id="${this.selectId}"
                            name="person"
                            class="select"
                            ?disabled=${!this.active}>
                            ${!this.authenticated()
                                ? html`
                                      <option value="" disabled selected>
                                          ${i18n.t('person-select.login-required')}
                                      </option>
                                  `
                                : ''}
                    </div>
Reiter, Christoph's avatar
Reiter, Christoph committed
                    <a
                        class="control button"
                        id="reload-button"
                        ?disabled=${this.object === null}
                        @click="${this.reloadClick}"
                        style="display: ${this.showReloadButton ? 'flex' : 'none'}"
                        title="${this.reloadButtonTitle}">
                        <dbp-icon name="reload"></dbp-icon>
                    </a>
                </div>
                <div id="person-select-dropdown"></div>
            </div>
        `;
    }