UNPKG

@digital-blueprint/formalize-app

Version:

[GitHub Repository](https://github.com/digital-blueprint/formalize-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/formalize-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/formalize-app/) | [Formalize Bundle](https:

1,652 lines • 55.3 kB
import {css, html} from 'lit';
import {html as staticHtml, unsafeStatic} from 'lit/static-html.js';
import {ref, createRef} from 'lit/directives/ref.js';
import {
    ScopedElementsMixin,
    Button,
    Icon,
    IconButton,
    MiniSpinner,
    DBPSelect,
    sendNotification,
} from '@dbp-toolkit/common';
import * as commonUtils from '@dbp-toolkit/common/utils';
import * as commonStyles from '@dbp-toolkit/common/styles.js';
import DBPFormalizeLitElement from './dbp-formalize-lit-element.js';
import {formatDate, SUBMISSION_STATES_BINARY} from './utils.js';
import {DeletionConfirmationModal} from './deletion-confirmation-modal.js';
import {CustomTabulatorTable, GetDetailsButton} from './table-components.js';
import {Modal} from '@dbp-toolkit/common/src/modal.js';
import {Notification} from '@dbp-toolkit/notification';
import {
    gatherFormDataFromElement,
    validateRequiredFields,
} from '@dbp-toolkit/form-elements/src/utils.js';

function parseSubmissionData(submission) {
    if (!submission?.dataFeedElement) {
        return {};
    }

    try {
        return JSON.parse(submission.dataFeedElement);
    } catch (error) {
        console.error('Failed to parse item data:', error);
        return {};
    }
}

function parseListAttribute(value) {
    if (Array.isArray(value)) {
        return value.map((item) => `${item}`.trim()).filter((item) => item !== '');
    }

    if (typeof value !== 'string') {
        return [];
    }

    const trimmedValue = value.trim();
    if (trimmedValue === '') {
        return [];
    }

    try {
        const parsedValue = JSON.parse(trimmedValue);
        if (Array.isArray(parsedValue)) {
            return parsedValue.map((item) => `${item}`.trim()).filter((item) => item !== '');
        }
    } catch {
        // Fallback to comma-separated values for HTML attributes.
    }

    return trimmedValue
        .split(',')
        .map((item) => item.trim())
        .filter((item) => item !== '');
}

function getLocalizedFormName(form, lang) {
    const localizedName = form?.localizedNames?.find((name) => name.languageTag === lang);
    return localizedName?.name || form?.name || form?.identifier || '';
}

function getSentenceCaseItemName(name, lang) {
    if (lang !== 'en' || !name) {
        return name;
    }

    return name.charAt(0).toLowerCase() + name.slice(1);
}

const itemFormsCache = new Map();
const itemFormsInflight = new Map();
const editModalNotificationId = 'manage-fields-edit-modal-notification';

class ManageFields extends ScopedElementsMixin(DBPFormalizeLitElement) {
    constructor() {
        super();
        this.formRef = createRef();
        this.itemModules = [];
        this.itemFormEntries = [];
        this.activeModule = null;
        this.activeForm = null;
        this.items = [];
        this.selectedItem = null;
        this.mode = 'forms';
        this.loading = true;
        this.loadingItems = false;
        this.saving = false;
        this.deletingItems = false;
        this.selectedItemCount = 0;
        this.lastModifiedByName = '';
        this.errorMessage = '';
        this.itemFrontendKeys = '';
        this._hasLoadedForms = false;
        this._isLoadingForms = false;
        this._lastFrontendKeys = '';
        this._routeApplyPromise = Promise.resolve();
        this._submissionsLoadPromise = null;
        this._submissionsLoadFormIdentifier = '';
        this._userNameCache = new Map();
        this._failedUserNameLookups = new Set();
    }

    static get scopedElements() {
        return {
            'dbp-button': Button,
            'dbp-icon': Icon,
            'dbp-icon-button': IconButton,
            'dbp-select': DBPSelect,
            'dbp-mini-spinner': MiniSpinner,
            'dbp-formalize-get-details-button': GetDetailsButton,
            'dbp-tabulator-table': CustomTabulatorTable,
            'dbp-formalize-deletion-confirmation-modal': DeletionConfirmationModal,
            'dbp-modal': Modal,
            'dbp-notification': Notification,
        };
    }

    static get properties() {
        return {
            ...super.properties,
            itemModules: {type: Array, attribute: false},
            itemFormEntries: {type: Array, attribute: false},
            activeModule: {type: Object, attribute: false},
            activeForm: {type: Object, attribute: false},
            items: {type: Array, attribute: false},
            selectedItem: {type: Object, attribute: false},
            mode: {type: String, attribute: false},
            loading: {type: Boolean, attribute: false},
            loadingItems: {type: Boolean, attribute: false},
            saving: {type: Boolean, attribute: false},
            deletingItems: {type: Boolean, attribute: false},
            selectedItemCount: {type: Number, attribute: false},
            lastModifiedByName: {type: String, attribute: false},
            errorMessage: {type: String, attribute: false},
            itemFrontendKeys: {type: String, attribute: 'item-frontend-keys'},
        };
    }

    async initialize() {
        await this.loadItemForms();
    }

    async loadItemForms() {
        if (!this.auth?.token || this._isLoadingForms) {
            return;
        }

        const cacheKey = `${this.basePath}|${this.entryPointUrl}|${this.itemFrontendKeys}`;
        const cachedResult = itemFormsCache.get(cacheKey);
        if (cachedResult) {
            this.itemModules = cachedResult.itemModules;
            this.itemFormEntries = cachedResult.itemFormEntries;
            this._hasLoadedForms = true;
            this._lastFrontendKeys = this.itemFrontendKeys;
            await this.applyRoute();
            return;
        }

        this._isLoadingForms = true;
        this.loading = true;
        this.errorMessage = '';

        try {
            let inflight = itemFormsInflight.get(cacheKey);
            if (!inflight) {
                inflight = this.loadItemFormEntries().finally(() => {
                    itemFormsInflight.delete(cacheKey);
                });
                itemFormsInflight.set(cacheKey, inflight);
            }

            const result = await inflight;
            itemFormsCache.set(cacheKey, result);

            this.itemModules = result.itemModules;
            this.itemFormEntries = result.itemFormEntries;
            this._hasLoadedForms = true;
            this._lastFrontendKeys = this.itemFrontendKeys;
            await this.applyRoute();
        } catch (error) {
            console.error(error);
            this.errorMessage = this._i18n.t('manage-fields.load-error');
        } finally {
            this._isLoadingForms = false;
            this.loading = false;
        }
    }

    async loadItemFormEntries() {
        const itemModules = await this.getItemModules();
        const itemFormEntries = await this.getMatchingForms(itemModules);
        return {itemModules, itemFormEntries};
    }

    async getItemModules() {
        const response = await fetch(this.basePath + 'modules.json');
        const data = await response.json();
        const itemEntries = Object.entries(data.items || {});
        const frontendKeys = parseListAttribute(this.itemFrontendKeys);

        const modules = [];
        for (const [, path] of itemEntries) {
            const absolutePath = new URL(path, window.location.origin + this.basePath).href;
            const module = await import(absolutePath);
            const object = new module.default();
            const frontendKey = object.getFormFrontendKey?.();

            if (
                object.getFormComponent &&
                object.getItemText &&
                (frontendKeys.length === 0 || frontendKeys.includes(frontendKey))
            ) {
                modules.push(object);
            }
        }

        return modules;
    }

    async getMatchingForms(itemModules) {
        if (itemModules.length === 0) {
            return [];
        }

        const frontendKeys = parseListAttribute(this.itemFrontendKeys);
        const formsUrl = new URL(this.entryPointUrl + '/formalize/forms');
        formsUrl.searchParams.set('perPage', '9999');
        frontendKeys.forEach((frontendKey) => {
            formsUrl.searchParams.append('whereFrontendKeyIn[]', frontendKey);
        });

        const response = await fetch(formsUrl.href, {
            headers: {
                'Content-Type': 'application/ld+json',
                Authorization: 'Bearer ' + this.getToken(),
            },
        });

        if (!response.ok) {
            this.handleErrorResponse(response);
            return [];
        }

        const data = await response.json();
        const forms = data['hydra:member'] || [];
        const entries = [];

        for (const form of forms) {
            const module = itemModules.find((candidate) => {
                const frontendKey = candidate.getFormFrontendKey?.();
                const formIdentifier = candidate.getFormIdentifier?.();
                return (
                    (frontendKey && form.frontendKey === frontendKey) ||
                    (formIdentifier && form.identifier === formIdentifier)
                );
            });

            if (module) {
                entries.push({form, module});
            }
        }

        return entries;
    }

    async loadSubmissions() {
        if (!this.activeForm) {
            this.items = [];
            return;
        }

        const formIdentifier = this.activeForm.identifier;
        if (
            this._submissionsLoadPromise !== null &&
            this._submissionsLoadFormIdentifier === formIdentifier
        ) {
            await this._submissionsLoadPromise;
            return;
        }

        this.loadingItems = true;
        this._submissionsLoadFormIdentifier = formIdentifier;
        const loadPromise = this.fetchSubmissions(formIdentifier).finally(() => {
            if (this._submissionsLoadPromise === loadPromise) {
                this._submissionsLoadPromise = null;
                this._submissionsLoadFormIdentifier = '';
                this.loadingItems = false;
            }
        });
        this._submissionsLoadPromise = loadPromise;

        await this._submissionsLoadPromise;
    }

    async fetchSubmissions(formIdentifier) {
        const response = await fetch(
            this.entryPointUrl +
                '/formalize/submissions?formIdentifier=' +
                formIdentifier +
                '&perPage=9999',
            {
                headers: {
                    'Content-Type': 'application/ld+json',
                    Authorization: 'Bearer ' + this.getToken(),
                },
            },
        );

        if (!response.ok) {
            this.handleErrorResponse(response);
            this.items = [];
            return;
        }

        const data = await response.json();
        if (this.activeForm?.identifier === formIdentifier) {
            this.items = data['hydra:member'] || [];
        }
    }

    getNormalizedRouteSegments() {
        const segments = [...this.getRoutingData().pathSegments];
        const basePathSegments = new URL(this.basePath || '/', window.location.origin).pathname
            .split('/')
            .filter((segment) => segment !== '');

        while (basePathSegments.length > 0 && segments[0] === basePathSegments[0]) {
            segments.shift();
            basePathSegments.shift();
        }

        if (this._i18n.languages.includes(segments[0])) {
            segments.shift();
        }

        const formSegmentIndex = segments.findIndex((segment) => {
            return this.itemFormEntries.some((entry) => entry.form.identifier === segment);
        });
        if (formSegmentIndex > 0) {
            return segments.slice(formSegmentIndex);
        }

        return segments;
    }

    async applyRoute() {
        if (!this._hasLoadedForms) {
            return;
        }

        const pathSegments = this.getNormalizedRouteSegments();
        const formIdentifier = pathSegments[0] || '';
        const actionOrItemIdentifier = pathSegments[1] || '';
        const editMarker = pathSegments[2] || '';

        if (!formIdentifier) {
            this.activeForm = null;
            this.activeModule = null;
            this.selectedItem = null;
            this.items = [];
            this.selectedItemCount = 0;
            this.mode = 'forms';
            return;
        }

        const entry = this.itemFormEntries.find((itemFormEntry) => {
            return itemFormEntry.form.identifier === formIdentifier;
        });

        if (!entry) {
            this.activeForm = null;
            this.activeModule = null;
            this.selectedItem = null;
            this.items = [];
            this.selectedItemCount = 0;
            this.mode = 'unknown-form';
            return;
        }

        const activeFormChanged = this.activeForm?.identifier !== entry.form.identifier;
        this.activeForm = entry.form;
        this.activeModule = entry.module;

        if (activeFormChanged || this.items.length === 0) {
            this.selectedItemCount = 0;
            await this.loadSubmissions();
        }

        if (actionOrItemIdentifier === 'create') {
            this.selectedItem = null;
            this.mode = 'edit';
            return;
        }

        if (actionOrItemIdentifier && editMarker === 'edit') {
            this.selectedItem =
                this.items.find((item) => item.identifier === actionOrItemIdentifier) || null;
            this.mode = this.selectedItem ? 'edit' : 'unknown-item';
            return;
        }

        this.selectedItem = null;
        this.mode = 'list';
    }

    setRoute(path) {
        this.sendSetPropertyEvent('routing-url', path, true);
    }

    openForm(entry) {
        this.setRoute(`/${entry.form.identifier}`);
    }

    /**
     * @param {{form: {identifier: string}} | null} [entry]
     */
    createItem(entry = null) {
        const form = entry?.form || this.activeForm;
        if (!form) {
            return;
        }

        this.setRoute(`/${form.identifier}/create`);
    }

    editItem(item) {
        if (!this.activeForm) {
            return;
        }

        this.setRoute(`/${this.activeForm.identifier}/${item.identifier}/edit`);
    }

    cancelEdit() {
        if (!this.activeForm) {
            this.setRoute('/');
            return;
        }

        this.setRoute(`/${this.activeForm.identifier}`);
    }

    async saveItem() {
        const formElement = this.formRef.value?.shadowRoot?.querySelector('form');
        if (!formElement) {
            return;
        }

        const isValid = await validateRequiredFields(formElement);
        if (!isValid) {
            const form = /** @type {import('./form/base-object.js').BaseFormElement | null} */ (
                this.formRef.value
            );
            form?.scrollToFirstInvalidField(formElement, true);
            sendNotification({
                summary: this._i18n.t('errors.warning-title'),
                body: this._i18n.t('errors.form-validation-warning-notification-body'),
                type: 'warning',
                timeout: 5,
                targetNotificationId: editModalNotificationId,
            });
            return;
        }

        this.saving = true;
        const formData = new FormData();
        formData.append('dataFeedElement', JSON.stringify(gatherFormDataFromElement(formElement)));

        if (!this.selectedItem) {
            formData.append('form', '/formalize/forms/' + this.activeForm.identifier);
            formData.append('submissionState', String(SUBMISSION_STATES_BINARY.SUBMITTED));
        }

        try {
            const response = await fetch(
                this.selectedItem
                    ? this.entryPointUrl + '/formalize/submissions/' + this.selectedItem.identifier
                    : this.entryPointUrl + '/formalize/submissions',
                {
                    method: this.selectedItem ? 'PATCH' : 'POST',
                    headers: {
                        Authorization: 'Bearer ' + this.getToken(),
                    },
                    body: formData,
                },
            );

            if (!response.ok) {
                this.handleErrorResponse(response, editModalNotificationId);
                return;
            }

            try {
                await response.json();
            } catch {
                // The API may return an empty body for PATCH responses.
            }

            sendNotification({
                summary: this._i18n.t('success.success-title'),
                body: this._i18n.t(
                    this.selectedItem ? 'manage-fields.item-updated' : 'manage-fields.item-created',
                ),
                type: 'success',
                timeout: 5,
            });

            await this.loadSubmissions();
            this.setRoute(`/${this.activeForm.identifier}`);
        } finally {
            this.saving = false;
        }
    }

    async deleteItem(item) {
        await this.deleteItems([item]);
    }

    async deleteItems(items) {
        if (!items || items.length === 0) {
            sendNotification({
                summary: this._i18n.t('errors.warning-title'),
                body: this._i18n.t('errors.no-submission-selected'),
                type: 'warning',
                timeout: 10,
            });
            return;
        }

        /** @type {DeletionConfirmationModal | null} */
        const confirmationModal = this.renderRoot.querySelector(
            'dbp-formalize-deletion-confirmation-modal',
        );
        const confirmed = await confirmationModal?.confirm();
        if (!confirmed) {
            return;
        }

        this.deletingItems = true;
        let deletedCount = 0;
        try {
            for (const item of items) {
                const response = await fetch(
                    this.entryPointUrl + '/formalize/submissions/' + item.identifier,
                    {
                        method: 'DELETE',
                        headers: {
                            Authorization: 'Bearer ' + this.getToken(),
                        },
                    },
                );

                if (!response.ok) {
                    this.handleErrorResponse(response);
                    continue;
                }

                deletedCount++;
            }
        } finally {
            this.deletingItems = false;
        }

        if (deletedCount === 0) {
            return;
        }

        sendNotification({
            summary: this._i18n.t('success.success-title'),
            body: this._i18n.t('manage-fields.items-deleted', {count: deletedCount}),
            type: 'success',
            timeout: 5,
        });
        await this.loadSubmissions();
        this.selectedItemCount = 0;
        this.setRoute(`/${this.activeForm.identifier}`);
    }

    async deleteSelectedItems() {
        /** @type {CustomTabulatorTable | null} */
        const table = this.renderRoot?.querySelector('#manage-fields-item-table');
        const selectedData = table?.tabulatorTable?.getSelectedData() || [];
        await this.deleteItems(selectedData.map((row) => row.item).filter(Boolean));
    }

    async deleteAllItems() {
        await this.deleteItems([...this.items]);
    }

    getItemTitle(item) {
        const data = parseSubmissionData(item);
        return (
            this.activeModule?.getItemText?.(data, this.lang) ||
            this._i18n.t('manage-fields.unnamed-item')
        );
    }

    getLastModifiedByIdentifier(item) {
        const lastModifiedBy = item?.lastModifiedBy || item?.lastModifiedByPerson || null;
        const identifier =
            item?.lastModifiedById || lastModifiedBy?.identifier || lastModifiedBy?.['@id'] || '';

        return `${identifier}`.trim();
    }

    async loadLastModifiedByName(item = this.selectedItem) {
        const identifier = this.getLastModifiedByIdentifier(item);
        this.lastModifiedByName = '';

        if (!identifier || !this.auth?.token || this._failedUserNameLookups.has(identifier)) {
            return;
        }

        if (this._userNameCache.has(identifier)) {
            this.lastModifiedByName = this._userNameCache.get(identifier);
            return;
        }

        try {
            const response = await this.apiGetUserDetails(identifier);
            if (!response.ok) {
                this._failedUserNameLookups.add(identifier);
                return;
            }

            const user = await response.json();
            const fullName =
                [user?.givenName, user?.familyName].filter(Boolean).join(' ') || user?.name || '';
            this._userNameCache.set(identifier, fullName);

            if (this.getLastModifiedByIdentifier(this.selectedItem) === identifier) {
                this.lastModifiedByName = fullName;
            }
        } catch (error) {
            console.error('Failed to load last modifier details:', error);
        }
    }

    getLastModifiedByName(item) {
        const lastModifiedBy = item?.lastModifiedBy || item?.lastModifiedByPerson || null;

        return (
            item?.lastModifiedByName ||
            lastModifiedBy?.name ||
            [lastModifiedBy?.givenName, lastModifiedBy?.familyName].filter(Boolean).join(' ') ||
            this.lastModifiedByName
        );
    }

    renderEditMetadata() {
        const i18n = this._i18n;
        const item = this.selectedItem || {};
        const emptyValue = html`
            —
        `;
        const metadata = [
            {
                label: i18n.t('render-form.forms.base-object.submission-creation-date-label'),
                value: formatDate(item.dateCreated),
            },
            {
                label: i18n.t('render-form.forms.base-object.last-modified-date-label'),
                value: formatDate(item.dateLastModified),
            },
            {
                label: i18n.t('render-form.forms.base-object.last-modified-by-name-label'),
                value: this.getLastModifiedByName(item),
            },
        ];

        return html`
            <div class="edit-metadata">
                ${metadata.map(
                    (entry) => html`
                        <div>
                            <span class="label metadata-label">${entry.label}:</span>
                            <span class="value">${entry.value || emptyValue}</span>
                        </div>
                    `,
                )}
            </div>
        `;
    }

    getFormTableData() {
        return this.itemFormEntries.map((entry) => ({
            name: getLocalizedFormName(entry.form, this.lang),
            entry,
        }));
    }

    getItemTableData() {
        return this.items.map((item) => ({
            title: this.getItemTitle(item),
            dateCreated: item.dateCreated || '',
            item,
        }));
    }

    createTableActionButton(iconName, title, onClick, ariaLabel = title) {
        const button = this.createScopedElement('dbp-icon-button');
        button.setAttribute('subscribe', 'lang');
        button.setAttribute('icon-name', iconName);
        // The title stays short as a tooltip, while the aria-label additionally names the row
        // so screen reader users can tell the repeated action buttons apart.
        button.title = title;
        button.setAttribute('aria-label', ariaLabel);
        button.addEventListener('click', (event) => {
            event.stopPropagation();
            onClick();
        });

        return button;
    }

    createTableActions(buttons) {
        const actions = document.createElement('div');
        actions.style.display = 'flex';
        actions.style.flexWrap = 'wrap';
        actions.style.gap = '0.5rem';
        actions.style.alignItems = 'center';
        actions.style.justifyContent = 'flex-end';
        buttons.forEach((button) => actions.append(button));
        return actions;
    }

    createFormActions(entry) {
        const i18n = this._i18n;
        const formName = getLocalizedFormName(entry.form, this.lang);
        const openButton = this.createScopedElement('dbp-formalize-get-details-button');
        openButton.setAttribute('subscribe', 'lang');
        openButton.title = i18n.t('manage-fields.open-form');
        openButton.ariaLabel = i18n.t('manage-fields.open-form-aria', {formName});
        openButton.addEventListener('click', (event) => {
            event.stopPropagation();
            this.openForm(entry);
        });

        return this.createTableActions([openButton]);
    }

    createItemActions(item) {
        const i18n = this._i18n;
        const itemName = this.getItemTitle(item);
        return this.createTableActions([
            this.createTableActionButton(
                'pencil',
                i18n.t('manage-fields.edit-item'),
                () => this.editItem(item),
                i18n.t('manage-fields.edit-item-aria', {itemName}),
            ),
        ]);
    }

    getFormTableOptions() {
        const i18n = this._i18n;
        const langsForms = {
            en: {
                columns: {
                    name: i18n.t('manage-fields.form', {lng: 'en'}),
                },
            },
            de: {
                columns: {
                    name: i18n.t('manage-fields.form', {lng: 'de'}),
                },
            },
        };

        return {
            langs: langsForms,
            data: this.getFormTableData(),
            layout: 'fitColumns',
            rowHeight: 64,
            columnDefaults: {
                vertAlign: 'middle',
                hozAlign: 'left',
                resizable: false,
            },
            placeholder: i18n.t('manage-fields.no-form'),
            columns: [
                {field: 'name', sorter: 'string', minWidth: 150, widthGrow: 1},
                {
                    field: 'actions',
                    headerSort: false,
                    hozAlign: 'right',
                    formatter: (cell) => this.createFormActions(cell.getRow().getData().entry),
                    width: 100,
                    widthGrow: 0,
                    widthShrink: 0,
                },
            ],
        };
    }

    getInitialItemColumns() {
        const i18n = this._i18n;

        return [
            {
                field: 'title',
                title: i18n.t('manage-fields.item'),
                sorter: 'string',
                minWidth: 150,
                widthGrow: 1,
                visible: true,
                frozen: false,
            },
            {
                field: 'dateCreated',
                title: i18n.t('manage-fields.date-created'),
                sorter: 'string',
                width: 180,
                widthGrow: 0,
                widthShrink: 0,
                visible: true,
                frozen: false,
            },
        ];
    }

    getActionsColumnDefinition() {
        return {
            field: 'actions',
            headerSort: false,
            hozAlign: 'right',
            headerHozAlign: 'right',
            width: 100,
            widthGrow: 0,
            widthShrink: 0,
            formatter: (cell) => this.createItemActions(cell.getRow().getData().item),
        };
    }

    getItemTableOptions() {
        const i18n = this._i18n;

        const langsItems = {
            en: {
                columns: {
                    title: i18n.t('manage-fields.item', {lng: 'en'}),
                    dateCreated: i18n.t('manage-fields.date-created', {lng: 'en'}),
                },
            },
            de: {
                columns: {
                    title: i18n.t('manage-fields.item', {lng: 'de'}),
                    dateCreated: i18n.t('manage-fields.date-created', {lng: 'de'}),
                },
            },
        };

        return {
            langs: langsItems,
            data: this.getItemTableData(),
            layout: 'fitColumns',
            rowHeight: 64,
            columnDefaults: {
                vertAlign: 'middle',
                hozAlign: 'left',
                resizable: false,
            },
            placeholder: i18n.t('manage-fields.no-items'),
            selectableRows: true,
            rowHeader: {
                formatter: 'rowSelection',
                titleFormatter: 'rowSelection',
                titleFormatterParams: {rowRange: 'visible'},
                headerSort: false,
                resizable: false,
                frozen: true,
                width: 40,
                minWidth: 40,
                widthGrow: 0,
                widthShrink: 0,
                headerHozAlign: 'center',
                hozAlign: 'center',
            },
            columns: [
                ...this.getInitialItemColumns().map(({title, ...column}) => column),
                this.getActionsColumnDefinition(),
            ],
        };
    }

    getItemActionOptions() {
        const i18n = this._i18n;

        if (this.selectedItemCount > 0) {
            return [
                {
                    value: 'delete-selected',
                    label: i18n.t('manage-fields.delete-selected-items', {
                        n: this.selectedItemCount,
                    }),
                    iconName: 'delete-selection',
                },
            ];
        }

        return [
            {
                value: 'delete-all',
                label: i18n.t('manage-fields.delete-all-items', {n: this.items.length}),
                iconName: 'trash',
            },
        ];
    }

    handleItemAction(event) {
        const option = event.detail?.option;
        if (!option) {
            return;
        }

        if (option.value === 'delete-selected') {
            void this.deleteSelectedItems();
            return;
        }

        if (option.value === 'delete-all') {
            void this.deleteAllItems();
        }
    }

    handleItemSelectionCountChanged(event) {
        this.selectedItemCount = event.detail?.count ?? 0;
    }

    /** @returns {CustomTabulatorTable | null} */
    getItemTable() {
        return /** @type {CustomTabulatorTable | null} */ (
            this.renderRoot?.querySelector('#manage-fields-item-table') ?? null
        );
    }

    /** @returns {HTMLInputElement | null} */
    getItemSearchbar() {
        return /** @type {HTMLInputElement | null} */ (
            this.renderRoot?.querySelector('#manage-fields-item-searchbar') ?? null
        );
    }

    handleItemSearch(event) {
        event?.preventDefault();

        const searchInput = this.getItemSearchbar();
        if (!searchInput) return;

        this.applyItemSearch(searchInput.value.trim());
    }

    handleItemSearchInput(event) {
        const target = /** @type {HTMLInputElement | null} */ (event.target);
        if (!target) return;
        this.applyItemSearch(target.value.trim());
    }

    applyItemSearch(filterValue) {
        const table = this.getItemTable();
        if (!table) return;

        if (filterValue === '') {
            table.clearFilter();
            return;
        }

        const filters = this.getInitialItemColumns()
            .filter((column) => column.field && column.visible !== false)
            .map((column) => ({field: column.field, type: 'like', value: filterValue}));

        table.setFilter([filters]);
    }

    handleResetItemSearch() {
        const searchInput = this.getItemSearchbar();
        const table = this.getItemTable();
        if (!searchInput || !table) return;

        searchInput.value = '';
        table.clearFilter();
        searchInput.focus();
    }

    async syncTabulatorTable(selector, options) {
        const table = this.renderRoot?.querySelector(selector);
        if (!table) {
            return;
        }

        table.options = options;
        table.data = options.data;

        if (!table.tabulatorTable) {
            await table.updateComplete;
            if (!table.tabulatorTable && !table.tableBuilding) {
                table.buildTable();
            }
            return;
        }

        table.tabulatorTable.setLocale(this.lang);
        table.tabulatorTable.replaceData(options.data);
    }

    getFormHtml() {
        if (!this.activeModule || !this.activeForm) {
            return html``;
        }

        const tagName = 'dbp-formalize-item-' + this.activeModule.getUrlSlug();
        this.defineScopedElement(tagName, this.activeModule.getFormComponent());

        const formData = this.selectedItem ? parseSubmissionData(this.selectedItem) : {};

        return staticHtml`
            <${unsafeStatic(tagName)}
                ${ref(this.formRef)}
                subscribe="auth,lang,entry-point-url"
                form-identifier=${this.activeForm.identifier}
                form-url-slug=${this.activeModule.getUrlSlug()}
                .formProperties=${this.activeForm}
                .formData=${formData}></${unsafeStatic(tagName)}>
        `;
    }

    getColumnConfigurationStorageKey(scope) {
        const userId = this.auth?.['user-id'];
        return this.isLoggedIn() && userId ? `formalize-manage-fields-${scope}-${userId}` : '';
    }

    getPaginationSizeStorageKey() {
        const userId = this.auth?.['user-id'];
        return this.isLoggedIn() && userId ? `formalize-manage-fields-${userId}` : '';
    }

    renderFormOverview() {
        const i18n = this._i18n;

        if (this.itemFormEntries.length === 0) {
            return html`
                <div class="notification is-warning">${i18n.t('manage-fields.no-form')}</div>
            `;
        }

        return html`
            <dbp-tabulator-table
                lang="${this.lang}"
                class="tabulator-table"
                id="manage-fields-form-table"
                identifier="manage-fields-form-table"
                pagination-enabled
                pagination-size="5"
                .paginationSizeStorageKey=${this.getPaginationSizeStorageKey()}
                .options=${this.getFormTableOptions()}></dbp-tabulator-table>
        `;
    }

    renderList() {
        const i18n = this._i18n;

        return html`
            <div class="active-form-wrapper">
                <div class="active-form-top-bar">
                    <span class="back-navigation">
                        <button
                            type="button"
                            @click=${() => this.setRoute('/')}
                            title="${i18n.t('manage-fields.back-to-forms')}">
                            <dbp-icon name="chevron-left"></dbp-icon>
                            ${i18n.t('manage-fields.back-to-forms')}
                        </button>
                    </span>
                </div>
                <div class="active-form-header">
                    <h2 class="form-name">${getLocalizedFormName(this.activeForm, this.lang)}</h2>
                    <button class="button is-primary" @click=${() => this.createItem()}>
                        <dbp-icon name="plus" aria-hidden="true"></dbp-icon>
                        ${i18n.t('manage-fields.create-item')}
                    </button>
                </div>
            </div>

            ${
                this.loadingItems
                    ? html`
                          <dbp-mini-spinner text="${i18n.t('loading-message')}"></dbp-mini-spinner>
                      `
                    : this.items.length === 0
                      ? html`
                            <p class="empty-state">${i18n.t('manage-fields.no-items')}</p>
                        `
                      : html`
                            <div class="items-action-header">
                                <dbp-select
                                    ?disabled=${this.deletingItems}
                                    @change=${(event) => this.handleItemAction(event)}
                                    label="${i18n.t('manage-fields.actions-button-text')}"
                                    align="left"
                                    allow-expand
                                    .options=${this.getItemActionOptions()}></dbp-select>
                                <form
                                    class="search-input items-search search-field"
                                    @submit=${this.handleItemSearch}>
                                    <label
                                        for="manage-fields-item-searchbar"
                                        class="label visually-hidden">
                                        ${i18n.t('manage-fields.search-input-label')}:
                                    </label>
                                    <div class="control search-control">
                                        <input
                                            type="text"
                                            id="manage-fields-item-searchbar"
                                            class="input searchbar"
                                            placeholder="${i18n.t(
                                                'manage-fields.searchbar-placeholder',
                                            )}"
                                            aria-label="${i18n.t(
                                                'manage-fields.search-input-label',
                                            )}"
                                            @input=${this.handleItemSearchInput} />
                                        <span class="search-icon" aria-hidden="true">
                                            <dbp-icon name="search"></dbp-icon>
                                        </span>
                                    </div>
                                    <button
                                        type="submit"
                                        class="button search-button"
                                        title="${i18n.t('manage-fields.search-button')}"
                                        aria-label="${i18n.t('manage-fields.search-button')}"
                                        style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
                                        <dbp-icon name="search" aria-hidden="true"></dbp-icon>
                                    </button>
                                </form>
                                <button
                                    type="button"
                                    class="reset-search"
                                    @click=${this.handleResetItemSearch}>
                                    <dbp-icon name="spinner-arrow" aria-hidden="true"></dbp-icon>
                                    ${i18n.t('manage-fields.reset-search-label')}
                                </button>
                            </div>
                            <dbp-tabulator-table
                                lang="${this.lang}"
                                class="tabulator-table"
                                id="manage-fields-item-table"
                                identifier="manage-fields-item-table"
                                pagination-enabled
                                pagination-size="5"
                                .paginationSizeStorageKey=${this.getPaginationSizeStorageKey()}
                                select-rows-enabled
                                column-configuration-enabled
                                column-configuration-in-header
                                .columnConfigurationExcludedFields=${['actions']}
                                .columnConfigurationStorageKey=${this.getColumnConfigurationStorageKey(
                                    `items-${this.activeForm?.identifier || 'form'}`,
                                )}
                                @dbp-tabulator-table-selection-count-changed=${(event) =>
                                    this.handleItemSelectionCountChanged(event)}
                                .options=${this.getItemTableOptions()}></dbp-tabulator-table>
                        `
            }
        `;
    }

    renderEdit() {
        const i18n = this._i18n;
        const formName =
            this.activeModule?.getFormName?.(this.lang) ||
            getLocalizedFormName(this.activeForm, this.lang) ||
            i18n.t('manage-fields.item');
        const titleName = getSentenceCaseItemName(formName, this.lang);
        const itemName = this.selectedItem ? this.getItemTitle(this.selectedItem) : '';
        const titleKey = this.selectedItem
            ? 'manage-fields.edit-item-title-with-name'
            : 'manage-fields.create-item-title';

        return html`
            <dbp-modal
                id="manage-fields-edit-modal"
                modal-id="manage-fields-edit-modal"
                subscribe="lang"
                class="modal-width manage-fields-edit-modal"
                sticky-footer
                @dbp-modal-closed=${() => {
                    if (this.mode === 'edit') this.cancelEdit();
                }}>
                <div slot="title">
                    <h2 class="edit-title">${i18n.t(titleKey, {name: titleName, itemName})}</h2>
                </div>
                <div slot="header">
                    <dbp-notification
                        id=${editModalNotificationId}
                        lang=${this.lang}></dbp-notification>
                    ${this.renderEditMetadata()}
                </div>
                <div slot="content">${this.mode === 'edit' ? this.getFormHtml() : ''}</div>
                <div slot="footer" class="edit-actions">
                    <dbp-button
                        type="is-secondary"
                        no-spinner-on-click
                        @click=${() =>
                            /** @type {Modal | null} */ (
                                this.renderRoot?.querySelector('#manage-fields-edit-modal')
                            )?.close()}>
                        <dbp-icon name="close" aria-hidden="true"></dbp-icon>
                        <span class="button-label">${i18n.t('manage-fields.cancel')}</span>
                    </dbp-button>
                    <dbp-button
                        type="is-primary"
                        ?disabled=${this.saving}
                        no-spinner-on-click
                        @click=${() => this.saveItem()}>
                        <dbp-icon name="save" aria-hidden="true"></dbp-icon>
                        <span class="button-label">${i18n.t('manage-fields.save-item')}</span>
                    </dbp-button>
                </div>
            </dbp-modal>
        `;
    }

    renderContent() {
        if (this.mode === 'forms') {
            return this.renderFormOverview();
        }

        if (this.mode === 'unknown-form') {
            return html`
                <div class="notification is-warning">
                    ${this._i18n.t('manage-fields.unknown-form')}
                </div>
                ${this.renderFormOverview()}
            `;
        }

        if (this.mode === 'unknown-item') {
            return html`
                <div class="notification is-warning">
                    ${this._i18n.t('manage-fields.unknown-item')}
                </div>
                ${this.renderList()}
            `;
        }

        return this.renderList();
    }

    render() {
        const i18n = this._i18n;

        if (!this.isLoggedIn() && !this.isAuthPending()) {
            return html`
                <div class="notification is-warning">
                    ${i18n.t('error-login-message')}
                    <a
                        href="#"
                        @click=${(event) => {
                            event.preventDefault();
                            this.sendSetPropertyEvent('requested-login-status', 'logged-in');
                        }}>
                        ${i18n.t('error-login-link')}
                    </a>
                </div>
            `;
        }

        if ((this.loading && !this._hasLoadedForms) || this.isAuthPending()) {
            return html`
                <dbp-mini-spinner text="${i18n.t('loading-message')}"></dbp-mini-spinner>
            `;
        }

        if (this.errorMessage) {
            return html`
                <div class="notification is-danger">${this.errorMessage}</div>
            `;
        }

        if (this.itemModules.length === 0) {
            return html`
                <div class="notification is-warning">${i18n.t('manage-fields.no-module')}</div>
            `;
        }

        return html`
            <dbp-formalize-deletion-confirmation-modal
                subscribe="lang"
                message-key="manage-fields.delete-confirmation-message"
                message-li2-key="manage-fields.delete-confirmation-message-li2"></dbp-formalize-deletion-confirmation-modal>
            <section class="manage-fields">
                ${
                    this.mode === 'list' || this.mode === 'unknown-item'
                        ? ''
                        : html`
                              <h2>${i18n.t('manage-fields.title')}</h2>
                          `
                }
                ${this.renderContent()} ${this.activeForm ? this.renderEdit() : ''}
            </section>
        `;
    }

    update(changedProperties) {
        super.update(changedProperties);

        const oldAuth = changedProperties.get('auth');
        const oldToken = oldAuth?.token || '';
        const newToken = this.auth?.token || '';
        const gainedToken = !oldToken && newToken;
        const itemFrontendKeysChanged =
            changedProperties.has('itemFrontendKeys') &&
            this._lastFrontendKeys !== this.itemFrontendKeys;

        if (((gainedToken && !this._hasLoadedForms) || itemFrontendKeysChanged) && newToken) {
            void this.loadItemForms();
            return;
        }

        if (changedProperties.has('routingUrl') && this._hasLoadedForms) {
            this._routeApplyPromise = this._routeApplyPromise.then(() => this.applyRoute());
        }
    }

    updated(changedProperties) {
        super.updated?.(changedProperties);

        const needFormTableSync =
            changedProperties.has('itemFormEntries') ||
            changedProperties.has('lang') ||
            changedProperties.has('mode');

        const needItemTableSync =
            changedProperties.has('items') ||
            changedProperties.has('lang') ||
            changedProperties.has('mode');

        if (needFormTableSync) {
            void this.syncTabulatorTable('#manage-fields-form-table', this.getFormTableOptions());
        }

        if (needItemTableSync) {
            void this.syncTabulatorTable('#manage-fields-item-table', this.getItemTableOptions());
        }

        if (changedProperties.has('selectedItem') || changedProperties.has('auth')) {
            void this.loadLastModifiedByName();
        }

        if (changedProperties.has('mode') && this.activeForm) {
            /** @type {Modal | null} */
            const modal = this.renderRoot?.querySelector('#manage-fields-edit-modal');
            if (this.mode === 'edit') {
                void modal?.updateComplete.then(() => {
                    if (this.mode === 'edit' && modal.isConnected) modal.open();
                });
            } else if (modal?.isOpen()) {
                modal.close();
            }
        }
    }

    static get styles() {
        return css`
            ${commonStyles.getThemeCSS()}
            ${commonStyles.getGeneralCSS(false)}
            ${commonStyles.getButtonCSS()}
            ${commonStyles.getNotificationCSS()}

            :host {
                display: block;
            }

            .manage-fields {
                display: grid;
                gap: 1rem;
            }

            .manage-fields h2 {
                font-weight: 300;
                margin: 0;
            }

            .active-form-header {
                display: block;
            }

            .active-form-header .button {
                display: inline-flex;
                width: auto;
                gap: 0.25rem;
            }

            .active-form-top-bar {
                align-items: center;
                display: flex;
                flex-wrap: wrap;
                gap: 1rem;
                justify-content: space-between;
                padding-top: 1rem;
            }

            .back-navigation dbp-icon {
                font-size: 0.8em;
                padding-bottom: 2px;
                padding-right: 7px;
            }

            .back-navigation:hover {
                background-color: var(--dbp-hover-background-color);
                color: var(--dbp-hover-color, var(--dbp-content));
            }

            .back-navigation:hover::before {
                background-color: var(--dbp-hover-color, var(--dbp-content));
            }

            .back-navigation button {
                border: 0;
                padding: 0;
                background: none;
                color: inherit;
                font: inherit;
            }

            .active-form-header h2,
            .edit-title {
                margin: 0;
            }

            .active-form-header .form-name {
                margin-top: 0.5em;
                margin-bottom: 1em;
            }

            .edit-metadata {
                display: grid;
                gap: 0.25rem;
                margin: 0.5rem 0;
            }

            .edit-metadata > div {
                align-items: baseline;
                display: flex;
                gap: 0.25rem;
            }

            .edit-metadata .label {
                font-weight: bold;
            }
            .metadata-label {
                margin: 0;
            }

            .edit-actions {
                display: flex;
                flex-wrap: wrap;
                gap: 0.75rem;
                justify-content: flex-end;
                padding: 1em 0;
            }

            .tabulator-table {
                width: 100%;
            }

            #manage-fields-form-table,
            #manage-fields-item-table {
                display: block;
                max-width: 100%;
                min-width: 0;
            }

            .forms-table-actions,
            .items-action-header {
                display: flex;
                align-items: center;
                gap: 1rem;
                margin-bottom: 0.5rem;
            }

            .forms-search,
            .items-search {
                flex: 1;
                min-width: 12rem;
            }

            .forms-search label,
            .items-search label {
                clip: rect(0 0 0 0);
                clip-path: inset(50%);
                height: 1px;
                overflow: hidden;
                position: absolute;
                white-space: nowrap;
                width: 1px;
            }

            .reset-search {
                cursor: pointer;
                background: none;
                border: 0 none;
                padding: 5px;
                transform: translateX(5px);
            }

            .reset-search:disabled {
                cursor: not-allowed;
            }

            .reset-search dbp-icon {
                transition: transform 250ms ease-in;
            }

            .reset-search:hover:not(:disabled) dbp-icon {
                transform: rotate(360deg);
            }

            @media (max-width: 530px) {
                .forms-table-actions,
                .items-action-header {
                    align-items: stretch;
                    flex-wrap: wrap;
                    justify-content: space-between;
                }

                .items-action-header dbp-select {
                    order: 1;
                }

                .items-action-header .reset-search {
                    order: 2;
                 }

                .forms-search,
                .items-search {
                    flex-basis: 100%;
                }
            }

            .create-form-btn {
                display: inline-flex;
                align-items: center;
                gap: 0.4rem;
            }

            .create-form-btn-icon {
                flex-shrink: 0;
                top: 0;
            }

            .hidden {
                display: none;
            }

            /* 1:1 aus dbp-bulletin-view-job-offers.js:1522-1552 – overlay icon statt separatem button */
            .search-field {
                margin-bottom: 0;
            }

            .search-control {
                position: relative;
                flex: 1;
            }

            .search-control .input {
                padding-right: 2.5rem;
                width: 100%;
            }

            .search-control .input.searchbar {
                /* überschreibt .searchbar aus styles.js (border-right:0) für job-offers look */
                border: 1px solid var(--dbp-content);
                border-right: 1px solid var(--dbp-content);
                padding: 0 2.5rem 0 0.5em;
                height: 32px;
                box-sizing: border-box;
            }

            .search-icon {
                position: absolute;
                right: 0.75rem;
                top: 50%;
                transform: translateY(-50%);
                color: var(--dbp-muted);
                pointer-events: none;
            }

            .visually-hidden {
                clip: rect(0 0 0 0);
                clip-path: inset(50%);
                height: 1px;
                overflow: hidden;
                position: absolute;
                white-space: nowrap;
                width: 1px;
            }

            .modal-width {
                --dbp-modal-min-width: min(95vw, 900px);
                --dbp-modal-max-width: min(95vw, 900px);
                --dbp-modal-max-height: 90vh;
                --dbp-modal-content-overflow-y: auto;
            }

            .manage-fields-edit-modal {
                --dbp-label-margin-bottom: 3px;
            }
        }

            .empty-state {
                color: var(--dbp-muted);
            }

            @media only screen and (orientation: portrait) and (max-width: 768px) {
                .back-navigation {
                    padding-top: 0;
                }

                .active-form-top-bar {
                    padding-top: 0;
                }

                .active-form-header .form-name {
                    margin-bottom: 0.5em;
                }
            }

            @media (max-width: 560px) {
                .modal-width {
                    --dbp-modal-min-width: unset;
                    --dbp-modal-max-width: unset;
                    --dbp-modal-max-height: 90vh;
                    --dbp-modal-content-overflow-y: auto;
                }
            }
        `;
    }
}

commonUtils.defineCustomElement('dbp-formalize-manage-fields', ManageFields);