Skip to content
Snippets Groups Projects
  • Reiter, Christoph's avatar
    433d5c85
    Add a new resource-select package/web-component · 433d5c85
    Reiter, Christoph authored
    The goal of this new component is to be a generic selector for API resources,
    unlike the organization/person select, which hardcode various things for their
    used resource.
    
    Instead this provides some events which can be used to customize the creation of the
    URL for fetching as well as for formatting the list of results.
    
    Atm this only handles the organization and no search-as-you-type like the person select,
    but maybe this can be extended in the future.
    433d5c85
    History
    Add a new resource-select package/web-component
    Reiter, Christoph authored
    The goal of this new component is to be a generic selector for API resources,
    unlike the organization/person select, which hardcode various things for their
    used resource.
    
    Instead this provides some events which can be used to customize the creation of the
    URL for fetching as well as for formatting the list of results.
    
    Atm this only handles the organization and no search-as-you-type like the person select,
    but maybe this can be extended in the future.
resource-select.js 7.97 KiB
import $ from 'jquery';
import select2 from 'select2';
import select2CSSPath from 'select2/dist/css/select2.min.css';
import {createInstance} from './i18n.js';
import {css, html} from 'lit';
import * as commonUtils from '@dbp-toolkit/common/utils';
import * as commonStyles from '@dbp-toolkit/common/styles';
import select2LangDe from '@dbp-toolkit/resource-select/src/i18n/de/select2';
import select2LangEn from '@dbp-toolkit/resource-select/src/i18n/en/select2';
import {AdapterLitElement} from '@dbp-toolkit/provider/src/adapter-lit-element';
import * as hydra from './hydra.js';

export class ResourceSelect extends AdapterLitElement {
    constructor() {
        super();
        this._i18n = createInstance();
        this._resources = [];
        this._url = null;
        // For some reason using the same ID on the whole page twice breaks select2 (regardless if they are in different custom elements)
        this._selectId = 'select-resource-' + commonUtils.makeId(24);

        this.auth = {};
        this.lang = this._i18n.language;
        this.entryPointUrl = null;
        this.resourcePath = null;
        this.value = null;
        this.valueObject = null;

        this._onDocumentClicked = this._onDocumentClicked.bind(this);
        select2(window, $);
    }

    static get properties() {
        return {
            ...super.properties,
            lang: {type: String},
            auth: {type: Object},
            entryPointUrl: {type: String, attribute: 'entry-point-url'},
            resourcePath: {type: String, attribute: 'resource-path'},
            value: {type: String, reflect: true},
        };
    }

    _getSelect2() {
        return this._$('#' + this._selectId);
    }

    _$(selector) {
        return $(this.renderRoot.querySelector(selector));
    }

    _IsSelect2Initialized(elm) {
        return elm !== null && elm.hasClass('select2-hidden-accessible');
    }

    connectedCallback() {
        super.connectedCallback();
        document.addEventListener('click', this._onDocumentClicked);
        this._updateAll();
    }

    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._getSelect2();
            if ($select.length && this._IsSelect2Initialized($select)) {
                $select.select2('close');
            }
        }
    }

    _clearSelect2() {
        const $select = this._getSelect2();
        console.assert($select.length, 'select2 missing');

        // we need to destroy Select2 and remove the event listeners before we can initialize it again
        if (this._IsSelect2Initialized($select)) {
            $select.off('select2:select');
            $select.empty().trigger('change');
            $select.select2('destroy');
        }
    }

    _getUrl() {
        if (this.entryPointUrl === null) {
            return null;
        }
        let url = this.entryPointUrl;
        if (this.resourcePath !== null) {
            url = new URL(this.resourcePath, this.entryPointUrl).href;
        }
        let detail = {
            url: url,
        };
        const event = new CustomEvent('build-url', {
            detail: detail,
        });
        this.dispatchEvent(event);
        return detail.url;
    }

    _getText(resource) {
        let detail = {
            text: resource.name ?? null,
            object: resource,
        };
        const event = new CustomEvent('format-resource', {
            detail: detail,
        });
        this.dispatchEvent(event);
        return detail.text;
    }

    _setValue(value) {
        let changed = false;
        changed = this.value !== value;
        this.value = value;

        let found = null;
        for (let res of this._resources) {
            if (res['@id'] === this.value) {
                found = res;
                break;
            }
        }
        changed = changed || this.valueObject !== found;
        this.valueObject = found;

        if (!changed) {
            return;
        }

        const event = new CustomEvent('change', {
            bubbles: true,
            composed: true,
            detail: {
                value: this.value,
                object: this.valueObject,
            },
        });
        this.dispatchEvent(event);
    }

    async _updateAll() {
        this._setValue(this.value);
        if (!this.auth.token) {
            await this._setSelect2Loading();
            return;
        }
        await this._updateResources();
        await this._updateSelect2();
    }

    async _setSelect2Loading() {
        await this.updateComplete;
        const i18n = this._i18n;

        const $select = this._getSelect2();
        console.assert($select.length, 'select2 missing');

        // Show an empty select until we load the resources
        this._clearSelect2();

        $select.select2({
            width: '100%',
            language: this.lang === 'de' ? select2LangDe() : select2LangEn(),
            placeholder: i18n.t('select.loading'),
            data: [],
            disabled: true,
        });
    }

    async _updateResources() {
        let url = this._getUrl();
        if (url === null || url === this._url) {
            return;
        }

        this._resources = await hydra.getCollection(url, this.auth.token);
        this._url = url;
        this._setValue(this.value);
    }

    async _updateSelect2() {
        await this.updateComplete;
        const i18n = this._i18n;

        const $select = this._getSelect2();
        console.assert($select.length, 'select2 missing');

        const data = this._resources.map((item) => {
            return {id: item['@id'], text: this._getText(item)};
        });

        data.sort((a, b) => {
            return a.text < b.text ? -1 : a.text > b.text ? 1 : 0;
        });

        this._clearSelect2();

        $select
            .select2({
                width: '100%',
                language: this.lang === 'de' ? select2LangDe() : select2LangEn(),
                placeholder: i18n.t('select.placeholder'),
                dropdownParent: this._$('#select-resource-dropdown'),
                data: data,
                disabled: false,
            })
            .on('select2:select', () => {
                let id = $select.select2('data')[0].id;
                this._setValue(id);
            });

        // If none is selected, default to the first one
        if (this.value === null && data.length) {
            this._setValue(data[0].id);
        }

        // Apply the selection
        $select.val(this.value).trigger('change');
    }

    update(changedProperties) {
        if (changedProperties.has('lang')) {
            this._i18n.changeLanguage(this.lang);
        }

        if (
            changedProperties.has('lang') ||
            changedProperties.has('value') ||
            changedProperties.has('resourcePath') ||
            changedProperties.has('entryPointUrl') ||
            changedProperties.has('auth')
        ) {
            this._updateAll();
        }

        super.update(changedProperties);
    }

    static get styles() {
        return [
            commonStyles.getThemeCSS(),
            commonStyles.getGeneralCSS(),
            commonStyles.getNotificationCSS(),
            commonStyles.getSelect2CSS(),
            // language=css
            css``,
        ];
    }

    render() {
        const select2CSS = commonUtils.getAssetURL(select2CSSPath);
        return html`
            <link rel="stylesheet" href="${select2CSS}" />

            <div class="select">
                <div class="select2-control control">
                    <select
                        id="${this._selectId}"
                        name="select-resources"
                        class="select"
                        style="visibility: hidden;"></select>
                </div>
                <div id="select-resource-dropdown"></div>
            </div>
        `;
    }
}