@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
JavaScript
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();
});
});