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,403 lines • 54.1 kB
import {assert} from 'chai';
import {setFeatureFlag} from '@dbp-toolkit/common';

import '../src/dbp-formalize.js';
import {ManageForms} from '../src/dbp-formalize-manage-forms';
import {ManageFormsOverviewPage} from '../src/manage-forms-overview-page.js';
import {ManageFormSubmissionsPage} from '../src/manage-form-submissions-page.js';
import {filterAvailableForms} from '../src/dbp-formalize-render-form.js';
import {apiCreateForm, apiUpdateForm, getListOfAllForms} from '../src/manage-forms-api.js';
import {
    BaseFormElement,
    BaseObject,
    FILE_SECURITY_VALIDATION_ERROR_ID,
} from '../src/form/base-object.js';
import {
    AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG,
    isAvailableFormsOverviewEnabled,
} from '../src/feature-flags.js';
import {
    setDefaultSubmissionTableOrder,
    setSubmissionFormOptions,
} from '../src/manage-forms-table-config.js';
import {createDefaultManageFormsOverviewActions} from '../src/manage-forms-overview-actions.js';

customElements.define('test-manage-forms-overview-page', class extends ManageFormsOverviewPage {});
customElements.define('test-manage-forms', class extends ManageForms {});
customElements.define(
    'test-manage-form-submissions-page',
    class extends ManageFormSubmissionsPage {},
);
customElements.define('test-base-form-element', class extends BaseFormElement {});

suite('available forms feature flag', () => {
    teardown(() => {
        setFeatureFlag(AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG, false);
    });

    test('is disabled by default and can be enabled', () => {
        setFeatureFlag(AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG, false);
        assert.isFalse(isAvailableFormsOverviewEnabled());

        setFeatureFlag(AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG, true);
        assert.isTrue(isAvailableFormsOverviewEnabled());
    });

    test('controls whether the render-form menu entry is disabled', () => {
        const shell = document.createElement('dbp-formalize');
        shell.routes = ['render-form'];
        shell.metadata = {
            'render-form': {
                required_roles: [],
                visible: true,
                disabled: true,
                feature_flag: AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG,
            },
        };

        setFeatureFlag(AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG, false);
        shell._updateVisibleRoutes();
        assert.isTrue(shell.visibleRoutes[0].disabled);

        setFeatureFlag(AVAILABLE_FORMS_OVERVIEW_FEATURE_FLAG, true);
        shell._updateVisibleRoutes();
        assert.isFalse(shell.visibleRoutes[0].disabled);
    });
});

suite('available forms', () => {
    const formIdentifiers = {
        exam: 'exam-id',
        course: 'course-id',
    };

    test('only includes supported forms that the user can submit', () => {
        const forms = filterAvailableForms(
            [
                {
                    identifier: 'exam-id',
                    name: 'Exam',
                    localizedNames: [{languageTag: 'de', name: 'Prüfung'}],
                    grantedFormActions: ['create_submissions'],
                },
                {
                    identifier: 'course-id',
                    name: 'Course',
                    grantedFormActions: ['read'],
                },
                {
                    identifier: 'unsupported-id',
                    name: 'Unsupported',
                    grantedFormActions: ['manage'],
                },
            ],
            formIdentifiers,
            'de',
        );

        assert.deepEqual(forms, [
            {
                identifier: 'exam-id',
                name: 'Prüfung',
                slug: 'exam',
            },
        ]);
    });

    test('applies frontend key allow and deny lists', () => {
        const entries = [
            {
                identifier: 'exam-id',
                name: 'Exam',
                frontendKey: 'exam',
                grantedSubmissionCollectionActions: ['manage'],
            },
            {
                identifier: 'course-id',
                name: 'Course',
                frontendKey: 'course',
                grantedSubmissionCollectionActions: ['create_submissions'],
            },
        ];

        assert.deepEqual(filterAvailableForms(entries, formIdentifiers, 'en', ['exam'], []), [
            {identifier: 'exam-id', name: 'Exam', slug: 'exam'},
        ]);
        assert.deepEqual(filterAvailableForms(entries, formIdentifiers, 'en', [], ['exam']), [
            {identifier: 'course-id', name: 'Course', slug: 'course'},
        ]);
    });

    test('configures a sortable name and action column for the available forms table', () => {
        const node = document.createElement('dbp-formalize-render-form');
        node.availableForms = [{identifier: 'exam-id', name: 'Exam', slug: 'exam'}];

        const options = node.getAvailableFormsTableOptions();

        assert.equal(options.data, node.availableForms);
        assert.deepInclude(options.columns[0], {
            field: 'name',
            sorter: 'string',
        });
        assert.deepInclude(options.columns[3], {
            field: 'actionButton',
            formatter: 'html',
            headerSort: false,
        });
    });

    test('opens a form from its table action', () => {
        const node = document.createElement('dbp-formalize-render-form');
        const calls = [];
        node.sendSetPropertyEvent = (...args) => calls.push(args);
        node.createScopedElement = () => document.createElement('button');

        const action = node.createAvailableFormAction({slug: 'exam'});
        action.firstElementChild.dispatchEvent(new Event('click'));

        assert.deepEqual(calls, [['routing-url', '/exam', true]]);
    });

    test('searches and resets the available forms table', () => {
        const node = document.createElement('dbp-formalize-render-form');
        const calls = [];
        const input = {value: '  exam  ', focus: () => calls.push(['focus'])};
        const table = {
            setFilter: (...args) => calls.push(['setFilter', ...args]),
            clearFilter: () => calls.push(['clearFilter']),
        };
        node.getAvailableFormsSearchInput = () => input;
        node.getAvailableFormsTable = () => table;

        node.handleAvailableFormsSearch({preventDefault: () => calls.push(['preventDefault'])});
        node.handleAvailableFormsSearchReset();

        assert.deepEqual(calls, [
            ['preventDefault'],
            ['setFilter', [[{field: 'name', type: 'like', value: 'exam'}]]],
            ['clearFilter'],
            ['focus'],
        ]);
        assert.equal(input.value, '');
    });

    test('explicitly builds the table after it is rendered', async () => {
        const node = document.createElement('dbp-formalize-render-form');
        const calls = [];
        const table = {
            updateComplete: Promise.resolve(),
            options: null,
            data: [],
            tabulatorTable: null,
            tableReady: false,
            tableBuilding: false,
            buildTable: () => calls.push('build'),
        };
        node.availableForms = [{name: 'Exam'}];
        node.availableFormsTableOptions = {data: node.availableForms};
        node.getAvailableFormsTable = () => table;

        node.rebuildAvailableFormsTable();
        await table.updateComplete;

        assert.equal(table.options, node.availableFormsTableOptions);
        assert.equal(table.data, node.availableForms);
        assert.deepEqual(calls, ['build']);
    });
});

suite('submission error handling', () => {
    test('should show a localized message for rejected files', async () => {
        const element = document.createElement('test-base-form-element');
        await element._i18n.changeLanguage('en');
        let notificationDetail;
        const notificationHandler = (event) => {
            notificationDetail = event.detail;
            event.preventDefault();
        };
        window.addEventListener('dbp-notification-send', notificationHandler);

        try {
            await element.displayErrors({
                status: 400,
                json: () =>
                    Promise.resolve({
                        'relay:errorId': FILE_SECURITY_VALIDATION_ERROR_ID,
                        'relay:errorDetails': [
                            'clamav_check: Virus detected in application.pdf: test-signature',
                        ],
                    }),
            });
        } finally {
            window.removeEventListener('dbp-notification-send', notificationHandler);
        }

        assert.equal(notificationDetail?.type, 'danger');
        assert.equal(
            notificationDetail?.body,
            'One or more files were rejected by the security scan. Remove or replace them and try again.',
        );
        assert.notInclude(notificationDetail?.body, 'test-signature');
    });
});

suite('manage forms table configuration', () => {
    test('should only show the employer column for job-offer-only activities', () => {
        const node = document.createElement('test-manage-forms');

        node.allowListFrontendKeys = [];
        node.updateFormsTableOptions();
        assert.notInclude(
            node.options_forms.columns.map(({field}) => field),
            'employer',
        );
        assert.equal(node.options_forms.columns.find(({field}) => field === 'name').widthGrow, 4);

        node.allowListFrontendKeys = ['job-offer'];
        node.updateFormsTableOptions();
        assert.include(
            node.options_forms.columns.map(({field}) => field),
            'employer',
        );
        assert.equal(node.options_forms.columns.find(({field}) => field === 'name').widthGrow, 2);

        node.allowListFrontendKeys = ['job-offer', 'other-form'];
        node.updateFormsTableOptions();
        assert.notInclude(
            node.options_forms.columns.map(({field}) => field),
            'employer',
        );
    });

    test('should use module schema labels when persisted labels are missing', () => {
        const definitions = [
            {field: 'dateCreated', title: 'Date created'},
            {field: 'givenName', title: 'givenName'},
            {field: 'attachments', title: 'attachments'},
            {field: 'submissionId', title: 'submissionId'},
        ];
        const host = {
            activeFormId: 'job-offer',
            availableTags: [],
            forms: new Map([
                [
                    'job-offer',
                    {
                        dataFeedSchema: JSON.stringify({
                            properties: {givenName: {}, attachments: {}},
                            files: {attachments: {}},
                        }),
                        moduleInstance: {
                            getDataFeedSchema: () => ({
                                properties: {
                                    givenName: {
                                        localizedName: {de: 'Vorname', en: 'First name'},
                                    },
                                },
                                files: {
                                    attachments: {
                                        localizedName: {de: 'Anhänge', en: 'Attachments'},
                                    },
                                },
                            }),
                        },
                    },
                ],
            ]),
            lang: 'en',
            submissionTables: {
                submitted: {
                    getColumns: () =>
                        definitions.map((definition) => ({
                            getDefinition: () => definition,
                        })),
                },
            },
            submissionsColumnsInitial: {},
        };

        setDefaultSubmissionTableOrder(host, 'submitted');

        const columns = host.submissionsColumnsInitial.submitted;
        assert.equal(columns.find(({field}) => field === 'givenName').title, 'First name');
        assert.equal(columns.find(({field}) => field === 'attachments').title, 'Attachments');
        assert.equal(
            columns.find(({field}) => field === 'form_files-attachments').title,
            'Attachments',
        );
    });

    test('should localize the submission identifier column', () => {
        const host = {
            lang: 'de',
            _i18n: {t: (key) => ({'manage-forms.submission-id': 'Einreichungs-ID'})[key]},
            options_submissions: {},
        };
        setSubmissionFormOptions(host, 'submitted');
        const definitions = [{field: 'submissionId', title: 'submissionId'}];

        host.options_submissions.submitted.autoColumnsDefinitions(definitions);

        assert.equal(definitions[0].title, 'Einreichungs-ID');
    });
});

suite('dbp-formalize-manage-forms basics', () => {
    let node;

    suiteSetup(async () => {
        node = document.createElement('dbp-formalize-manage-forms');
        node.auth = {token: ''};
        node.refreshTableReferences = () => {};
        document.body.appendChild(node);
        await node.updateComplete;
    });

    suiteTeardown(() => {
        node.remove();
    });

    test('should render', () => {
        assert(!!node.shadowRoot);
    });

    test('should expose editing a form in the routing URL', () => {
        const calls = [];
        const dialog = {
            existingForm: null,
            open: () => calls.push(['open']),
        };
        const moduleInstance = {getEditFormComponent: () => document.createElement('div')};
        node.forms.set('job-offer', {
            formId: 'job-offer',
            formSlug: 'job-offer',
            formName: 'Job offer',
            moduleInstance,
            additionalData: {title: 'Job offer'},
            localizedNames: [],
        });
        node._ = () => dialog;
        node.sendSetPropertyEvent = (...args) => calls.push(args);

        node.handleOpenEditFormDialog('job-offer');

        assert.deepInclude(calls, ['routing-url', '/job-offer/edit', true]);
        assert.deepInclude(calls, ['open']);
        assert.equal(dialog.existingForm.formId, 'job-offer');
    });

    test('should preserve form-list parameters when returning to the overview', () => {
        const calls = [];
        node.getRoutingData = () => ({
            pathname: '/job-offer',
            pathSegments: ['job-offer'],
            queryParams: new URLSearchParams(
                'forms-search=cont&forms-page=2&draft-search=application',
            ),
        });
        node.clearAllFilters = () => {};
        node.closeAllSearchWidgets = () => {};
        node.showFormsOverview = () => {};
        node.sendSetPropertyEvent = (...args) => calls.push(args);

        node.handleBackToOverview();

        assert.deepEqual(calls, [['routing-url', '/?forms-search=cont&forms-page=2', true]]);
    });

    test('should open an edit URL instead of the submissions page', () => {
        const calls = [];
        const form = {formId: 'job-offer'};
        node.getRoutingData = () => ({pathSegments: ['job-offer', 'edit']});
        node._ = () => ({existingForm: null});
        node.showFormsOverview = () => calls.push('overview');
        node.handleOpenEditFormDialog = (formId) => calls.push(`edit:${formId}`);
        node.switchToSubmissionTable = () => calls.push('submissions');

        node.showRoutedForm(form);

        assert.deepEqual(calls, ['overview', 'edit:job-offer']);
    });

    test('should resolve enabled overview actions for one manageable form', () => {
        const form = {
            formId: 'job-offer',
            grantedActions: ['manage'],
        };
        const actionHost = {
            enableFormsBulkDelete: true,
            forms: new Map([
                [
                    'job-offer',
                    {
                        formId: 'job-offer',
                        moduleInstance: {
                            getEditFormComponent: () => document.createElement('div'),
                        },
                    },
                ],
            ]),
            formsGrantedActions: new Map(),
            selectedFormsCount: 0,
            formsOverviewActionDefinitions: createDefaultManageFormsOverviewActions().filter(
                (action) => action.placements.includes('dropdown'),
            ),
            formsOverviewDropdownActions: [],
            _i18n: {t: (key) => key},
            formsTable: {
                tabulatorTable: {
                    getSelectedRows: () => [{getData: () => form}],
                },
            },
        };
        node.setFormsActionButtonsState.call(actionHost);

        assert.equal(actionHost.selectedFormsCount, 1);
        assert.deepEqual(
            actionHost.formsOverviewDropdownActions.map(({value, disabled}) => ({value, disabled})),
            [
                {value: 'delete', disabled: false},
                {value: 'edit', disabled: false},
                {value: 'edit-permission', disabled: false},
            ],
        );
    });

    test('should disable form actions without the required grants', () => {
        const form = {formId: 'job-offer', grantedActions: ['read']};
        const actionHost = {
            enableFormsBulkDelete: true,
            forms: new Map([['job-offer', {moduleInstance: {getEditFormComponent: () => {}}}]]),
            formsGrantedActions: new Map(),
            selectedFormsCount: 0,
            formsOverviewActionDefinitions: createDefaultManageFormsOverviewActions().filter(
                (action) => action.placements.includes('dropdown'),
            ),
            formsOverviewDropdownActions: [],
            _i18n: {t: (key) => key},
            formsTable: {
                tabulatorTable: {
                    getSelectedRows: () => [{getData: () => form}],
                },
            },
        };

        node.setFormsActionButtonsState.call(actionHost);

        assert.isTrue(actionHost.formsOverviewDropdownActions.every((action) => action.disabled));
    });

    test('should execute a resolved overview action without action-specific dispatch code', () => {
        const form = {formId: 'job-offer'};
        let handledContext = null;
        const actionHost = {
            formsOverviewActionDefinitions: [
                {
                    id: 'custom-action',
                    placements: ['dropdown'],
                    label: 'Custom action',
                    handler: (context) => (handledContext = context),
                },
            ],
            forms: new Map([['job-offer', form]]),
            formsTable: {
                tabulatorTable: {
                    getSelectedData: () => [form],
                },
            },
            _i18n: {t: (key) => key},
        };

        node.handleFormsPageAction.call(actionHost, {
            detail: {action: 'custom-action'},
        });

        assert.equal(handledContext.form.formId, form.formId);
        assert.deepEqual(
            handledContext.items.map((item) => item.formId),
            [form.formId],
        );
    });

    test('should require manage on every form for multi-form permission editing', () => {
        const forms = [
            {formId: 'job-offer-1', grantedActions: ['manage']},
            {formId: 'job-offer-2', grantedActions: ['delete']},
        ];
        const actionHost = {
            enableFormsBulkDelete: true,
            formsGrantedActions: new Map(),
            selectedFormsCount: 0,
            formsOverviewActionDefinitions: createDefaultManageFormsOverviewActions().filter(
                (action) => action.placements.includes('dropdown'),
            ),
            formsOverviewDropdownActions: [],
            _i18n: {t: (key) => key},
            forms: new Map(forms.map((form) => [form.formId, form])),
            formsTable: {
                tabulatorTable: {
                    getSelectedRows: () => forms.map((form) => ({getData: () => form})),
                },
            },
        };

        node.setFormsActionButtonsState.call(actionHost);

        const actions = Object.fromEntries(
            actionHost.formsOverviewDropdownActions.map((action) => [action.value, action]),
        );
        assert.isFalse(actions.delete.disabled);
        assert.isTrue(actions.edit.disabled);
        assert.isTrue(actions['edit-permission'].disabled);
    });

    test('should disable deletion when form bulk deletion is not enabled', () => {
        const form = {formId: 'job-offer', grantedActions: ['manage']};
        const actionHost = {
            enableFormsBulkDelete: false,
            forms: new Map([['job-offer', {moduleInstance: {getEditFormComponent: () => {}}}]]),
            formsGrantedActions: new Map(),
            selectedFormsCount: 0,
            formsOverviewActionDefinitions: createDefaultManageFormsOverviewActions().filter(
                (action) => action.placements.includes('dropdown'),
            ),
            formsOverviewDropdownActions: [],
            _i18n: {t: (key) => key},
            formsTable: {
                tabulatorTable: {
                    getSelectedRows: () => [{getData: () => form}],
                },
            },
        };

        node.setFormsActionButtonsState.call(actionHost);

        assert.notInclude(
            actionHost.formsOverviewDropdownActions.map((action) => action.value),
            'delete',
        );
        assert.isFalse(
            actionHost.formsOverviewDropdownActions.find((action) => action.value === 'edit')
                .disabled,
        );
    });

    test('should open the permission dialog for a form resource', () => {
        const calls = [];
        const dialog = {
            resourceIdentifier: '',
            open: () => calls.push('open'),
        };
        const actionHost = {
            _: (selector) =>
                selector === '#form-grant-permission-dialog'
                    ? dialog
                    : document.createElement('div'),
        };

        node.handleEditFormPermission.call(actionHost, ['job-offer-1', 'job-offer-2']);

        assert.equal(dialog.resourceIdentifier, '');
        assert.deepEqual(dialog.resourceIdentifiers, ['job-offer-1', 'job-offer-2']);
        assert.deepEqual(calls, ['open']);
        assert.equal(
            node.shadowRoot
                .querySelector('#form-grant-permission-dialog')
                .getAttribute('resource-class-identifier'),
            'DbpRelayFormalizeForm',
        );
    });

    test('should open permission editing for selected submissions', () => {
        const calls = [];
        const dialog = {
            resourceIdentifier: '',
            resourceIdentifiers: [],
            open: () => calls.push('open'),
        };
        const actionHost = {
            enableSubmissionPermissionEditing: true,
            submissionTables: {
                submitted: {
                    tabulatorTable: {
                        getSelectedData: () => [
                            {submissionId: 'submission-1'},
                            {submissionId: 'submission-2'},
                        ],
                    },
                },
            },
            _: () => dialog,
        };

        node.handleEditSubmissionsPermission.call(actionHost, 'submitted');

        assert.equal(dialog.resourceIdentifier, '');
        assert.deepEqual(dialog.resourceIdentifiers, ['submission-1', 'submission-2']);
        assert.deepEqual(calls, ['open']);
    });

    test('should require manage on every selected submission', () => {
        const submissions = [{submissionId: 'submission-1'}, {submissionId: 'submission-2'}];
        const rows = submissions.map((submission) => ({getData: () => submission}));
        const actionHost = {
            enableSubmissionPermissionEditing: true,
            submissionTables: {
                submitted: {
                    tabulatorTable: {
                        getSelectedRows: () => rows,
                        getRows: () => rows,
                    },
                },
            },
            submissionsGrantedActions: new Map([
                ['submission-1', ['manage']],
                ['submission-2', ['read']],
            ]),
            selectedRowCount: {submitted: 0},
            allRowCount: {submitted: 0},
            isDeleteSelectedSubmissionEnabled: {submitted: false},
            isDeleteAllSubmissionEnabled: {submitted: false},
            isEditSubmissionEnabled: {submitted: false},
            isEditSubmissionPermissionEnabled: {submitted: false},
            isBatchTaggingEnabled: {submitted: false},
        };

        node.setActionButtonsStates.call(actionHost, 'submitted');
        assert.isFalse(actionHost.isEditSubmissionPermissionEnabled.submitted);

        actionHost.submissionsGrantedActions.set('submission-2', ['manage']);
        node.setActionButtonsStates.call(actionHost, 'submitted');
        assert.isTrue(actionHost.isEditSubmissionPermissionEnabled.submitted);
    });
});

suite('manage forms action menus', () => {
    test('should render supplied dropdown actions', async () => {
        const element = document.createElement('test-manage-forms-overview-page');
        element.selectedFormsCount = 1;
        element.actions = [{value: 'edit', label: 'Edit', iconName: 'pencil', disabled: false}];
        document.body.appendChild(element);
        await element.updateComplete;

        const select = element.shadowRoot.querySelector('#forms-table-actions-select');
        assert.include(
            select.options.map((option) => option.value),
            'edit',
        );

        element.actions = [];
        await element.updateComplete;
        assert.notInclude(
            select.options.map((option) => option.value),
            'edit',
        );
        element.remove();
    });

    test('should let a module customize row actions without changing defaults', async () => {
        const originalFetch = window.fetch;
        const createModule = (frontendKey, iconName) => ({
            getFormFrontendKey: () => frontendKey,
            getUrlSlug: () => frontendKey,
            getManageFormsOverviewActions: (_context, actions) =>
                iconName ? actions.map((action) => ({...action, iconName})) : actions,
        });
        const host = {
            _i18n: {t: (key) => key},
            entryPointUrl: 'https://example.com',
            auth: {token: 'token'},
            allForms: [],
            allowListFrontendKeys: [],
            denyListFrontendKeys: [],
            loadedModules: new Map([
                ['custom', {formId: 'custom', moduleInstance: createModule('custom', 'list')}],
                ['default', {formId: 'default', moduleInstance: createModule('default', null)}],
            ]),
            forms: new Map(),
            formsGrantedActions: new Map(),
            options_forms: {},
            lang: 'en',
            createScopedElement: () => {
                const button = document.createElement('button');
                button.iconName = 'keyword-research';
                return button;
            },
            sendSetPropertyEvent: () => {},
        };
        window.fetch = () =>
            Promise.resolve({
                ok: true,
                json: () =>
                    Promise.resolve({
                        'hydra:member': ['custom', 'default'].map((frontendKey) => ({
                            identifier: frontendKey,
                            frontendKey,
                            name: frontendKey,
                            localizedNames: [],
                            grantedActions: ['read'],
                            grantedFormActions: ['update'],
                            grantedSubmissionCollectionActions: [],
                        })),
                    }),
            });

        try {
            await getListOfAllForms(host);
        } finally {
            window.fetch = originalFetch;
        }

        const getIconName = (formId) =>
            host.allForms
                .find((form) => form.formId === formId)
                .actionButton.querySelector('button').iconName;
        assert.equal(getIconName('custom'), 'list');
        assert.equal(getIconName('default'), 'keyword-research');
        const defaultActions = [{id: 'default'}];
        assert.equal(
            new BaseObject().getManageFormsOverviewActions({}, defaultActions),
            defaultActions,
        );
    });

    test('should append module actions beside the default row action', async () => {
        const originalFetch = window.fetch;
        let actionContext = null;
        let grantedActions = ['read'];
        const moduleInstance = {
            getFormFrontendKey: () => 'job-offer',
            getUrlSlug: () => 'job-offer',
            getEditFormComponent: () => document.createElement('div'),
            getManageFormsOverviewActions: (_context, actions) => [
                ...actions.map((action) =>
                    action.id === 'edit' ? {...action, placements: ['row']} : action,
                ),
                {
                    id: 'preview',
                    iconName: 'eye',
                    placements: ['row'],
                    title: 'Preview',
                    ariaLabel: 'Preview job offer',
                    handler: (context) => (actionContext = context),
                },
            ],
        };
        const host = {
            _i18n: {t: (key) => key},
            entryPointUrl: 'https://example.com',
            auth: {token: 'token'},
            allForms: [],
            allowListFrontendKeys: [],
            denyListFrontendKeys: [],
            loadedModules: new Map([['job-offer', {formId: 'job-offer', moduleInstance}]]),
            forms: new Map(),
            formsGrantedActions: new Map(),
            options_forms: {},
            lang: 'en',
            createScopedElement: () => document.createElement('button'),
            sendSetPropertyEvent: () => {},
        };
        window.fetch = () =>
            Promise.resolve({
                ok: true,
                json: () =>
                    Promise.resolve({
                        'hydra:member': [
                            {
                                identifier: 'job-1',
                                frontendKey: 'job-offer',
                                name: 'Developer',
                                localizedNames: [],
                                additionalData: {title: 'Developer'},
                                grantedActions,
                                grantedFormActions: ['update'],
                                grantedSubmissionCollectionActions: [],
                            },
                        ],
                    }),
            });

        try {
            await getListOfAllForms(host);

            const buttons = host.allForms[0].actionButton.querySelectorAll('button');
            assert.lengthOf(buttons, 2);
            assert.equal(buttons[0].dataset.action, 'open-submissions');
            assert.equal(buttons[1].dataset.action, 'preview');
            assert.equal(buttons[1].iconName, 'eye');

            host.forms.set('job-1', {...host.forms.get('job-1'), formName: 'Updated developer'});
            buttons[1].click();
            assert.equal(actionContext.host, host);
            assert.equal(actionContext.form.formId, 'job-1');
            assert.equal(actionContext.form.formName, 'Updated developer');
            assert.deepEqual(actionContext.form.grantedActions, ['read']);

            grantedActions = ['manage'];
            await getListOfAllForms(host);
            assert.deepEqual(host.allForms[0].grantedActions, ['manage']);
            assert.deepEqual(
                host.formsOverviewActionDefinitions.map((action) => action.id),
                ['open-submissions', 'delete', 'edit', 'edit-permission', 'preview'],
            );
            assert.deepEqual(
                [...host.allForms[0].actionButton.querySelectorAll('button')].map(
                    (button) => button.dataset.action,
                ),
                ['open-submissions', 'edit', 'preview'],
            );
        } finally {
            window.fetch = originalFetch;
        }
    });

    test('should forward submission authorization settings when saving forms', async () => {
        const originalFetch = window.fetch;
        const requests = [];
        window.fetch = (url, options) => {
            requests.push({url, options});
            return Promise.resolve({ok: true, json: () => Promise.resolve({identifier: 'form-1'})});
        };
        const host = {
            auth: {token: 'token'},
            entryPointUrl: 'https://example.com',
            _i18n: {t: (key) => key},
        };
        const formData = {
            name: 'Job offer',
            localizedNames: [],
            frontendKey: 'job-offer',
            grantBasedSubmissionAuthorization: true,
            allowedActionsWhenSubmitted: ['read'],
        };

        try {
            await apiCreateForm(host, formData);
            await apiUpdateForm(host, 'form-1', formData);
        } finally {
            window.fetch = originalFetch;
        }

        assert.lengthOf(requests, 2);
        requests.forEach(({options}) => {
            const body = JSON.parse(options.body);
            assert.isTrue(body.grantBasedSubmissionAuthorization);
            assert.deepEqual(body.allowedActionsWhenSubmitted, ['read']);
        });
    });

    test('should only show forms with grants relevant to form management', async () => {
        const originalFetch = window.fetch;
        const moduleInstance = {
            getFormFrontendKey: () => 'job-offer',
            getUrlSlug: () => 'job-offer',
            getEditFormComponent: () => document.createElement('div'),
        };
        const makeHost = (
            grantedActions,
            grantedFormActions,
            grantedSubmissionCollectionActions,
        ) => ({
            _i18n: {t: (key) => key},
            entryPointUrl: 'https://example.com',
            auth: {token: 'token'},
            allForms: [],
            allowListFrontendKeys: [],
            denyListFrontendKeys: [],
            loadedModules: new Map([['job-offer', {formId: 'job-offer', moduleInstance}]]),
            forms: new Map(),
            formsGrantedActions: new Map(),
            options_forms: {},
            lang: 'en',
            createScopedElement: () => document.createElement('button'),
            sendSetPropertyEvent: () => {},
            apiGrantedActions: grantedActions,
            apiGrantedFormActions: grantedFormActions,
            apiGrantedSubmissionCollectionActions: grantedSubmissionCollectionActions,
        });
        window.fetch = () =>
            Promise.resolve({
                ok: true,
                json: () =>
                    Promise.resolve({
                        'hydra:member': [
                            {
                                identifier: 'job-offer',
                                frontendKey: 'job-offer',
                                name: 'Job offer',
                                localizedNames: [],
                                grantedActions: currentHost.apiGrantedActions,
                                grantedFormActions: currentHost.apiGrantedFormActions,
                                grantedSubmissionCollectionActions:
                                    currentHost.apiGrantedSubmissionCollectionActions,
                            },
                        ],
                    }),
            });
        let currentHost;

        try {
            currentHost = makeHost(['read'], ['read'], []);
            await getListOfAllForms(currentHost);
            assert.isEmpty(currentHost.allForms);

            currentHost = makeHost(
                ['read', 'create_submissions'],
                ['read'],
                ['create_submissions'],
            );
            await getListOfAllForms(currentHost);
            assert.isEmpty(currentHost.allForms);

            for (const action of ['update', 'delete', 'manage']) {
                currentHost = makeHost(['read'], [action], []);
                await getListOfAllForms(currentHost);
                assert.lengthOf(currentHost.allForms, 1);
            }

            for (const action of ['read', 'manage']) {
                currentHost = makeHost(['read'], [], [action]);
                await getListOfAllForms(currentHost);
                assert.lengthOf(currentHost.allForms, 1);
            }
        } finally {
            window.fetch = originalFetch;
        }
    });

    test('should add employers to job-offer rows and refresh changed employers', async () => {
        const originalFetch = window.fetch;
        let companyName = 'Example Company';
        const makeEntry = (identifier, frontendKey, additionalData) => ({
            identifier,
            frontendKey,
            name: identifier,
            localizedNames: [],
            grantedActions: ['read'],
            grantedFormActions: ['update'],
            grantedSubmissionCollectionActions: [],
            additionalData,
        });
        const host = {
            _i18n: {t: (key) => key},
            entryPointUrl: 'https://example.com',
            auth: {token: 'token'},
            allForms: [],
            allowListFrontendKeys: [],
            denyListFrontendKeys: [],
            loadedModules: new Map(),
            forms: new Map(),
            formsGrantedActions: new Map(),
            options_forms: {},
            lang: 'en',
            createScopedElement: () => document.createElement('button'),
            sendSetPropertyEvent: () => {},
        };

        window.fetch = () =>
            Promise.resolve({
                ok: true,
                json: () =>
                    Promise.resolve({
                        'hydra:member': [
                            makeEntry('external', 'job-offer', {
                                jobOfferType: 'external',
                                companyName,
                            }),
                            makeEntry('internal', 'job-offer', {
                                jobOfferType: 'internal',
                                organization: 'Faculty',
                            }),
                            makeEntry('other', 'other-form', {companyName: 'Ignored Company'}),
                        ],
                    }),
            });

        try {
            await getListOfAllForms(host);
            assert.deepEqual(
                host.allForms.map(({employer}) => employer),
                ['Example Company', 'TU Graz', ''],
            );

            companyName = 'Updated Company';
            await getListOfAllForms(host);
            assert.equal(host.allForms[0].employer, 'Updated Company');
        } finally {
            window.fetch = originalFetch;
        }
    });

    test('should disable the dropdown when all supplied actions are disabled', async () => {
        const page = document.createElement('test-manage-forms-overview-page');
        page.selectedFormsCount = 1;
        page.actions = ['delete', 'edit', 'edit-permission'].map((value) => ({
            value,
            label: value,
            disabled: true,
        }));
        document.body.appendChild(page);
        await page.updateComplete;

        const select = page.shadowRoot.querySelector('dbp-select');
        const actions = select.options.map(({value}) => value);
        assert.deepEqual(actions, ['delete', 'edit', 'edit-permission']);
        assert.isTrue(select.hasAttribute('disabled'));

        page.actions = page.actions.map((action) =>
            action.value === 'edit-permission' ? {...action, disabled: false} : action,
        );
        await page.updateComplete;
        assert.isFalse(select.hasAttribute('disabled'));

        page.remove();
    });

    test('should render only supplied dropdown actions', async () => {
        const page = document.createElement('test-manage-forms-overview-page');
        page.selectedFormsCount = 1;
        page.actions = [
            {value: 'edit', label: 'Edit', disabled: false},
            {value: 'edit-permission', label: 'Edit permissions', disabled: false},
        ];
        document.body.appendChild(page);
        await page.updateComplete;

        const select = page.shadowRoot.querySelector('dbp-select');
        assert.deepEqual(
            select.options.map(({value}) => value),
            ['edit', 'edit-permission'],
        );
        assert.isFalse(select.hasAttribute('disabled'));

        page.remove();
    });

    test('should search visible form table fields and reset the search', async () => {
        const page = document.createElement('test-manage-forms-overview-page');
        page.optionsForms = {
            columns: [
                {field: 'id'},
                {field: 'name'},
                {field: 'formId', visible: false},
                {field: 'actionButton', formatter: 'html'},
            ],
        };
        const filters = [];
        const searchValues = [];
        let clearCount = 0;
        page.getFormsTable = () => ({
            setFilter: (filter) => filters.push(filter),
            clearFilter: () => clearCount++,
        });
        document.body.appendChild(page);
        page.addEventListener('forms-search-change', (event) => {
            searchValues.push(event.detail.value);
        });
        await page.updateComplete;

        const searchInput = page.getSearchbar();
        searchInput.value = 'Job offer';
        searchInput.dispatchEvent(new Event('input'));
        await page.updateComplete;

        assert.deepEqual(filters, [
            [
                [
                    {field: 'id', type: 'like', value: 'Job offer'},
                    {field: 'name', type: 'like', value: 'Job offer'},
                ],
            ],
        ]);

        page.shadowRoot.querySelector('.reset-search').click();
        assert.equal(searchInput.value, '');
        assert.equal(clearCount, 1);
        assert.deepEqual(searchValues, ['Job offer', '']);

        page.remove();
    });

    test('should search submissions as the user types', async () => {
        const page = document.createElement('test-manage-form-submissions-page');
        page.noSubmissionAvailable = {draft: false, submitted: true};
        page.submissions = {draft: [{name: 'Draft'}], submitted: []};
        page.isActionAvailable = {draft: false, submitted: false};
        const searchedStates = [];
        page.addEventListener('submission-search', (event) => {
            searchedStates.push(event.detail.state);
        });
        document.body.appendChild(page);
        await page.updateComplete;

        const searchInput = page.getSearchbar('draft');
        searchInput.value = 'Draft';
        searchInput.dispatchEvent(new Event('input'));

        assert.deepEqual(searchedStates, ['draft']);
        page.remove();
    });

    test('should put all submission filters into the routing URL', () => {
        const host = document.createElement('test-manage-forms');
        const searchInput = document.createElement('input');
        const searchColumn = document.createElement('select');
        const searchOperator = document.createElement('select');
        searchColumn.add(new Option('All', 'all'));
        searchColumn.add(new Option('Name', 'name'));
        searchOperator.add(new Option('Like', 'like'));
        searchOperator.add(new Option('Starts', 'starts'));
        searchInput.value = 'Alice';
        searchColumn.value = 'name';
        searchOperator.value = 'starts';

        host.routingUrl = '/job-offer?submitted-search=Existing';
        host.getRoutingData = () => ({
            pathname: '/job-offer',
            queryParams: new URLSearchParams('submitted-search=Existing'),
            hash: '',
        });
        host.getSubmissionsPage = () => ({
            getSearchbar: () => searchInput,
            getSearchSelect: () => searchColumn,
            getSearchOperator: () => searchOperator,
        });
        host.submissionTables.draft = {
            clearFilter: () => {},
            setFilter: () => {},
            getColumnsFields: () => ['name'],
            tabulatorTable: {
                deselectRow: () => {},
                getRows: () => [],
            },
        };
        let routingUrl = '';
        host.sendSetPropertyEvent = (name, value) => {
            if (name === 'routing-url') routingUrl = value;
        };

        host.filterTable('draft');

        const url = new URL(routingUrl, 'https://example.com');
        assert.equal(url.searchParams.get('draft-search'), 'Alice');
        assert.equal(url.searchParams.get('draft-search-column'), 'name');
        assert.equal(url.searchParams.get('draft-search-operator'), 'starts');
        assert.equal(url.searchParams.get('submitted-search'), 'Existing');
    });

    test('should put form and submission pagination into the routing URL', () => {
        const host = document.createElement('test-manage-forms');
        host.routingUrl = '/';
        host.getRoutingData = () => ({
            pathname: '/',
            queryParams: new URLSearchParams(),
            hash: '',
        });
        host.formsTable = {identifier: 'forms-table'};
        host._urlStateReadyTables.add('forms-table');
        let routingUrl = '';
        host.sendSetPropertyEvent = (name, value) => {
            if (name === 'routing-url') routingUrl = value;
        };

        host.handleTablePaginationPageLoaded({
            detail: {tableId: 'forms-table', page: 3, pageSize: 3, paginationSize: 20},
        });

        const url = new URL(routingUrl, 'https://example.com');
        assert.equal(url.searchParams.get('forms-page'), '3');
        assert.equal(url.searchParams.get('forms-page-size'), '20');

        host.submissionTables.draft = {
            identifier: 'submissions-table-draft',
            tabulatorTable: {getRows: () => []},
        };
        host._urlStateReadyTables.add('submissions-table-draft');
        host.handleTablePaginationPageLoaded({
            detail: {
                tableId: 'submissions-table-draft',
                page: 2,
                pageSize: 2,
                paginationSize: 10,
            },
        });

        const submissionUrl = new URL(routingUrl, 'https://example.com');
        assert.equal(submissionUrl.searchParams.get('draft-page'), '2');
        assert.equal(submissionUrl.searchParams.get('draft-page-size'), '10');
    });

    test('should scope pagination size storage to the current user and activity', () => {
        const host = document.createElement('test-manage-forms');
        host.auth = {'user-id': 'user-1'};
        host.isLoggedIn = () => true;

        assert.equal(host.getPaginationSizeStorageKey(), 'formalize-manage-forms-user-1');

        host.auth = {'user-id': 'user-2'};
        assert.equal(host.getPaginationSizeStorageKey(), 'formalize-manage-forms-user-2');
    });

    test('should put submission details into the activity routing URL', () => {
        const host = document.createElement('test-manage-forms');
        const formId = '11111111-1111-1111-1111-111111111111';
        const submissionId = '22222222-2222-2222-2222-222222222222';
        host.activeFormId = formId;
        host.getRoutingData = () => ({
            pathSegments: [formId],
            queryParams: new URLSearchParams('submitted-page=2'),
            hash: '',
        });
        let routingUrl = '';
        host.sendSetPropertyEvent = (name, value) => {
            if (name === 'routing-url') routingUrl = value;
        };

        host.setSubmissionDetailsRoute(submissionId);
        assert.equal(routingUrl, `/${formId}/details/${submissionId}?submitted-page=2`);

        host.setSubmissionDetailsRoute();
        assert.equal(routingUrl, `/${formId}?submitted-page=2`);
    });

    test('should restore submission filters and pagination from the routing URL', async () => {
        const host = document.createElement('test-manage-forms');
        const searchInput = document.createElement('input');
        const searchColumn = document.createElement('select');
        const searchOperator = document.createElement('select');
        searchColumn.add(new Option('All', 'all'));
        searchColumn.add(new Option('Name', 'name'));
        searchOperator.add(new Option('Like', 'like'));
        searchOperator.add(new Option('Starts', 'starts'));
        host.getRoutingData = () => ({
            queryParams: new URLSearchParams(
                'draft-search=Alice&draft-search-column=name&draft-search-operator=starts&draft-page=2&draft-page-size=10',
            ),
        });
        host.getSubmissionsPage = () => ({
            getSearchbar: () => searchInput,
            getSearchSelect: () => searchColumn,
            getSearchOperator: () => searchOperator,
        });
        const calls = [];
        host.submissionTables.draft = {
            identifier: 'submissions-table-draft',
            paginationSize: 5,
            setFilter: (filters) => calls.push(['filter', filters]),
            getColumnsFields: () => ['name'],
            tabulatorTable: {
                deselectRow: () => {},
                getRows: () => [],
                setPageSize: (size) => Promise.resolve(calls.push(['page-size', size])),
                setPage: (page) => Promise.resolve(calls.push(['page', page])),
            },
        };

        await host.restoreSubmissionTableState('draft');

        assert.equal(searchInput.value, 'Alice');
        assert.equal(searchColumn.value, 'name');
        assert.equal(searchOperator.value, 'starts');
        assert.deepInclude(calls, ['page-size', 10]);
        assert.deepInclude(calls, ['page', 2]);
        assert.isTrue(host._urlStateReadyTables.has('submissions-table-draft'));
    });

    test('should keep the stored page size when the routing URL has no page size', async () => {
        const host = document.createElement('test-manage-forms');
        const searchInput = document.createElement('input');
        const searchColumn = document.createElement('select');
        const searchOperator = document.createElement('select');
        searchColumn.add(new Option('All', 'all'));
        searchOperator.add(new Option('Like', 'like'));
        host.getRoutingData = () => ({queryParams: new URLSearchParams()});
        host.getSubmissionsPage = () => ({
            getSearchbar: () => searchInput,
            getSearchSelect: () => searchColumn,
            getSearchOperator: () => searchOperator,
        });
        const calls = [];
        host.submissionTables.draft = {
            identifier: 'submissions-table-draft',
            paginationSize: 20,
            clearFilter: () => {},
            setFilter: () => {},
            getColumnsFields: () => [],
            tabulatorTable: {
                deselectRow: () => {},
                getRows: () => [],
                setPageSize: (size) => Promise.resolve(calls.push(['page-size', size])),
                setPage: () => Promise.resolve(),
            },
        };

        await host.restoreSubmissionTableState('draft');

        assert.deepInclude(calls, ['page-size', 20]);
    });

    test('should restore form pagination after table data has loaded', async () => {
        const host = document.createElement('test-manage-forms');
        let resolveData;
        const dataLoaded = new Promise((resolve) => {
            resolveData = resolve;
        });
        const calls = [];
        host.allForms = [{id: 1}];
        host.formsTable = {
            identifier: 'forms-table',
            tableReady: true,
            tableBuilding: false,
            setData: () => dataLoaded,
        };
        host.refreshTableReferences = () => {};
        host.restoreFormsTableState = () => {
            calls.push('restore');
        };
        host._urlStateReadyTables.add('forms-table');

        host.showFormsOverview();
        assert.deepEqual(calls, []);
        assert.isFalse(host._urlStateReadyTables.has('forms-table'));

        resolveData();
        await dataLoaded;
        await Promise.resolve();

        assert.deepEqual(calls, ['restore']);
    });

    test('should not provide a permission action for submissions', async () => {
        const page = document.createElement('test-manage-form-submissions-page');
        page.noSubmissionAvailable = {draft: false, submitted: true};
        page.isActionAvailable = {draft: false, submitted: false};
        document.body.appendChild(page);
        await page.updateComplete;

        const actions = page.shadowRoot
            .querySelector('#action-dropdown--draft')
            .options.map(({value}) => value);
        assert.notInclude(actions, 'edit-permission');

        page.remove();
    });

    test('should provide submission permission editing when enabled', async () => {
        const page = document.createElement('test-manage-form-submissions-page');
        page.noSubmissionAvailable = {draft: false, submitted: true};
        page.isActionAvailable = {draft: true, submitted: false};
        page.enableSubmissionPermissionEditing = true;
        page.isEditSubmissionPermissionEnabled = {draft: true, submitted: false};
        document.body.appendChild(page);
        await page.updateComplete;

        const select = page.shadowRoot.querySelector('#action-dropdown--draft');
        const permissionAction = select.options.find(({value}) => value === 'edit-permission');
        assert.isDefined(permissionAction);
        assert.isFalse(permissionAction.disabled);

        page.remove();
    });
});