@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:
2,691 lines • 108 kB
JavaScript
// @ts-nocheck
import {css, html} from 'lit';
import {ScopedElementsMixin} from '@dbp-toolkit/common';
import {setOverridesByGlobalCache} from '@dbp-toolkit/common/i18next.js';
import {
Button,
Icon,
IconButton,
MiniSpinner,
Translated,
sendNotification,
} from '@dbp-toolkit/common';
import * as commonUtils from '@dbp-toolkit/common/utils';
import {classMap} from 'lit/directives/class-map.js';
import {CustomTabulatorTable, GetDetailsButton, GetSubmissionLink} from './table-components.js';
import {FileSink} from '@dbp-toolkit/file-handling';
import * as commonStyles from '@dbp-toolkit/common/styles';
import {
SUBMISSION_STATES,
getFormRenderUrl,
SUBMISSION_PERMISSIONS,
FORM_PERMISSIONS,
isDraftStateEnabled,
isSubmittedStateEnabled,
} from './utils.js';
import {getSelectorFixCSS, getFileHandlingCss, getTagsCSS, getManageFormsCSS} from './styles.js';
import xss from 'xss';
import DBPFormalizeLitElement from './dbp-formalize-lit-element.js';
import {GrantPermissionDialog} from '@dbp-toolkit/grant-permission-dialog';
import {Modal} from '@dbp-toolkit/common/src/modal.js';
import {ManageFormsOverviewPage} from './manage-forms-overview-page.js';
import {ManageFormSubmissionsPage} from './manage-form-submissions-page.js';
import {ManageSubmissionModal} from './manage-submission-modal.js';
import {BatchTaggingModal} from './batch-tagging-modal.js';
import {DeletionConfirmationModal} from './deletion-confirmation-modal.js';
import {EditFormDialog} from './edit-form-dialog.js';
import {
createManageFormsOverviewActionContext,
resolveManageFormsOverviewAction,
} from './manage-forms-overview-actions.js';
// Extracted modules
import {
loadModules,
getListOfAllForms,
getAllFormSubmissions,
apiDeleteSubmission,
apiDeleteForm,
apiUpdateSubmissionTags,
apiGetTags,
humanReadableDate,
successFailureNotification,
} from './manage-forms-api.js';
import {
setSubmissionFormOptions,
setDefaultSubmissionTableOrder,
enableCheckboxSelection,
disableCheckboxSelection,
enablePagination,
disablePagination,
} from './manage-forms-table-config.js';
/**
* Statically reference translation keys that are only resolved dynamically
* (passed as strings to helpers such as successFailureNotification). Without
* this, the i18next extractor would treat them as unused and prune them.
* The `{count}` argument ensures the plural (`_one`/`_other`) variants are
* generated/kept.
*
* @param {(key: string, options?: object) => string} t
*/
const keepDynamicTranslations = (t) => {
t('success.forms-processed', {count: 0});
t('errors.forms-processing-failed', {count: 0});
t('success.submissions-processed', {count: 0});
t('errors.submissions-processing-failed', {count: 0});
};
const PAGINATION_SIZES = [5, 10, 20, 50, 100];
// Accept JSON arrays and comma-separated HTML attribute values for frontendKey lists.
function parseFormListAttribute(value) {
console.log('parseFormListAttribute input:', value);
if (Array.isArray(value)) {
return value.map((item) => `${item}`.trim()).filter((item) => item !== '');
}
if (typeof value !== 'string') {
return [];
}
const trimmedValue = value.trim();
if (trimmedValue === '') {
return [];
}
try {
const parsedValue = JSON.parse(trimmedValue);
if (Array.isArray(parsedValue)) {
return parsedValue.map((item) => `${item}`.trim()).filter((item) => item !== '');
}
} catch {
// Fallback to comma-separated values for HTML attributes.
}
return trimmedValue
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '');
}
/**
* @augments {DBPFormalizeLitElement}
*/
export class ManageForms extends ScopedElementsMixin(DBPFormalizeLitElement) {
constructor() {
super();
this.allForms = [];
this.isLoadingModules = false;
// Holds the in-flight loadModules() promise so concurrent callers await
// the same load instead of returning early while modules resolve.
this.loadModulesPromise = null;
this.boundKeyEventHandler = this.handleKeyEvents.bind(this);
this.boundCloseActionsDropdownHandler = this.closeActionsDropdown.bind(this);
this.boundTableSelectionChanges = this.handleTableSelectionChanges.bind(this);
this.boundTablePaginationPageLoaded = this.handleTablePaginationPageLoaded.bind(this);
this.selectedRowCount = {
draft: 0,
submitted: 0,
};
this.allRowCount = {
draft: 0,
submitted: 0,
};
this.visibleRowCount = {
draft: 0,
submitted: 0,
};
this.searchIsActive = {
draft: false,
submitted: false,
};
this.options_submissions = {
draft: {},
submitted: {},
};
this.options_forms = {};
// Bulk removal of forms in the overview is opt-in and disabled by default.
this.enableFormsBulkDelete = false;
this.formsOverviewActionDefinitions = [];
this.formsOverviewDropdownActions = [];
this.enableSubmissionPermissionEditing = false;
// Initialize the forms table options (including `langs`) up front so the
// table can be built before the `lang`/`langDir` branch of updated() runs.
// Otherwise buildTable() may access options.langs while it is still undefined.
this.updateFormsTableOptions();
this._overridesReady = null;
this.forms = new Map();
/** @type {Map<string, {formId: string, formSlug: string, formName: string|null, moduleInstance: object}>} */
this.loadedModules = new Map();
this.rawSubmissions = [];
this.submissionsGrantedActions = new Map();
// Maps a form identifier to its grantedActions array (used to gate bulk form deletion).
this.formsGrantedActions = new Map();
// Number of currently selected forms in the overview table.
this.selectedFormsCount = 0;
// Whether deleting the selected forms is allowed (all selected forms grant delete/manage).
this.submissions = {
draft: [],
submitted: [],
};
this.showSubmissionTables = false;
this.showFormsTable = false;
this.submissionSlug = '';
this.submissionsColumnsInitial = {
draft: [],
submitted: [],
};
this.activeFormName = '';
this.activeFormId = '';
this.currentBeautyId = 0;
this.totalNumberOfItems = {
draft: 0,
submitted: 0,
};
this.isPrevEnabled = false;
this.isNextEnabled = false;
// @TODO: remove unused property
this.storeSession = true;
this.loadingFormsTable = false;
this.loadingSubmissionTables = false;
this.noSubmissionAvailable = {
draft: true,
submitted: true,
};
this.modalContentHeight = 0;
this.loadCourses = true;
this.hiddenColumns = false;
this.currentDetailPosition = 0;
this.submissionTables = {
submitted: null,
draft: null,
};
this.formsTable = null;
this.submissionsHasAttachment = {
draft: false,
submitted: false,
};
this.submittedFileDetails = {
draft: new Map(),
submitted: new Map(),
};
this.isDeleteSelectedSubmissionEnabled = {
draft: false,
submitted: false,
};
this.isDeleteAllSubmissionEnabled = {
draft: false,
submitted: false,
};
this.isEditSubmissionEnabled = {
draft: false,
submitted: false,
};
this.isEditSubmissionPermissionEnabled = {
draft: false,
submitted: false,
};
this.isBatchTaggingEnabled = {
draft: false,
submitted: false,
};
this.enabledStates = {
draft: false,
submitted: false,
};
this.searchWidgetIsOpen = {
draft: false,
submitted: false,
};
this.actionsWidgetIsOpen = {
draft: false,
submitted: false,
};
this.isActionAvailable = {
draft: false,
submitted: false,
};
this.needTableRebuild = {
draft: false,
submitted: false,
};
this.createSubmissionUrl = '';
this.useSubFoldersForExports = true;
this.downloadFolderNamePattern = '';
this.allowListFrontendKeys = [];
this.denyListFrontendKeys = [];
this.hideCreateSubmissionButton = false;
this.noFormsAvailable = false;
// Number of loaded modules that implement createForm(); drives button visibility
this.creatableModulesCount = 0;
this.userNameCache = new Map();
this.isRequestDetailedView = false;
this.submissionIdToOpen = null;
this.submissionIdsForTagging = [];
this.availableTags = [];
this.attachmentsAreLoading = {
draft: false,
submitted: false,
};
this._abortAttachmentLoading = false;
// Guard: prevents updated('submissions') from rebuilding tables while
// switchToSubmissionTable is in progress.
this._isSwitchingTable = false;
// Counter: incremented on each switchToSubmissionTable call so that a
// stale .then() callback from an earlier call is discarded.
this._switchGeneration = 0;
this._restoringUrlStateTables = new Set();
this._urlStateReadyTables = new Set();
}
static get scopedElements() {
return {
'dbp-button': Button,
'dbp-icon': Icon,
'dbp-icon-button': IconButton,
'dbp-translated': Translated,
'dbp-mini-spinner': MiniSpinner,
'dbp-tabulator-table': CustomTabulatorTable,
'dbp-grant-permission-dialog': GrantPermissionDialog,
'dbp-modal': Modal,
'dbp-file-sink': FileSink,
'dbp-formalize-get-details-button': GetDetailsButton,
'dbp-formalize-get-submission-link': GetSubmissionLink,
'dbp-formalize-manage-forms-overview-page': ManageFormsOverviewPage,
'dbp-formalize-manage-form-submissions-page': ManageFormSubmissionsPage,
'dbp-formalize-manage-submission-modal': ManageSubmissionModal,
'dbp-formalize-batch-tagging-modal': BatchTaggingModal,
'dbp-formalize-deletion-confirmation-modal': DeletionConfirmationModal,
'dbp-formalize-edit-form-dialog': EditFormDialog,
};
}
static get properties() {
return {
...super.properties,
allForms: {type: Array, attribute: false},
form: {type: String},
name: {type: String},
activeFormName: {type: String, attribute: false},
forms: {type: Object, attribute: false},
submissions: {type: Object, attribute: false},
emptyCoursesTable: {type: Boolean, attribute: true},
showFormsTable: {type: Boolean, attribute: false},
showSubmissionTables: {type: Boolean, attribute: false},
loadingFormsTable: {type: Boolean, attribute: false},
loadingSubmissionTables: {type: Boolean, attribute: false},
isPrevEnabled: {type: Boolean, attribute: false},
isNextEnabled: {type: Boolean, attribute: false},
currentBeautyId: {type: Number, attribute: false},
totalNumberOfItems: {type: Object, attribute: false},
modalContentHeight: {type: Number, attribute: false},
loadCourses: {type: Boolean, attribute: true},
hasPermissions: {type: Boolean, attribute: false},
hiddenColumns: {type: Boolean, attribute: false},
options_submissions: {type: Object, attribute: false},
options_forms: {type: Object, attribute: false},
searchWidgetIsOpen: {type: Object, attribute: false},
actionsWidgetIsOpen: {type: Object, attribute: false},
isActionAvailable: {type: Object, attribute: false},
noSubmissionAvailable: {type: Object, attribute: false},
createSubmissionUrl: {type: String, attribute: false},
schemaVisibilitySet: {type: Boolean, attribute: false},
isDeleteSelectedSubmissionEnabled: {type: Boolean, attribute: false},
isDeleteAllSubmissionEnabled: {type: Boolean, attribute: false},
isEditSubmissionEnabled: {type: Boolean, attribute: false},
isEditSubmissionPermissionEnabled: {type: Object, attribute: false},
selectedFormsCount: {type: Number, attribute: false},
formsOverviewActionDefinitions: {type: Array, attribute: false},
formsOverviewDropdownActions: {type: Array, attribute: false},
selectedRowCount: {type: Object, attribute: false},
allRowCount: {type: Object, attribute: false},
visibleRowCount: {type: Object, attribute: false},
searchIsActive: {type: Object, attribute: false},
justAddTagsForBatchTagging: {type: Boolean, attribute: false},
attachmentsAreLoading: {type: Object, attribute: false},
noFormsAvailable: {type: Boolean, attribute: false},
creatableModulesCount: {type: Number, attribute: false},
// List of frontendKey values to include; forms without a matching frontendKey are hidden.
allowListFrontendKeys: {
type: Array,
attribute: 'allow-list-frontend-keys',
converter: {
fromAttribute: parseFormListAttribute,
},
},
// List of frontendKey values to exclude; forms with a matching frontendKey are hidden.
denyListFrontendKeys: {
type: Array,
attribute: 'deny-list-frontend-keys',
converter: {
fromAttribute: parseFormListAttribute,
},
},
hideCreateSubmissionButton: {
type: Boolean,
attribute: 'hide-create-submission-button',
},
enableFormsBulkDelete: {
type: Boolean,
attribute: 'enable-forms-bulk-delete',
},
enableSubmissionPermissionEditing: {
type: Boolean,
attribute: 'enable-submission-permission-editing',
},
};
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keyup', this.boundKeyEventHandler);
document.removeEventListener('click', this.boundCloseActionsDropdownHandler);
document.removeEventListener(
'dbp-tabulator-table-row-selection-changed-event',
this.boundTableSelectionChanges,
);
document.removeEventListener(
'dbp-tabulator-table-page-loaded-event',
this.boundTablePaginationPageLoaded,
);
document.removeEventListener(
'dbp-tabulator-table-page-size-changed-event',
this.boundTablePaginationPageLoaded,
);
document.removeEventListener(
'dbp-file-sink-download-started',
this.boundFileSinkDownloadStartedHandler,
);
navigator.serviceWorker.removeEventListener('message', this.boundSwMessageHandler);
}
getOverviewPage() {
return this._('dbp-formalize-manage-forms-overview-page');
}
getSubmissionsPage() {
return this._('dbp-formalize-manage-form-submissions-page');
}
getSubmissionModal(state) {
return this._(`#submission-modal-${state}`);
}
refreshTableReferences() {
const overviewPage = this.getOverviewPage();
if (overviewPage) {
this.formsTable = /** @type {CustomTabulatorTable} */ (overviewPage.getFormsTable());
}
const submissionsPage = this.getSubmissionsPage();
if (submissionsPage) {
for (const state of Object.values(SUBMISSION_STATES)) {
this.submissionTables[state] = /** @type {CustomTabulatorTable} */ (
submissionsPage.getSubmissionTable(state)
);
}
}
}
connectedCallback() {
super.connectedCallback();
// Build table options after overrides are loaded so that column
// labels use the overridden text on the very first render.
// connectedCallback cannot be async, so we chain the work.
const initTableOptions = async () => {
if (this.langDir) {
await setOverridesByGlobalCache(this._i18n, this);
}
this.updateFormsTableOptions();
setSubmissionFormOptions(this, 'draft');
setSubmissionFormOptions(this, 'submitted');
};
// Store the promise so other code paths can await it
this._overridesReady = initTableOptions();
void this.updateComplete.then(() => {
// see: http://tabulator.info/docs/5.1
document.addEventListener('keyup', this.boundKeyEventHandler);
document.addEventListener('click', this.boundCloseActionsDropdownHandler);
document.addEventListener(
'dbp-tabulator-table-row-selection-changed-event',
this.boundTableSelectionChanges,
);
document.addEventListener(
'dbp-tabulator-table-page-loaded-event',
this.boundTablePaginationPageLoaded,
);
document.addEventListener(
'dbp-tabulator-table-page-size-changed-event',
this.boundTablePaginationPageLoaded,
);
document.addEventListener(
'dbp-file-sink-download-started',
this.boundFileSinkDownloadStartedHandler,
);
// listen for the SW message indicating that the download has started, to hide the loading indicator in the UI
navigator.serviceWorker.addEventListener('message', this.boundSwMessageHandler);
// Table built event listener
document.addEventListener(
'dbp-tabulator-table-built',
(/** @type {CustomEvent} */ e) => {
if (e.detail.id) {
const state = this.getTableState(e.detail.id);
if (state) {
// Set visibility and name localization of columns based on form schema
setDefaultSubmissionTableOrder(this, state);
this.submissionTables[state].setColumns(
this.submissionsColumnsInitial[state],
);
this.setIsActionAvailable(state);
this.setVisibleRowCount(state);
// Open detailed view modal if /details/[uuid] is in the URL
if (this.isRequestDetailedView) {
// Get the selected submission
const selectedIndex = this.submissions[state].findIndex(
(submission) =>
submission.submissionId === this.submissionIdToOpen,
);
const selectedSubmission =
selectedIndex !== -1
? this.submissions[state][selectedIndex]
: null;
if (selectedSubmission) {
const cols = selectedSubmission;
const id = selectedIndex + 1;
// Open details modal
this.requestDetailedSubmission(state, cols, id);
}
}
void this.restoreSubmissionTableState(state);
} else if (e.detail.id === this.formsTable?.identifier) {
void this.restoreFormsTableState();
}
}
},
);
});
}
updateFormsTableOptions() {
const i18n = this._i18n;
const showEmployerColumn =
this.allowListFrontendKeys?.length === 1 &&
this.allowListFrontendKeys[0] === 'job-offer';
let langs_forms = {
en: {
columns: {
id: i18n.t('manage-forms.id', {lng: 'en'}),
name: i18n.t('manage-forms.name', {lng: 'en'}),
employer: i18n.t('manage-forms.employer', {lng: 'en'}),
},
},
de: {
columns: {
id: i18n.t('manage-forms.id', {lng: 'de'}),
name: i18n.t('manage-forms.name', {lng: 'de'}),
employer: i18n.t('manage-forms.employer', {lng: 'de'}),
},
},
};
this.options_forms = {
langs: langs_forms,
layout: 'fitColumns',
columns: [
{field: 'id', width: 50, sorter: 'number'},
{field: 'name', sorter: 'string', widthGrow: showEmployerColumn ? 2 : 4},
...(showEmployerColumn
? [{field: 'employer', sorter: 'string', widthGrow: 2}]
: []),
// Hidden helper columns carrying data needed for bulk deletion.
{field: 'formId', visible: false},
{field: 'grantedActions', visible: false},
{field: 'dateCreated', visible: false},
{
field: 'actionButton',
formatter: 'html',
hozAlign: 'right',
widthShrink: 1,
minWidth: 44,
headerSort: false,
},
],
columnDefaults: {
vertAlign: 'middle',
hozAlign: 'left',
resizable: false,
},
initialSort: [{column: 'dateCreated', dir: 'desc'}],
};
this.options_forms.selectableRows = 'highlight';
this.options_forms.rowHeader = {
formatter: 'rowSelection',
titleFormatter: 'rowSelection',
titleFormatterParams: {
rowRange: 'visible',
},
headerSort: false,
resizable: false,
frozen: true,
headerHozAlign: 'center',
hozAlign: 'center',
// With the "fitColumns" layout every column grows to fill the row.
// Pin the selection column to the checkbox width so it doesn't
// stretch across the table like it did before.
width: 40,
minWidth: 40,
widthGrow: 0,
widthShrink: 0,
};
}
getTableState(tableId) {
for (const state of Object.keys(this.submissionTables)) {
if (
this.submissionTables[state] &&
tableId === this.submissionTables[state].identifier
) {
return state;
}
}
return null;
}
getColumnConfigurationStorageKey(scope) {
const userId = this.auth?.['user-id'];
if (!this.storeSession || !this.isLoggedIn() || !userId) return '';
return `formalize-${scope}-${userId}`;
}
getPaginationSizeStorageKey() {
const userId = this.auth?.['user-id'];
if (!this.storeSession || !this.isLoggedIn() || !userId) return '';
return `formalize-manage-forms-${userId}`;
}
rebuildFormsTable() {
this.refreshTableReferences();
if (!this.formsTable) return;
// The child component receives `options_forms` through Lit rendering.
// When this method is called during the same update cycle that changes
// `options_forms`, that render may not have propagated yet. Push the
// fresh options directly before rebuilding so Tabulator never builds
// from stale options without the row-selection header.
this.formsTable.options = this.options_forms;
this.formsTable.data = this.allForms;
if (this.formsTable.tabulatorTable) {
this.formsTable.tabulatorTable.destroy();
}
this.formsTable.tableReady = false;
this.formsTable.tableBuilding = false;
this.formsTable.buildTable();
}
async firstUpdated() {
this.refreshTableReferences();
if (this.auth.token === '' || this.allForms.length > 0) {
return;
}
// If we arrive from another activity, auth is not updated
// we need to init form-loading here.
if (this.forms.size === 0) {
await loadModules(this);
// Refresh count after modules are loaded so the button visibility is updated
this.getCreatableModules();
}
await getListOfAllForms(this);
}
async loginCallback() {
if (this.forms.size === 0) {
await loadModules(this);
// Refresh count after modules are loaded so the button visibility is updated
this.getCreatableModules();
}
await getListOfAllForms(this);
}
async updated(changedProperties) {
// Ensure translation overrides are loaded BEFORE any table is built
// so that column labels, placeholders, etc. use overridden text on
// the very first render — not just after a second language switch.
if (changedProperties.has('lang') || changedProperties.has('langDir')) {
if (this.langDir) {
await setOverridesByGlobalCache(this._i18n, this);
}
this.updateFormsTableOptions();
setSubmissionFormOptions(this, 'draft');
setSubmissionFormOptions(this, 'submitted');
this.rebuildFormsTable();
}
if (
changedProperties.has('enableFormsBulkDelete') &&
!changedProperties.has('lang') &&
!changedProperties.has('langDir')
) {
this.updateFormsTableOptions();
this.setFormsActionButtonsState();
this.rebuildFormsTable();
}
if (changedProperties.has('formsOverviewActionDefinitions')) {
this.setFormsActionButtonsState();
}
if (changedProperties.has('enableSubmissionPermissionEditing')) {
for (const state of Object.values(SUBMISSION_STATES)) {
if (this.submissionTables[state]?.tabulatorTable) {
this.setIsActionAvailable(state);
} else if (!this.enableSubmissionPermissionEditing) {
this.isEditSubmissionPermissionEnabled = {
...this.isEditSubmissionPermissionEnabled,
[state]: false,
};
}
}
}
if (changedProperties.has('allForms')) {
// Wait for the initial override load started in connectedCallback
// so that column labels are correct on the very first table build.
if (this._overridesReady !== null) {
await this._overridesReady;
}
// Build tables
if (this.allForms && this.allForms.length > 0) {
this.noFormsAvailable = false;
// We use the first URL segment as the form identifier and the optional second segment as its view.
const formId = this.getRoutingData().pathSegments[0] || '';
if (formId) {
// Check if the form-id is one of the forms identifiers.
const form = this.forms.get(formId);
if (form) {
this.showRoutedForm(form);
} else {
sendNotification({
summary: this._i18n.t('errors.notfound-title'),
body: this._i18n.t('errors.notfound-body'),
type: 'danger',
timeout: 0,
});
}
} else {
this.showFormsOverview();
}
} else if (this.allForms) {
// allForms is defined but empty — all forms were filtered out by the allow/deny list.
// Stop the loading spinner and show an empty table with a message instead.
this.noFormsAvailable = true;
this.refreshTableReferences();
if (this.formsTable) {
this.formsTable.options = this.options_forms;
this.formsTable.data = [];
if (!this.formsTable.tableReady && !this.formsTable.tableBuilding) {
this.formsTable.buildTable();
} else if (this.formsTable.tableReady) {
this.formsTable.setData([]);
}
}
this.loadingFormsTable = false;
this.showFormsTable = true;
this.showSubmissionTables = false;
}
}
if (changedProperties.has('routingUrl')) {
// Prepend a slash to the URL if it doesn't start with one
let newUrl = !this.routingUrl.match(/^\//) ? `/${this.routingUrl}` : this.routingUrl;
const prevUrl = changedProperties.get('routingUrl');
let oldUrl = prevUrl && !prevUrl.match(/^\//) ? '/' + prevUrl : prevUrl;
if (oldUrl === undefined) return;
// Remove hash from URLs
newUrl = newUrl.replace(/^(.*)#.*$/, '$1');
oldUrl = oldUrl.replace(/^(.*)#.*$/, '$1');
if (oldUrl !== newUrl) {
const queryChanged =
new URL(newUrl, window.location.origin).search !==
new URL(oldUrl, window.location.origin).search;
if (this.forms.size === 0 && this.isLoggedIn()) {
await loadModules(this);
// Refresh count after modules are loaded so the button visibility is updated
this.getCreatableModules();
await getListOfAllForms(this);
}
// Show the routed form view.
const formId = this.getRoutingData().pathSegments[0] || '';
if (formId) {
const form = this.forms.get(formId);
if (form) {
this.showRoutedForm(form);
}
} else {
this.closeEditFormDialog();
this.showFormsOverview();
}
// Detail modal routes only change the path. Reapplying the current
// filter would deselect the row that opened the modal.
if (queryChanged) {
void this.restoreVisibleTableState();
}
}
}
if (changedProperties.has('submissions') && !this._isSwitchingTable) {
this.refreshTableReferences();
for (const state in this.submissions) {
if (!this.submissionTables[state]) continue;
if (this.submissions[state]?.length === 0) {
this.noSubmissionAvailable = {...this.noSubmissionAvailable, [state]: true};
disablePagination(this, state);
if (this.submissionTables[state].tabulatorTable) {
this.submissionTables[state].tabulatorTable.destroy();
setSubmissionFormOptions(this, state);
// disableCheckboxSelection must be called after setSubmissionFormOptions
// because setSubmissionFormOptions creates a fresh options object,
// overwriting any prior headerVisible/rowHeader changes.
disableCheckboxSelection(this, state);
// Push the fresh empty options directly onto the component
// so buildTable() doesn't read stale options.data that is
// still cached from ManageFormSubmissionsPage's previous
// render cycle.
this.submissionTables[state].options = this.options_submissions[state];
this.submissionTables[state].data = [];
this.submissionTables[state].buildTable();
}
} else {
this.noSubmissionAvailable = {...this.noSubmissionAvailable, [state]: false};
enableCheckboxSelection(this, state);
enablePagination(this, state);
}
if (this.needTableRebuild[state]) {
this.submissionTables[state].buildTable();
this.needTableRebuild[state] = false;
}
}
}
if (changedProperties.has('lang')) {
await getListOfAllForms(this);
const activeForm = this.forms.get(this.activeFormId);
if (activeForm) {
const activeFormSlug = activeForm ? activeForm.formSlug : null;
this.createSubmissionUrl = activeFormSlug
? getFormRenderUrl(activeFormSlug, this.lang)
: '';
// To re-create get-submission-links with the new language
this.switchToSubmissionTable(activeForm);
}
}
if (
changedProperties.has('allowListFrontendKeys') ||
changedProperties.has('denyListFrontendKeys')
) {
// Deep-compare the old and new values so we don't refetch when the
// provider re-dispatches the same arrays (e.g. after a token refresh).
const oldAllow = changedProperties.get('allowListFrontendKeys');
const oldDeny = changedProperties.get('denyListFrontendKeys');
const allowChanged =
changedProperties.has('allowListFrontendKeys') &&
JSON.stringify(oldAllow) !== JSON.stringify(this.allowListFrontendKeys);
const denyChanged =
changedProperties.has('denyListFrontendKeys') &&
JSON.stringify(oldDeny) !== JSON.stringify(this.denyListFrontendKeys);
if (
allowChanged &&
!changedProperties.has('lang') &&
!changedProperties.has('langDir')
) {
this.updateFormsTableOptions();
this.rebuildFormsTable();
}
if ((allowChanged || denyChanged) && this.isLoggedIn()) {
console.log(
'updated: allowListFrontendKeys/denyListFrontendKeys changed',
this.allowListFrontendKeys,
this.denyListFrontendKeys,
'isLoggedIn:',
this.isLoggedIn(),
);
await getListOfAllForms(this);
}
}
super.updated(changedProperties);
}
/**
* Display the form submission table for the given form
* @param {object} form - form object
*/
switchToSubmissionTable(form) {
this.activeFormName = form.formName;
this.activeFormId = form.formId;
this.showFormsTable = false;
this.loadingSubmissionTables = true;
// Reset availableTags when switching forms
this.availableTags = [];
// Prevent updated('submissions') from interfering while we load
this._isSwitchingTable = true;
// Reset submissions so stale data from a previous form is never shown.
// Done after setting the guard so updated('submissions') won't react.
this.submissions = {submitted: [], draft: []};
// Invalidate any previous in-flight switchToSubmissionTable call
const generation = ++this._switchGeneration;
getAllFormSubmissions(this, this.activeFormId)
.then(async () => {
// If a newer switchToSubmissionTable was called while we were
// waiting, discard this stale result.
if (generation !== this._switchGeneration) return;
// Ensure the child component has rendered so table refs exist
const submissionsPage = this.getSubmissionsPage();
if (submissionsPage) {
await submissionsPage.updateComplete;
}
this.refreshTableReferences();
const activeForm = this.forms.get(this.activeFormId);
const activeFormSlug = activeForm ? activeForm.formSlug : null;
// if slug is an uuid ["e78869ce-e9b3-4df2-854b-cf88a35285f5"], do not create submission URL as it is not a renderable form
this.createSubmissionUrl =
activeFormSlug &&
!activeFormSlug.match(/[\w\d]{8}-[\w\d]{4}-[\w\d]{4}-[\w\d]{4}-[\w\d]{12}/)
? getFormRenderUrl(activeFormSlug, this.lang)
: '';
// Fetch available tags for this form before building the table
// This ensures availableTags is populated when setDefaultSubmissionTableOrder runs
await apiGetTags(this, this.activeFormId);
// Check again after the await — a newer call may have started
if (generation !== this._switchGeneration) return;
// For tabulatorTable 'draft' and 'submitted'
for (const state of Object.keys(this.submissionTables)) {
if (this.submissionTables[state]) {
const tableComponent = this.submissionTables[state];
// Destroy the old tabulator so stale data and event
// listeners from a previous form don't bleed through.
if (tableComponent.tabulatorTable) {
tableComponent.tabulatorTable.destroy();
}
// Reset the wrapper component's build flags so that a
// subsequent Lit property update doesn't skip buildTable().
tableComponent.tableReady = false;
tableComponent.tableBuilding = false;
// Clear the component-level data so the old array doesn't
// survive into the next buildTable() cycle.
tableComponent.data = [];
// Re-create options from scratch so we never inherit stale state
setSubmissionFormOptions(this, state);
if (this.submissions[state].length === 0) {
// There is no submission data
this.loadingSubmissionTables = false;
this.showSubmissionTables = true; // show back button
this.showFormsTable = false;
disableCheckboxSelection(this, state);
disablePagination(this, state);
this.options_submissions[state].data = [];
} else {
enableCheckboxSelection(this, state);
enablePagination(this, state);
// Set the data on the freshly-created options object
this.options_submissions[state].data = this.submissions[state];
this.loadingSubmissionTables = false;
this.showSubmissionTables = true;
this.showFormsTable = false;
// Open submission details modal if /details/[uuid] is in the URL
const routingData = this.getRoutingData();
this.submissionIdToOpen =
routingData.pathSegments[2] &&
routingData.pathSegments[2].match(/[0-9a-f-]+/)
? routingData.pathSegments[2]
: null;
const isRequestDetailedView =
routingData.pathSegments[1] === 'details' &&
this.submissionIdToOpen;
// Open modal from the `dbp-tabulator-table-built` event listener
this.isRequestDetailedView = isRequestDetailedView;
}
}
}
// Reactively propagate the final options so the child component
// has the same reference the tabulator will use.
this.options_submissions = {...this.options_submissions};
this._isSwitchingTable = false;
})
.catch(() => {
this._isSwitchingTable = false;
});
}
// -----------------------------------------------------------------------
// Submission detail modal
// -----------------------------------------------------------------------
/**
* Returns submitted files for a specific schema file field.
* @param {string} state - The state of the submission ('draft' or 'submitted').
* @param {string} submissionId
* @param {string} fieldName
* @returns {Array}
*/
getSubmittedFilesForField(state, submissionId, fieldName) {
if (!fieldName?.startsWith('form_files-') || !submissionId) {
return [];
}
const submittedFiles = this.submittedFileDetails[state]?.get(submissionId) || [];
const fileAttributeName = fieldName.replace('form_files-', '');
return submittedFiles.filter((file) => file.fileAttributeName === fileAttributeName);
}
/**
* Gets the detailed data of a specific row
* @param {string} state - The state of the submission ('draft' or 'submitted').
* @param entry
* @param pos
*/
requestDetailedSubmission(state, entry, pos) {
const modal = this.getSubmissionModal(state);
if (!modal) {
return;
}
const contentItems = [];
const columnDefinitions =
this.submissionTables[state]?.getColumnDefinitions?.() ||
this.submissionsColumnsInitial[state];
if (columnDefinitions.length !== 0) {
for (let current_column of columnDefinitions) {
if (
current_column &&
current_column.field &&
current_column.field !== 'htmlButtons' &&
current_column.field !== 'rowIndex'
) {
const labelText = current_column.title
? xss(current_column.title)
: xss(current_column.field);
const files = this.getSubmittedFilesForField(
state,
entry.submissionId,
current_column.field,
);
const value =
current_column.field === 'dateCreated'
? humanReadableDate(entry[current_column.field])
: xss(entry[current_column.field] ?? '');
contentItems.push(
files.length > 0
? {label: labelText, type: 'files', files}
: {label: labelText, value},
);
}
}
} else {
for (const [key, value] of Object.entries(entry)) {
// Skip the action buttons column and empty keys
if (!key || key === 'htmlButtons' || key === 'rowIndex') continue;
const files = this.getSubmittedFilesForField(state, entry.submissionId, key);
contentItems.push(
files.length > 0
? {
label: xss(key),
type: 'files',
files,
}
: {
label: xss(key),
value:
key === 'dateCreated'
? humanReadableDate(value)
: xss(value ?? ''),
},
);
}
}
this.currentDetailPosition = pos;
this.currentBeautyId = pos;
this.isPrevEnabled = pos !== 1;
this.isNextEnabled = pos + 1 <= this.totalNumberOfItems[state];
modal.lang = this.lang;
modal.state = state;
modal.hiddenColumns = this.hiddenColumns;
modal.isPrevEnabled = this.isPrevEnabled;
modal.isNextEnabled = this.isNextEnabled;
modal.currentBeautyId = this.currentBeautyId;
modal.totalItems = this.totalNumberOfItems[state];
modal.auth = this.auth;
modal.contentItems = contentItems;
this.showDetailedModal(state);
}
/**
* Opens submission detail Modal
*/
showDetailedModal(state) {
const modal = this.getSubmissionModal(state);
if (modal) {
modal.show();
}
}
/**
* Shows entry of a specific position of this.submissionTable
* @param {string} state - 'draft' or 'submitted'
* @param {number} positionToShow
* @param {"next"|"previous"} direction
*/
showEntryOfPos(state, positionToShow, direction) {
if (positionToShow > this.totalNumberOfItems[state] || positionToShow < 1) return;
const table = this.submissionTables[state];
if (!table) return;
let rows = table.getRows();
let next_row = rows[positionToShow - 1];
let cells = next_row.getCells();
let next_data = {};
for (let cell of cells) {
let column = cell.getColumn();
let definition = column.getDefinition();
if (definition.formatter !== 'html') {
next_data[cell.getField()] = cell.getValue();
}
}
this.setSubmissionDetailsRoute(next_data.submissionId);
this.requestDetailedSubmission(state, next_data, positionToShow);
}
// -----------------------------------------------------------------------
// Export
// -----------------------------------------------------------------------
/**
* Export the specific table
*
* @param e
*/
exportSubmissionTable(e, state) {
const exportInput = /** @type {HTMLSelectElement} */ (e.target);
if (!exportInput) return;
let exportValue = exportInput.value;
if (!exportValue || exportValue === '') return;
if (e) e.stopPropagation();
if (exportValue === 'attachments') {
// Download all attachments of the current submission table
const downloadFiles = [];
// Get selected rows or all rows if no selection
const selectedRowsObjects =
this.submissionTables[state].tabulatorTable.getSelectedRows();
let rowsToExport;
let selectedRowsSubmissionIds = [];
if (selectedRowsObjects && selectedRowsObjects.length > 0) {
rowsToExport = selectedRowsObjects;
} else {
rowsToExport = this.submissionTables[state].tabulatorTable.getRows();
}
// Get submissionIds of selected rows
rowsToExport.forEach((row) => {
const data = row.getData();
selectedRowsSubmissionIds.push(data.submissionId);
});
for (const [submissionId, attachments] of this.submittedFileDetails[state]) {
// If there are selected rows, only download the attachments of the selected rows
if (
selectedRowsSubmissionIds.length > 0 &&
!selectedRowsSubmissionIds.some((row) => row === submissionId)
) {
continue;
}
if (!attachments || attachments.length === 0) continue;
// Add folder name from the schema if available, fallback to submissionId
const downloadFolderName = this.getDownloadFolderName(submissionId, state);
attachments.forEach((attachment) => {
// No subfolders, just download all files into one folder
if (this.useSubFoldersForExports === false) {
downloadFiles.push({
name: attachment.fileName,
url: attachment.downloadUrl,
});
} else {
// Use subfolder for each submission
downloadFiles.push({
name: `${downloadFolderName}/${attachment.fileName}`,
url: attachment.downloadUrl,
});
}
});
}
this._('#file-sink').files = downloadFiles;
} else {
const table = this.submissionTables[state];
table.download(exportValue, this.activeFormName);
}
exportInput.value = '-';
}
/**
* Return the download folder name based on the pattern set in the form schema
* @param {string} submissionId - identifier of the submission
* @param {string} state - submission state (draft/submitted)
* @returns {string} - the folder name for downloading attachments
*/
getDownloadFolderName(submissionId, state) {
const SCHEMA_FIELD_PREFIX = 'schemaField/';
const SYSTEM_ATTRIBUTE_PREFIX = 'systemAttribute/';
let patternMatchingFailed = false;
if (this.useSubFoldersForExports === false) {
return '';
}
const submissionData = this.submissions[state].find((submission) => {
return submission.submissionId === submissionId;
});
if (!submissionData || !submissionData['submissionId']) {
throw new Error('Submission data not found for submissionId: ' + submissionId);
}
// Fallback to submissionId if no pattern is set or no submission data found
if (
!this.downloadFolderNamePattern ||
this.downloadFolderNamePattern === '' ||
!submissionData
) {
return submissionData['submissionId'];
}
let fieldPatterns = this.downloadFolderNamePattern.split('_');
let folderNameParts = [];
for (const fieldPattern of fieldPatterns) {
// Remove ${ and } to get the field name
const fieldName = fieldPattern.replace(/\${([a-zA-Z/]+)}/, '$1');
// System attributes
if (fieldName && fieldName.startsWith(SYSTEM_ATTRIBUTE_PREFIX)) {
const systemAttribute = fieldName.replace(SYSTEM_ATTRIBUTE_PREFIX, '');
const submission = this.rawSubmissions.find((submission) => {
return submission.identifier === submissionId;
});
if (submission && submission[systemAttribute]) {
folderNameParts.push(submission[systemAttribute].replace(/\s+/g, '-'));
} else {
patternMatchingFailed = true;
}
}
// Schema fields
if (fieldName && fieldName.startsWith(SCHEMA_FIELD_PREFIX)) {
const schemaField = fieldName.replace(SCHEMA_FIELD_PREFIX, '');
if (!submissionData[schemaField]) {
patternMatchingFailed = true;
} else {
// Get field value and replace spaces with dashes
folderNameParts.push(`${submissionData[schemaField]}`.replace(/\s+/g, '-'));
}
}
}
// Fallback to submissionId if pattern matching failed (no field data available)
if (patternMatchingFailed || folderNameParts.length === 0) {
return submissionData['submissionId'];
}
const folderName = folderNameParts.join('_');
// Cut to max 230 characters to avoid issues with long file paths
return folderName.substring(0, 230);
}
// -----------------------------------------------------------------------
// Search / filter
// -----------------------------------------------------------------------
getUrlPaginationValue(value, fallback) {
const parsedValue = Number.parseInt(value ?? '', 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : fallback;
}
updateRoutingQuery(values) {
const {pathname, queryParams, hash} = this.getRoutingData();
const updatedQueryParams = new URLSearchParams(queryParams);
for (const [name, value] of Object.entries(values)) {
if (value === undefined || value === null || value === '') {
updatedQueryParams.delete(name);
} else {
updatedQueryParams.set(name, `${value}`);
}
}
const queryString = updatedQueryParams.toString();
const routingUrl = `${pathname}${queryString ? `?${queryString}` : ''}${hash}`;
if (routingUrl !== this.routingUrl) {
this.sendSetPropertyEvent('routing-url', routingUrl, true);
}
}
getRoutingUrlWithQueryPrefixes(pathname, prefixes) {
const retainedQueryParams = new URLSearchParams();
const queryParams = this.getRoutingData().queryParams ?? new URLSearchParams();
for (const [name, value] of queryParams) {
if (prefixes.some((prefix) => name.startsWith(prefix))) {
retainedQueryParams.append(name, value);
}
}
const queryString = retainedQueryParams.toString();
return `${pathname}${queryString ? `?${queryString}` : ''}`;
}
syncFormsFilterToUrl(value) {
this.updateRoutingQuery({'forms-search': value, 'forms-page': null});
}
syncSubmissionFilterToUrl(state) {
const submissionsPage = this.getSubmissionsPage();
const searchInput = submissionsPage?.getSearchbar(state);
const searchColumn = submissionsPage?.getSearchSelect(state);
const searchOperator = submissionsPage?.getSearchOperator(state);
if (!searchInput || !searchColumn || !searchOperator) return;
this.updateRoutingQuery({
[`${state}-search`]: searchInput.value,
[`${state}-search-column`]: searchColumn.value === 'all' ? null : searchColumn.value,
[`${state}-search-operator`]:
searchOperator.value === 'like' ? null : searchOperator.value,
[`${state}-page`]: null,
});
}
async restoreFormsTableState() {
const overviewPage = this.getOverviewPage();
const searchInput = overviewPage?.getSearchbar();
const table = this.formsTable;
if (!searchInput || !table?.tabulatorTable) return;
const {queryParams} = this.getRoutingData();
const searchValue = queryParams.get('forms-search') ?? '';
const page = this.getUrlPaginationValue(queryParams.get('forms-page'), 1);
const requestedPageSize = this.getUrlPaginationValue(
queryParams.get('forms-page-size'),
table.paginationSize,
);
const pageSize = PAGINATION_SIZES.includes(requestedPageSize) ? requestedPageSize : 5;
this._restoringUrlStateTables.add(table.identifier);
try {
searchInput.value = searchValue;
table.paginationSize = pageSize;
await table.tabulatorTable.setPageSize(pageSize);
overviewPage.applySearch(searchValue);
await table.tabulatorTable.setPage(page);
} finally {
this._restoringUrlStateTables.delete(table.identifier);
this._urlStateReadyTables.add(table.identifier);
}
}
async restoreSubmissionTableState(state) {
const submissionsPage = this.getSubmissionsPage();
const searchInput = submissionsPage?.getSearchbar(state);
const searchColumn = submissionsPage?.getSearchSelect(state);
const searchOperator = submissionsPage?.getSearchOperator(state);
const table = this.submissionTables[state];
if (!searchInput || !searchColumn || !searchOperator || !table?.tabulatorTable) return;
const {queryParams} = this.getRoutingData();
const columnValue = queryParams.get(`${state}-search-column`) ?? 'all';
const operatorValue = queryParams.get(`${state}-search-operator`) ?? 'like';
const page = this.getUrlPaginationValue(queryParams.get(`${state}-page`), 1);
const requestedPageSize = this.getUrlPaginationValue(
queryParams.get(`${state}-page-size`),
table.paginationSize,
);
const pageSize = PAGINATION_SIZES.includes(requestedPageSize) ? requestedPageSize : 5;
this._restoringUrlStateTables.add(table.identifier);
try {
searchInput.value = queryParams.get(`${state}-search`) ?? '';
searchColumn.value = Array.from(searchColumn.options).some(
(option) => option.value === columnValue,
)
? columnValue
: 'all';
searchOperator.value = Array.from(searchOperator.options).some(
(option) => option.value === operatorValue,
)
? operatorValue
: 'like';
table.paginationSize = pageSize;
await table.tabulatorTable.setPageSize(pageSize);
this.filterTable(state, false);
await table.tabulatorTable.setPage(page);
} finally {
this._restoringUrlStateTables.delete(table.identifier);
this._urlStateReadyTables.add(table.identifier);
}
}
async restoreVisibleTableState() {
if (this.showFormsTable) {
await this.restoreFormsTableState();
return;
}
if (this.showSubmissionTables) {
for (const state of Object.values(SUBMISSION_STATES)) {
await this.restoreSubmissionTableState(state);
}
}
}
/**
* Filters the submissions table
*/
filterTable(state, updateUrl = true) {
const submissionsPage = this.getSubmissionsPage();
let filter = /** @type {HTMLInputElement} */ (submissionsPage?.getSearchbar(state));
let search = /** @type {HTMLSelectElement} */ (submissionsPage?.getSearchSelect(state));
let operator = /** @type {HTMLSelectElement} */ (submissionsPage?.getSearchOperator(state));
const table = this.submissionTables[state];
if (!filter || !search || !operator || !table) return;
if (filter.value === '') {
table.clearFilter();
this.searchIsActive = {...this.searchIsActive, [state]: false};
this.setVisibleRowCount(state);
if (updateUrl) this.syncSubmissionFilterToUrl(state);
return;
}
const filterValue = filter.value;
const searchValue = search.value;
const operatorValue = operator.value;
if (searchValue !== 'all') {
let filter_object = {field: searchValue, type: operatorValue, value: filterValue};
table.tabulatorTable.deselectRow();
table.setFilter([filter_object]);
this.setVisibleRowCount(state);
this.searchIsActive = {...this.searchIsActive, [state]: true};
} else {
const columns = table.getColumnsFields();
let listOfFilters = [];
for (let col of columns) {
if (col && col !== 'htmlButtons') {
let filter_object = {field: col, type: operatorValue, value: filterValue};
listOfFilters.push(filter_object);
}
}
table.tabulatorTable.deselectRow();
table.setFilter([listOfFilters]);
this.setVisibleRowCount(state);
this.searchIsActive = {...this.searchIsActive, [state]: true};
}
if (updateUrl) this.syncSubmissionFilterToUrl(state);
}
/**
* Removes the current filters from the submissions table
*/
clearAllFilters(stateToClear = null, updateUrl = true) {
const states = stateToClear ? [stateToClear] : Object.keys(this.submissionTables);
const queryUpdates = {};
for (const state of states) {
const submissionsPage = this.getSubmissionsPage();
let searchInput = /** @type {HTMLInputElement} */ (
submissionsPage?.getSearchbar(state)
);
let searchColumn = /** @type {HTMLSelectElement} */ (
submissionsPage?.getSearchSelect(state)
);
let searchOperator = /** @type {HTMLSelectElement} */ (
submissionsPage?.getSearchOperator(state)
);
const table = this.submissionTables[state];
if (!table || !searchInput || !searchColumn || !searchOperator) continue;
searchInput.value = '';
searchColumn.value = 'all';
searchOperator.value = 'like';
table.clearFilter();
this.searchIsActive = {...this.searchIsActive, [state]: false};
this.setVisibleRowCount(state);
queryUpdates[`${state}-search`] = null;
queryUpdates[`${state}-search-column`] = null;
queryUpdates[`${state}-search-operator`] = null;
queryUpdates[`${state}-page`] = null;
}
if (updateUrl) this.updateRoutingQuery(queryUpdates);
}
// -----------------------------------------------------------------------
// Event handlers
// -----------------------------------------------------------------------
/**
* Handle key events for the searchBar
*/
handleKeyEvents(event) {
const activeElement = this.getSubmissionsPage()?.shadowRoot?.activeElement;
if (activeElement && activeElement.classList.contains('searchbar')) {
// ENTER
if (event.keyCode === 13) {
event.preventDefault();
const state = activeElement.getAttribute('data-state');
this.filterTable(state);
}
}
}
/**
* Close action-dropdowns if clicked outside of the dropdown
* @param {Event} event
*/
closeActionsDropdown(event) {
const path = event.composedPath();
const actionsContainers = this.getSubmissionsPage()?.getActionsContainers() ?? [];
const clickedInsideAnyActionsDropdown = Array.from(actionsContainers).some((dropdown) =>
path.includes(dropdown),
);
if (!clickedInsideAnyActionsDropdown) {
this.closeAllActionsDropdown();
}
}
handleTablePaginationPageLoaded(event) {
const tableId = event.detail.tableId;
const state = this.getTableState(tableId);
if (state) this.setVisibleRowCount(state);
if (this._restoringUrlStateTables.has(tableId) || !this._urlStateReadyTables.has(tableId))
return;
const prefix = state ?? (tableId === this.formsTable?.identifier ? 'forms' : null);
if (!prefix) return;
const page = event.detail.page ?? 1;
const pageSize = event.detail.paginationSize ?? event.detail.pageSize ?? 5;
this.updateRoutingQuery({
[`${prefix}-page`]: page === 1 ? null : page,
[`${prefix}-page-size`]: pageSize === 5 ? null : pageSize,
});
}
setVisibleRowCount(state) {
this.visibleRowCount = {
...this.visibleRowCount,
[state]: this.submissionTables[state].tabulatorTable.getRows('active').length,
};
}
setSelectedRowCount(state) {
const selectedRows = this.submissionTables[state].tabulatorTable.getSelectedRows();
this.selectedRowCount = {...this.selectedRowCount, [state]: selectedRows.length};
}
/**
* Handle the loading indicator modal being closed.
* If the download has already started streaming, closing the modal is just
* cleanup and we must not cancel. Otherwise the user closed the modal
* (via X button or Escape) to abort the in-flight preparation requests.
*/
handleLoadingIndicatorModalClosed() {
if (this._downloadStreamingStarted) {
return;
}
const fileSink = this._('#file-sink');
if (fileSink) {
fileSink.cancelStreamedDownload();
}
}
/**
* Reset action buttons state if table selection changes
* @param {CustomEvent} tableEvent
*/
handleTableSelectionChanges(tableEvent) {
const selectedRows = tableEvent.detail.selected;
const deSelectedRows = tableEvent.detail.deselected;
// The event can fire with both arrays empty (e.g. when a table clears
// its selection while rebuilding). In that case there is no row to
// derive the originating table from, so just refresh the forms bulk
// action state (selection is now empty) and bail out.
const referenceRow = selectedRows[0] ?? deSelectedRows[0];
if (!referenceRow) {
this.setFormsActionButtonsState();
return;
}
const activeTable = referenceRow.getTable();
const root = activeTable.element.getRootNode();
if (root instanceof ShadowRoot) {
const tabulatorTableComponent = root.host;
// The forms overview table has its own selection handling for bulk deletion.
if (tabulatorTableComponent.identifier === 'forms-table') {
this.setFormsActionButtonsState();
return;
}
const state = this.getTableState(tabulatorTableComponent.identifier);
this.selectedRowCount = {...this.selectedRowCount, [state]: selectedRows.length};
this.setIsActionAvailable(state);
}
}
/** Resolves dropdown actions for the current form selection. */
setFormsActionButtonsState() {
const selectedRows = this.formsTable?.tabulatorTable?.getSelectedRows() ?? [];
this.selectedFormsCount = selectedRows.length;
const items = selectedRows
.map((row) => {
const rowData = row.getData();
return {...rowData, ...(this.forms.get(rowData.formId) ?? {})};
})
.filter(Boolean);
const context = createManageFormsOverviewActionContext(this, items);
this.formsOverviewDropdownActions = this.formsOverviewActionDefinitions
.filter((action) => action.placements?.includes('dropdown'))
.map((action) => resolveManageFormsOverviewAction(action, context))
.filter((action) => action.visible)
.map((action) => ({
value: action.id,
label: action.label,
iconName: action.iconName,
disabled: !action.enabled,
}));
}
handleFormsPageAction(event) {
const action = this.formsOverviewActionDefinitions.find(
(candidate) => candidate.id === event.detail?.action,
);
if (!action) return;
const items =
this.formsTable?.tabulatorTable
?.getSelectedData()
?.map((rowData) => ({
...rowData,
...(this.forms.get(rowData.formId) ?? {}),
}))
.filter(Boolean) ?? [];
const context = createManageFormsOverviewActionContext(this, items, event);
const resolvedAction = resolveManageFormsOverviewAction(action, context);
if (resolvedAction.visible && resolvedAction.enabled) {
action.handler(context);
}
}
handleEditFormPermission(formIds) {
const permissionDialog = this._('#form-grant-permission-dialog');
if (!permissionDialog || !Array.isArray(formIds) || formIds.length === 0) return;
permissionDialog.resourceIdentifier = formIds.length === 1 ? formIds[0] : '';
permissionDialog.resourceIdentifiers = formIds;
permissionDialog.open();
}
/**
* Deletes either the explicitly provided forms or the selected table rows.
* @param {string[]|null} formIds
*/
async handleDeleteForms(formIds = null) {
if (!this.enableFormsBulkDelete || !this.formsTable?.tabulatorTable) return;
const table = this.formsTable.tabulatorTable;
const selectedData = table.getSelectedData();
const targetIds = Array.isArray(formIds)
? formIds.filter(Boolean)
: selectedData.map((form) => form.formId).filter(Boolean);
const data = targetIds
.map(
(formId) =>
this.forms.get(formId) ?? this.allForms.find((form) => form.formId === formId),
)
.filter(Boolean);
if (data.length === 0) {
sendNotification({
summary: this._i18n.t('errors.warning-title'),
body: this._i18n.t('manage-forms.no-form-selected'),
type: 'warning',
timeout: 10,
});
return;
}
const deletionModal = this._('#deletion-modal');
// Pass form-specific confirmation wording for this invocation only.
// The override is scoped to this call and does not mutate the shared
// modal, so the submission deletion flow keeps its own text.
const confirmed = deletionModal
? await deletionModal.confirm({
messageKey: 'manage-forms.delete-forms-confirmation-message',
messageLi2Key: 'manage-forms.delete-forms-confirmation-message-li2',
})
: false;
if (!confirmed) return;
let responseStatus = [];
for (const form of data) {
const formId = form.formId;
const grants = this.formsGrantedActions.get(formId) ?? form.grantedActions ?? [];
// Skip forms the user is not allowed to delete.
if (
!grants.includes(FORM_PERMISSIONS.DELETE) &&
!grants.includes(FORM_PERMISSIONS.MANAGE)
) {
continue;
}
const response = await apiDeleteForm(this, formId);
responseStatus.push(response);
if (response === true) {
table
.getRows()
.find((row) => row.getData().formId === formId)
?.delete();
this.formsGrantedActions.delete(formId);
// Remove the deleted form from the cached lists.
this.allForms = this.allForms.filter((entry) => entry.formId !== formId);
this.forms.delete(formId);
}
}
this.noFormsAvailable = this.allForms.length === 0;
this.setFormsActionButtonsState();
// Keep the dynamically referenced notification keys in the i18next output.
keepDynamicTranslations((key) => this._i18n.t(key));
successFailureNotification(this, responseStatus, {
successKey: 'success.forms-processed',
failureKey: 'errors.forms-processing-failed',
});
}
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
setIsActionAvailable(state) {
this.setActionButtonsStates(state);
if (
this.isEditSubmissionEnabled[state] === false &&
this.isEditSubmissionPermissionEnabled[state] === false &&
this.isDeleteAllSubmissionEnabled[state] === false &&
this.isDeleteSelectedSubmissionEnabled[state] === false &&
this.isBatchTaggingEnabled[state] === false
) {
this.isActionAvailable = {...this.isActionAvailable, [state]: false};
} else {
this.isActionAvailable = {...this.isActionAvailable, [state]: true};
}
}
/**
* Toggle actions dropdown
* @param {string} state - form state. draft, or submitted
*/
toggleActionsDropdown(state) {
this.actionsWidgetIsOpen = {
...this.actionsWidgetIsOpen,
[state]: !this.actionsWidgetIsOpen[state],
};
}
/**
* Close all actions dropdowns
*/
closeAllActionsDropdown() {
this.actionsWidgetIsOpen = {
draft: false,
submitted: false,
};
}
/**
* Set action buttons states
* @param {string} state - form state. draft or submitted
*/
setActionButtonsStates(state) {
if (!this.submissionTables[state].tabulatorTable) return;
const selectedRows = this.submissionTables[state].tabulatorTable.getSelectedRows();
const allRows = this.submissionTables[state].tabulatorTable.getRows('all');
let selectedSubmissionsGrants = new Set();
for (const row of selectedRows) {
const submissionId = row.getData().submissionId;
const grants = this.submissionsGrantedActions.get(submissionId);
grants?.forEach((grant) => selectedSubmissionsGrants.add(grant));
}
let allSubmissionsGrants = new Set();
for (const row of allRows) {
const submissionId = row.getData().submissionId;
const grants = this.submissionsGrantedActions.get(submissionId);
grants?.forEach((grant) => allSubmissionsGrants.add(grant));
}
console.log(`allSubmissionsGrants`, allSubmissionsGrants);
const selectedCount = selectedRows.length;
const allCount = allRows.length;
// Replace objects with new references so Lit detects changes
this.selectedRowCount = {...this.selectedRowCount, [state]: selectedCount};
this.allRowCount = {...this.allRowCount, [state]: allCount};
this.isDeleteSelectedSubmissionEnabled = {
...this.isDeleteSelectedSubmissionEnabled,
[state]:
selectedCount > 0 &&
(selectedSubmissionsGrants.has(SUBMISSION_PERMISSIONS.MANAGE) ||
selectedSubmissionsGrants.has(SUBMISSION_PERMISSIONS.DELETE)),
};
this.isDeleteAllSubmissionEnabled = {
...this.isDeleteAllSubmissionEnabled,
[state]:
selectedCount === 0 &&
(allSubmissionsGrants.has(SUBMISSION_PERMISSIONS.MANAGE) ||
allSubmissionsGrants.has(SUBMISSION_PERMISSIONS.DELETE)),
};
this.isEditSubmissionEnabled = {
...this.isEditSubmissionEnabled,
[state]:
selectedCount === 1 &&
(selectedSubmissionsGrants.has(SUBMISSION_PERMISSIONS.MANAGE) ||
selectedSubmissionsGrants.has(SUBMISSION_PERMISSIONS.UPDATE)),
};
this.isEditSubmissionPermissionEnabled = {
...this.isEditSubmissionPermissionEnabled,
[state]:
this.enableSubmissionPermissionEditing &&
selectedCount > 0 &&
selectedRows.every((row) => {
const submissionId = row.getData().submissionId;
const grants = this.submissionsGrantedActions.get(submissionId) ?? [];
return grants.includes(SUBMISSION_PERMISSIONS.MANAGE);
}),
};
// this.isBatchTaggingEnabled = {
// ...this.isBatchTaggingEnabled,
// [state]:
// selectedCount > 0 && selectedSubmissionsGrants.has(SUBMISSION_PERMISSIONS.ADD_TAGS),
// };
// Disable batch tagging for now until we implement tag-based permissions
this.isBatchTaggingEnabled[state] = false;
}
async handleOpenBatchTaggingModal(state) {
const data = this.submissionTables[state].tabulatorTable.getSelectedData();
this.submissionIdsForTagging = data.map((submission) => submission.submissionId);
await apiGetTags(this, this.activeFormId);
this.currentStateForBatchTagging = state;
const batchTaggingModal = this._('#batch-tagging-modal');
if (batchTaggingModal) {
batchTaggingModal.availableTags = this.availableTags;
batchTaggingModal.submissionCount = this.submissionIdsForTagging.length;
batchTaggingModal.open();
}
}
handleEditSubmissionsPermission(state) {
if (!this.enableSubmissionPermissionEditing) return;
const submissionIds =
this.submissionTables[state]?.tabulatorTable
?.getSelectedData()
?.map((submission) => submission.submissionId)
.filter(Boolean) ?? [];
const permissionDialog = this._('#submission-grant-permission-dialog');
if (!permissionDialog || submissionIds.length === 0) return;
permissionDialog.resourceIdentifier = submissionIds.length === 1 ? submissionIds[0] : '';
permissionDialog.resourceIdentifiers = submissionIds;
permissionDialog.open();
}
async handleBatchTaggingConfirm(event) {
const {tags, justAdd} = event.detail;
const batchTaggingModal = this._('#batch-tagging-modal');
const button = batchTaggingModal?.getConfirmButton();
if (button) {
button.spinner = true;
button.start();
}
// PATCH submissions with the new tags.
let responseStatus = [];
let responseDetailedStatus = [];
let submissionFinalTagsMap = new Map();
for (const submissionId of this.submissionIdsForTagging) {
try {
// Find current tags from rawSubmissions
const rawSubmission = this.rawSubmissions.find(
(sub) => sub.identifier === submissionId,
);
const currentTags = rawSubmission?.tags || [];
let finalTags;
if (justAdd) {
// Add new tags to existing ones and remove duplicates
const mergedTags = [...currentTags, ...tags];
finalTags = [...new Set(mergedTags)];
} else {
// Replace all tags with selected ones
finalTags = [...tags];
}
const response = await apiUpdateSubmissionTags(this, submissionId, finalTags);
responseStatus.push(response);
responseDetailedStatus.push({status: response, submissionId: submissionId});
// Store final tags for table update and update rawSubmissions
if (response && rawSubmission) {
submissionFinalTagsMap.set(submissionId, finalTags);
rawSubmission.tags = finalTags;
}
} catch (error) {
console.error(`Failed to update tags for submission ${submissionId}:`, error);
responseStatus.push(false);
responseDetailedStatus.push({status: false, submissionId: submissionId});
}
}
// Unselect processed rows and update table
const state = this.currentStateForBatchTagging;
const tabulatorTable = this.submissionTables[state].tabulatorTable;
tabulatorTable
.getRows()
.filter((row) => {
const res = responseDetailedStatus.find(
(res) => res.submissionId === row.getData().submissionId,
);
return res && res.status === true;
})
.forEach((row) => {
const submissionId = row.getData().submissionId;
const finalTags = submissionFinalTagsMap.get(submissionId) || [];
row.update({
tags: Array.isArray(finalTags)
? finalTags.map((tag) => `<span class="tag">${xss(tag)}</span>`).join(' ')
: '',
});
row.deselect();
});
// Recalculate column widths after updating rows
tabulatorTable.redraw(true);
if (button) {
button.spinner = false;
button.stop();
}
if (batchTaggingModal) {
batchTaggingModal.close();
}
this.requestUpdate();
// Report
successFailureNotification(this, responseStatus);
}
/**
* Returns the list of creatable form modules.
* Each entry has { formId, formSlug, formName, moduleInstance }.
* The formName is retrieved from the module's getFormName() method if available,
* falling back to the URL slug for backwards compatibility.
* Also updates creatableModulesCount so the overview page can react to it.
* @returns {Array<object>}
*/
getCreatableModules() {
const modules = [];
// Iterate only the module definitions loaded from modules.json, not backend form instances.
for (const entry of this.loadedModules.values()) {
if (
!entry.moduleInstance ||
typeof entry.moduleInstance.getEditFormComponent !== 'function'
) {
continue;
}
const frontendKey =
typeof entry.moduleInstance.getFormFrontendKey === 'function'
? entry.moduleInstance.getFormFrontendKey()
: null;
if (
this.allowListFrontendKeys.length > 0 &&
(frontendKey === null || !this.allowListFrontendKeys.includes(frontendKey))
) {
continue;
}
if (
this.denyListFrontendKeys.length > 0 &&
frontendKey !== null &&
this.denyListFrontendKeys.includes(frontendKey)
) {
continue;
}
const formName =
typeof entry.moduleInstance.getFormName === 'function'
? entry.moduleInstance.getFormName(this.lang)
: entry.formSlug;
modules.push({
formId: entry.formId,
formSlug: entry.formSlug,
formName,
moduleInstance: entry.moduleInstance,
});
}
// Keep creatableModulesCount in sync so dependent components can react
this.creatableModulesCount = modules.length;
return modules;
}
/**
* Opens the edit form dialog for creating a new form.
*/
handleOpenCreateFormDialog() {
const dialog = this._('#edit-form-dialog');
if (dialog) {
dialog.existingForm = null;
dialog.creatableModules = this.getCreatableModules();
dialog.open();
}
}
/**
* Opens the edit form dialog in edit mode for the given form.
* @param {string} formId - Identifier of the form to edit.
* @param {boolean} updateRoutingUrl - Whether to publish the edit URL.
*/
handleOpenEditFormDialog(formId, updateRoutingUrl = true) {
const dialog = this._('#edit-form-dialog');
if (!dialog) return;
const formEntry = this.forms.get(formId);
if (!formEntry) return;
// Build the existingForm object the dialog and form component need
dialog.existingForm = {
formId: formEntry.formId,
formSlug: formEntry.formSlug,
formName: formEntry.formName,
moduleInstance: formEntry.moduleInstance,
additionalData: formEntry.additionalData || null,
localizedNames: formEntry.localizedNames || [],
};
if (updateRoutingUrl) {
this.sendSetPropertyEvent(
'routing-url',
this.getRoutingUrlWithQueryPrefixes(`/${formId}/edit`, ['forms-']),
true,
);
}
dialog.open();
}
handleEditFormDialogClosed(event) {
if (event.detail?.id !== 'edit-form-dialog') return;
const dialog = this._('#edit-form-dialog');
if (dialog) {
dialog.existingForm = null;
}
const {pathSegments} = this.getRoutingData();
if (pathSegments[0] && pathSegments[1] === 'edit') {
this.sendSetPropertyEvent(
'routing-url',
this.getRoutingUrlWithQueryPrefixes('/', ['forms-']),
true,
);
}
}
/**
* Handles the form creation success from the dialog's embedded component.
* Refreshes the forms list after a new form is created.
* @param {CustomEvent} event
*/
async handleCreateFormCreated(event) {
// Reload the forms list to include the newly created form
await getListOfAllForms(this);
}
/**
* Handles the form edit success from the dialog's embedded component.
* Refreshes the forms list to reflect the updated form data.
* @param {CustomEvent} event
*/
async handleEditFormSaved(event) {
await getListOfAllForms(this);
}
handleEditSubmissions(event, state) {
const data = this.submissionTables[state].tabulatorTable.getSelectedData();
const submissionId = data[0].submissionId;
// Redirect to render-form activity to display the readonly form with submission values
const activeForm = this.forms.get(this.activeFormId);
const activeFormSlug = activeForm ? activeForm.formSlug : null;
if (
activeForm &&
activeFormSlug &&
activeForm.moduleInstance &&
typeof activeForm.moduleInstance.hasReadOnlyMode === 'function' &&
activeForm.moduleInstance.hasReadOnlyMode()
) {
// Go to the edit submission
let formSubmissionUrl =
getFormRenderUrl(activeFormSlug, this.lang) + `/${submissionId}`;
const url = new URL(formSubmissionUrl);
url.searchParams.set('validate', 'true');
window.history.pushState({}, '', url);
// Middle click opens in a new tab
if (event.button === 1) {
window.open(url.toString(), '_blank');
} else {
// Left click navigates to the URL
window.location.href = url.toString();
}
} else {
sendNotification({
summary: this._i18n.t('errors.warning-title'),
body: this._i18n.t('errors.feature-not-implemented'),
type: 'warning',
timeout: 10,
});
}
}
/**
* Delete submissions visible in the table or all submissions
* @param {string} state - form state. draft or submitted
* @param {boolean} selectedOnly - if true only the selected submissions are deleted
*/
async handleDeleteSubmissions(state, selectedOnly = false) {
const data = selectedOnly
? this.submissionTables[state].tabulatorTable.getSelectedData()
: this.submissionTables[state].tabulatorTable.getData('all');
const rows = selectedOnly
? this.submissionTables[state].tabulatorTable.getSelectedRows()
: this.submissionTables[state].tabulatorTable.getRows('all');
if (data.length > 0) {
const deletionModal = this._('#deletion-modal');
const confirmed = deletionModal ? await deletionModal.confirm() : false;
if (!confirmed) return;
let responseStatus = [];
let failedRequestToSelect = [];
let index = 0;
for (const submission of data) {
const response = await apiDeleteSubmission(this, submission.submissionId);
responseStatus.push(response);
// Delete row from the table
if (response === true) {
rows[index].delete();
// Remove entry from this.submissions[state]
const filteredSubmissions = this.submissions[state].filter((sub) => {
return sub.submissionId !== submission.submissionId;
});
this.submissions = {...this.submissions, [state]: filteredSubmissions};
// Remove entry from options.data
this.options_submissions = {
...this.options_submissions,
[state]: {
...this.options_submissions[state],
data: this.options_submissions[state].data.filter((sub) => {
return sub.submissionId !== submission.submissionId;
}),
},
};
} else {
failedRequestToSelect.push(submission.submissionId);
}
index++;
}
// When every row has been deleted, explicitly clear the table so
// the last row doesn't stick around due to stale Tabulator state.
if (this.submissions[state].length === 0) {
this.submissionTables[state].tabulatorTable.clearData();
}
// Update status bar counters and action buttons state
this.setVisibleRowCount(state);
this.setSelectedRowCount(state);
this.setIsActionAvailable(state);
// Update row-indexes
this.submissionTables[state].tabulatorTable.redraw(true);
// Report
successFailureNotification(this, responseStatus);
// Re-select failed submissions
if (failedRequestToSelect.length > 0) {
const table = this.submissionTables[state];
for (const failedSubmissionId of failedRequestToSelect) {
table
.getRows()
.filter((row) => row.getData().submissionId === failedSubmissionId)
.forEach((row) => row.select());
}
}
} else {
sendNotification({
summary: this._i18n.t('errors.warning-title'),
body: this._i18n.t('errors.no-submission-selected'),
type: 'warning',
timeout: 10,
});
}
}
// -----------------------------------------------------------------------
// Navigation / routing helpers
// -----------------------------------------------------------------------
setSubmissionDetailsRoute(submissionId = null) {
const {pathSegments, queryParams, hash} = this.getRoutingData();
const formId = this.activeFormId || pathSegments[0];
if (!formId) return;
const pathname = submissionId
? `/${formId}/details/${encodeURIComponent(submissionId)}`
: `/${formId}`;
const queryString = queryParams?.toString() ?? '';
const routingUrl = `${pathname}${queryString ? `?${queryString}` : ''}${hash ?? ''}`;
if (routingUrl !== this.routingUrl) {
this.sendSetPropertyEvent('routing-url', routingUrl, true);
}
}
showFormsOverview() {
this.refreshTableReferences();
if (this.formsTable) {
this.formsTable.options = this.options_forms;
this.formsTable.data = this.allForms;
if (!this.formsTable.tableReady && !this.formsTable.tableBuilding) {
this.formsTable.buildTable();
} else if (this.formsTable.tableReady) {
const tableId = this.formsTable.identifier;
this._urlStateReadyTables.delete(tableId);
const dataLoaded = this.formsTable.setData(this.allForms);
void Promise.resolve(dataLoaded).then(() => {
if (this.showFormsTable && this.formsTable?.identifier === tableId) {
return this.restoreFormsTableState();
}
});
}
}
this.loadingFormsTable = false;
this.showFormsTable = true;
this.showSubmissionTables = false;
}
closeEditFormDialog() {
const dialog = this._('#edit-form-dialog');
if (dialog?.existingForm) {
dialog.close();
dialog.existingForm = null;
}
}
showRoutedForm(form) {
const {pathSegments} = this.getRoutingData();
if (pathSegments[1] === 'edit') {
this.showFormsOverview();
const dialog = this._('#edit-form-dialog');
if (dialog?.existingForm?.formId !== form.formId) {
this.handleOpenEditFormDialog(form.formId, false);
}
return;
}
this.closeEditFormDialog();
// Avoid rebuilding an intact submissions table after a background refresh.
const alreadyShowing =
this.activeFormId === form.formId &&
this.showSubmissionTables &&
!this.loadingSubmissionTables;
if (!alreadyShowing) {
this.switchToSubmissionTable(form);
}
}
closeAllSearchWidgets() {
this.searchWidgetIsOpen = {draft: false, submitted: false};
}
handleBackToOverview() {
this.showSubmissionTables = false;
this.loadingSubmissionTables = false;
this.clearAllFilters(null, false);
this.closeAllSearchWidgets();
this.loadingFormsTable = false;
this.showFormsTable = true;
this.activeFormId = null;
// Direct links to a form view skip the initial overview table build.
this.showFormsOverview();
this.sendSetPropertyEvent(
'routing-url',
this.getRoutingUrlWithQueryPrefixes('/', ['forms-']),
true,
);
}
// -----------------------------------------------------------------------
// Child component event handlers
// -----------------------------------------------------------------------
handleSubmissionsPageSearchToggle(event) {
const {state, open} = event.detail;
this.searchWidgetIsOpen = {
...this.searchWidgetIsOpen,
[state]: open,
};
}
handleSubmissionsPageActionsToggle(event) {
const {state, open} = event.detail;
this.actionsWidgetIsOpen = {
...this.actionsWidgetIsOpen,
[state]: open,
};
}
handleSubmissionsPageAction(event) {
const {action, state, payload} = event.detail;
const effectivePayload = payload ?? {};
const effectiveEvent = effectivePayload.event;
switch (action) {
case 'prepare-actions':
this.setActionButtonsStates(state);
break;
case 'edit-submission':
this.handleEditSubmissions(effectiveEvent, state);
break;
case 'batch-tagging':
void this.handleOpenBatchTaggingModal(state);
break;
case 'edit-permission':
if (this.isEditSubmissionPermissionEnabled[state]) {
this.handleEditSubmissionsPermission(state);
}
break;
case 'delete-all':
void this.handleDeleteSubmissions(state);
break;
case 'delete-selected':
void this.handleDeleteSubmissions(state, true);
break;
case 'export':
this.exportSubmissionTable(effectiveEvent, state);
break;
}
}
handleSubmissionModalClose() {
this.setSubmissionDetailsRoute();
}
handleSubmissionModalPrevious(event) {
const {state} = event.detail;
this.showEntryOfPos(state, this.currentDetailPosition - 1, 'previous');
}
handleSubmissionModalNext(event) {
const {state} = event.detail;
this.showEntryOfPos(state, this.currentDetailPosition + 1, 'next');
}
_onLoginClicked(e) {
this.sendSetPropertyEvent('requested-login-status', 'logged-in');
e.preventDefault();
}
// -----------------------------------------------------------------------
// Styles
// -----------------------------------------------------------------------
static get styles() {
// language=css
return css`
@layer theme, utility, formalize;
@layer theme {
${commonStyles.getThemeCSS()}
${commonStyles.getModalDialogCSS()}
${commonStyles.getRadioAndCheckboxCss()}
${commonStyles.getGeneralCSS(false)}
${commonStyles.getNotificationCSS()}
${commonStyles.getActivityCSS()}
${commonStyles.getButtonCSS()}
${getSelectorFixCSS()}
${getFileHandlingCss()}
${getTagsCSS()}
}
@layer formalize {
${getManageFormsCSS()}
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
`;
}
// -----------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------
render() {
const i18n = this._i18n;
if (this.activeFormId) {
const activeForm = this.forms.get(this.activeFormId);
const allowedSubmissionStates = activeForm?.allowedSubmissionStates;
this.enabledStates = {
draft: isDraftStateEnabled(allowedSubmissionStates),
submitted: isSubmittedStateEnabled(allowedSubmissionStates),
};
}
return html`
<div
class="notification is-warning ${classMap({
hidden: this.isLoggedIn() || this.isLoading(),
})}">
${i18n.t('error-login-message')}
<a href="#" @click="${this._onLoginClicked}">${i18n.t('error-login-link')}</a>
</div>
<div class="control ${classMap({hidden: this.isLoggedIn() || !this.isAuthPending()})}">
<span class="loading">
<dbp-mini-spinner text="${i18n.t('loading-message')}"></dbp-mini-spinner>
</span>
</div>
<div
class="${classMap({
hidden: !this.isLoggedIn() || this.isAuthPending(),
})}">
<div>
<slot name="additional-information"></slot>
</div>
<dbp-formalize-manage-forms-overview-page
lang="${this.lang}"
lang-dir="${this.langDir}"
id="overview-page"
.loadingFormsTable=${this.loadingFormsTable}
.showFormsTable=${this.showFormsTable}
.showSubmissionTables=${this.showSubmissionTables}
.optionsForms=${this.options_forms}
.paginationSizeStorageKey=${this.getPaginationSizeStorageKey()}
.noFormsAvailable=${this.noFormsAvailable}
.creatableModulesCount=${this.creatableModulesCount}
.actions=${this.formsOverviewDropdownActions}
.selectedFormsCount=${this.selectedFormsCount}
@forms-search-change=${(event) => this.syncFormsFilterToUrl(event.detail.value)}
@create-form-request=${() => this.handleOpenCreateFormDialog()}
@form-action=${(event) =>
this.handleFormsPageAction(
event,
)}></dbp-formalize-manage-forms-overview-page>
<dbp-formalize-manage-form-submissions-page
lang="${this.lang}"
lang-dir="${this.langDir}"
id="submissions-page"
.showFormsTable=${this.showFormsTable}
.showSubmissionTables=${this.showSubmissionTables}
.loadingSubmissionTables=${this.loadingSubmissionTables}
.activeFormName=${this.activeFormName}
.paginationSizeStorageKey=${this.getPaginationSizeStorageKey()}
.columnConfigurationStorageKeys=${Object.fromEntries(
Object.values(SUBMISSION_STATES).map((state) => [
state,
this.getColumnConfigurationStorageKey(
`submissions-${this.activeFormId}-${state}`,
),
]),
)}
.createSubmissionUrl=${this.createSubmissionUrl}
.hideCreateSubmissionButton=${this.hideCreateSubmissionButton}
.enabledStates=${this.enabledStates}
.noSubmissionAvailable=${this.noSubmissionAvailable}
.searchWidgetIsOpen=${this.searchWidgetIsOpen}
.actionsWidgetIsOpen=${this.actionsWidgetIsOpen}
.isActionAvailable=${this.isActionAvailable}
.isEditSubmissionEnabled=${this.isEditSubmissionEnabled}
.enableSubmissionPermissionEditing=${this.enableSubmissionPermissionEditing}
.isEditSubmissionPermissionEnabled=${this.isEditSubmissionPermissionEnabled}
.isBatchTaggingEnabled=${this.isBatchTaggingEnabled}
.isDeleteAllSubmissionEnabled=${this.isDeleteAllSubmissionEnabled}
.isDeleteSelectedSubmissionEnabled=${this.isDeleteSelectedSubmissionEnabled}
.optionsSubmissions=${this.options_submissions}
.submissions=${this.submissions}
.selectedRowCount=${this.selectedRowCount}
.allRowCount=${this.allRowCount}
.visibleRowCount=${this.visibleRowCount}
.searchIsActive=${this.searchIsActive}
.submissionsHasAttachment=${this.submissionsHasAttachment}
@back-to-overview=${() => this.handleBackToOverview()}
@submission-search-toggle=${(event) =>
this.handleSubmissionsPageSearchToggle(event)}
@submission-actions-toggle=${(event) =>
this.handleSubmissionsPageActionsToggle(event)}
@submission-search=${(event) => this.filterTable(event.detail.state)}
@submission-search-reset=${(event) => this.clearAllFilters(event.detail.state)}
@submission-action=${(event) =>
this.handleSubmissionsPageAction(
event,
)}></dbp-formalize-manage-form-submissions-page>
</div>
${Object.values(SUBMISSION_STATES).map(
(state) => html`
<dbp-formalize-manage-submission-modal
lang="${this.lang}"
id="submission-modal-${state}"
.auth=${this.auth}
.state=${state}
@detail-modal-close=${() => this.handleSubmissionModalClose()}
@detail-modal-previous=${(event) =>
this.handleSubmissionModalPrevious(event)}
@detail-modal-next=${(event) =>
this.handleSubmissionModalNext(
event,
)}></dbp-formalize-manage-submission-modal>
`,
)}
<dbp-grant-permission-dialog
id="form-grant-permission-dialog"
lang="${this.lang}"
modal-title="${i18n.t('manage-forms.edit-permission-modal-title')}"
subscribe="auth"
entry-point-url="${this.entryPointUrl}"
resource-class-identifier="DbpRelayFormalizeForm"></dbp-grant-permission-dialog>
${
this.enableSubmissionPermissionEditing
? html`
<dbp-grant-permission-dialog
id="submission-grant-permission-dialog"
lang="${this.lang}"
modal-title="${i18n.t('manage-forms.edit-permission-modal-title')}"
subscribe="auth"
entry-point-url="${this.entryPointUrl}"
resource-class-identifier="DbpRelayFormalizeSubmission"></dbp-grant-permission-dialog>
`
: ''
}
<dbp-file-sink
streamed
id="file-sink"
class="file-sink"
lang="${this.lang}"
allowed-mime-types="application/pdf,.pdf"
decompress-zip
enabled-targets="local,nextcloud"
subscribe="auth,nextcloud-auth-url,nextcloud-web-dav-url,nextcloud-name,nextcloud-file-url"></dbp-file-sink>
<dbp-formalize-deletion-confirmation-modal
id="deletion-modal"
lang-dir="${this.langDir}"
subscribe="lang"></dbp-formalize-deletion-confirmation-modal>
<dbp-formalize-batch-tagging-modal
id="batch-tagging-modal"
subscribe="lang"
@batch-tagging-confirm=${(event) =>
this.handleBatchTaggingConfirm(event)}></dbp-formalize-batch-tagging-modal>
<dbp-formalize-edit-form-dialog
id="edit-form-dialog"
lang="${this.lang}"
lang-dir="${this.langDir}"
.auth="${this.auth}"
entry-point-url="${this.entryPointUrl}"
@dbp-modal-closed=${(event) => this.handleEditFormDialogClosed(event)}
@dbp-create-form-created=${(event) => this.handleCreateFormCreated(event)}
@dbp-edit-form-saved=${(event) =>
this.handleEditFormSaved(event)}></dbp-formalize-edit-form-dialog>
`;
}
}
commonUtils.defineCustomElement('dbp-formalize-manage-forms', ManageForms);