@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,877 lines • 72.6 kB
JavaScript
import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element.js';
import {
ScopedElementsMixin,
sendNotification,
Button,
Icon,
DBPSelect,
AuthMixin,
} from '@dbp-toolkit/common';
import {ButtonTooltip} from '@dbp-toolkit/tooltip';
import {css, html} from 'lit';
import {createInstance} from '../i18n.js';
import * as commonStyles from '@dbp-toolkit/common/styles.js';
import {
getGeneralFormCSS,
getSelectorFixCSS,
getFormHeaderCSS,
getFileUploadWidgetCSS,
getTagsCSS,
} from '../styles.js';
import {
getElementWebComponents,
gatherFormDataFromElement,
validateRequiredFields,
} from '@dbp-toolkit/form-elements/src/utils.js';
import {
formatDate,
arrayToObject,
SUBMISSION_STATES,
SUBMISSION_STATES_BINARY,
SUBMISSION_COLLECTION_PERMISSIONS,
SUBMISSION_PERMISSIONS,
TAG_PERMISSIONS,
isDraftStateEnabled,
isSubmittedStateEnabled,
FORM_PERMISSIONS,
} from '../utils.js';
/** @typedef {import('lit').CSSResultGroup} CSSResultGroup */
export const FILE_SECURITY_VALIDATION_ERROR_ID =
'blob:create-file-data-file-does-not-validate-against-type';
/**
* @typedef {object} FileGroup
* @property {Map<string, File>} submittedFiles - Files already stored by the API.
* @property {Map<string, File>} filesToSubmit - Files queued for upload.
* @property {Map<string, File>} filesToRemove - Stored files queued for removal.
*/
export class BaseObject {
getUrlSlug() {
return 'url-slug';
}
getFormComponent() {
return BaseFormElement;
}
getFormIdentifier() {
return 'uuid';
}
/**
* Returns a human-readable display name for this form type.
* Override in subclasses to provide a descriptive label shown in the
* "Create new form" dialog's Form type selector.
* @returns {string}
*/
getFormName(_lang) {
return this.getUrlSlug();
}
/**
* Customizes the actions for a form row in the manage-forms overview.
* Override in subclasses to modify built-in actions or append module-specific actions.
*
* @param {object} _context
* @param {Array<Record<string, any>>} actions
* @returns {Array<Record<string, any>>}
*/
getManageFormsOverviewActions(_context, actions = []) {
return actions;
}
/**
* Indicates whether this form supports a read-only view mode.
* Override in subclasses and return true to enable the read-only
* submission view and the "open in render-form" actions.
* @returns {boolean}
*/
hasReadOnlyMode() {
return false;
}
/**
* Returns enum field translation mappings for submission display.
* Override in subclasses to translate stored enum keys into
* human-readable labels in the manage-forms submissions table.
*
* The returned object may contain:
* - Field-specific mappings: { fieldName: { key: label, ... }, ... }
* - A `_common` mapping applied as fallback for any field not listed
* explicitly: { _common: { key: label, ... } }
*
* @param {string} _lang - Language code ('en', 'de')
* @returns {object}
*/
getEnumTranslations(_lang) {
return {};
}
}
export class BaseFormElement extends AuthMixin(ScopedElementsMixin(DBPLitElement)) {
constructor() {
super();
this._i18n = createInstance();
this.lang = this._i18n.language;
this.formData = {};
this.data = {};
this.entryPointUrl = '';
this.formIdentifier = '';
this.formUrlSlug = '';
this.formProperties = {};
this.submitted = false;
this.submissionId = '';
this.currentSubmission = {};
this.userAllSubmissions = [];
this.submissionError = false;
this.readOnly = false;
this.isAdmin = false;
this.isFormValid = null;
this.hideForm = false;
/** @type {'draft' | 'submitted' | null} */
this.currentState = null;
this.allowedSubmissionStates = 4;
this.allowedActionsWhenSubmitted = [];
this.submissionBinaryState = SUBMISSION_STATES_BINARY.NONE;
this.maxNumberOfSubmissionsPerUser = 10;
this.lastModifiedCreatorName = null;
this.lastModifiedCreatorId = null;
this.fileUploadLimits = {};
this.conditionalFields = {};
// File management - dynamic structure based on schema
// Each file group (e.g., 'attachments', 'voting') will have:
// { submittedFiles: Map(), filesToSubmit: Map(), filesToRemove: Map() }
/** @type {Record<string, FileGroup>} */
this.filesByGroup = {};
this.currentUploadGroup = null; // Tracks which group is being uploaded to
this.fileUploadError = false;
this.fileUploadCounts = {};
// Tags
this.selectedTags = {};
this.allowedTags = {};
this.newSubmissionId = null;
// Responsive layout
this.tagIsInline = 'inline';
this.mediaQuery = null;
// Grants
this.formGrantedSubmissionCollectionActions = [];
this.formGrantedFormActions = [];
this.submissionGrantedActions = [];
this.isUserAllowedToEditSubmission = false;
this.isUserAllowedToEditPermission = false;
this.isUserAllowedToDeleteSubmission = false;
this.isUserAllowedToDownloadPdf = false;
// Button
this.saveButtonEnabled = true;
this.isViewModeButtonAllowed = false;
this.isDraftButtonAllowed = false;
this.isDeleteSubmissionButtonAllowed = false;
this.isSubmitButtonEnabled = false;
this.isPrintButtonAllowed = false;
// Form header observer
this._formHeaderObserved = false;
this._formHeaderObserver = null;
this.isDownloadButtonAllowed = false;
this.isSaveButtonEnabled = false;
// Event handlers
this.handleChangeEvents = this.handleChangeEvents.bind(this);
this.handleToggleSubmissionState = this.handleToggleSubmissionState.bind(this);
this.handleResize = this.handleResize.bind(this);
// Conditional fields
this.conditionalFields = {};
}
static get scopedElements() {
return {
'dbp-button': Button,
'dbp-icon': Icon,
'dbp-select': DBPSelect,
'dbp-button-tooltip': ButtonTooltip,
};
}
getToken() {
if (!this.auth?.token) {
throw new Error('Authentication token is not available');
}
return this.auth.token;
}
/**
* @returns {HTMLFormElement}
*/
get formElement() {
const formElement = this.renderRoot.querySelector('form');
if (!formElement) {
throw new Error(`${this.localName}: expected a rendered form element`);
}
return formElement;
}
getUserId() {
if (!this.auth?.['user-id']) {
throw new Error('User ID is not available');
}
return this.auth['user-id'];
}
connectedCallback() {
super.connectedCallback();
// Set up matchMedia for responsive layout
this.mediaQuery = window.matchMedia('(min-width: 350px)');
this.handleResize();
this.mediaQuery.addEventListener('change', this.handleResize);
// @ts-ignore
void this.updateComplete.then(() => {
// Event listener for form element changes
this.addEventListener('change', this.handleChangeEvents);
// Event listener for toggle submission state
this.addEventListener(
'DbpFormalizeFormToggleSubmissionState',
this.handleToggleSubmissionState,
);
});
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up matchMedia listener
if (this.mediaQuery) {
this.mediaQuery.removeEventListener('change', this.handleResize);
this.mediaQuery = null;
}
// Clean up form header observer
if (this._formHeaderObserver) {
this._formHeaderObserver.disconnect();
this._formHeaderObserver = null;
}
this._formHeaderObserved = false;
// Remove event listener for form element changes
this.removeEventListener('change', this.handleChangeEvents);
// Remove event listener for toggle submission state
this.removeEventListener(
'DbpFormalizeFormToggleSubmissionState',
this.handleToggleSubmissionState,
);
}
async validateAndSendSubmission(event) {
event.preventDefault();
const formElement = this.formElement;
// Validate the form before proceeding
const validationResult = await validateRequiredFields(formElement);
if (!validationResult) {
this.scrollToFirstInvalidField(formElement, true);
return;
}
this.sendSubmission(event);
}
/**
* Scroll to the first invalid field in the form and set the focus on it.
* @param {HTMLFormElement} formElement
*/
scrollToFirstInvalidField(formElement, setFocus = false) {
const elementWebComponents = getElementWebComponents(formElement);
for (const element of elementWebComponents) {
const invalidElement = element.shadowRoot?.querySelector('.validation-errors');
if (invalidElement) {
const fieldset = invalidElement.closest('fieldset');
const invalidFieldLabel = fieldset?.querySelector('label');
if (invalidFieldLabel) {
invalidFieldLabel.style.scrollMarginTop = '100px';
invalidFieldLabel.scrollIntoView({behavior: 'smooth', block: 'start'});
}
if (setFocus) {
/** @type {HTMLInputElement | HTMLTextAreaElement | null} */
const invalidFieldInput = fieldset?.querySelector('input, textarea') ?? null;
invalidFieldInput?.focus();
}
break;
}
}
}
toggleSubmissionState(event) {
const data = {
submissionId: this.submissionId,
};
const customEvent = new CustomEvent('DbpFormalizeFormToggleSubmissionState', {
detail: data,
bubbles: true,
composed: true,
});
this.dispatchEvent(customEvent);
}
/**
* Handle submitting draft from read-only view.
* @param {CustomEvent} event - The event object containing the form data.
*/
async handleToggleSubmissionState(event) {
const data = event.detail;
const submissionId = data.submissionId;
const formData = new FormData();
formData.append('submissionState', String(SUBMISSION_STATES_BINARY.SUBMITTED));
const method = 'PATCH';
const options = this._buildRequestOptions(formData, method);
const url = this._buildSubmissionUrl(submissionId);
try {
const response = await fetch(url, options);
if (!response.ok) {
this.submissionError = true;
await this.displayErrors(response);
} else {
this.currentState = SUBMISSION_STATES.SUBMITTED;
this.submitted = true;
// Hide form after successful submission
this.hideForm = true;
this.disableLeavePageWarning();
sendNotification({
summary: this._i18n.t('success.success-title'),
body: this._i18n.t('success.form-submitted-successfully'),
type: 'success',
timeout: 5,
});
// formDataUpdated event to notify parent component
this.dispatchEvent(
new CustomEvent('dbpFormDataUpdated', {
detail: {
needUpdate: true,
},
bubbles: true,
composed: true,
}),
);
}
} catch (error) {
console.error(error);
sendNotification({
summary: this._i18n.t('errors.error-title'),
body: this._i18n.t('errors.unknown-error-on-form-submission'),
type: 'danger',
timeout: 0,
});
}
}
/**
* Sends a submission event with the given form data.
* @param {object} event
*/
sendSubmission(event) {
this.saveButtonEnabled = false;
const formElement = this.formElement;
const data = {
formData: gatherFormDataFromElement(formElement),
submissionId: this.submissionId,
};
const customEvent = new CustomEvent('DbpFormalizeFormSubmission', {
detail: data,
bubbles: true,
composed: true,
});
this.dispatchEvent(customEvent);
}
/**
* Sends a draft submission event with the given form data.
* @param {object} event
*/
sendSaveDraft(event) {
this.draftButtonEnabled = false;
const formElement = this.formElement;
const data = {
formData: gatherFormDataFromElement(formElement),
submissionId: this.submissionId,
};
const customEvent = new CustomEvent('DbpFormalizeFormSaveDraft', {
bubbles: true,
composed: true,
detail: data,
});
this.dispatchEvent(customEvent);
}
/**
* Sends a delete submission event with the submission ID to delete.
*/
sendDeleteSubmission() {
if (!this.submissionId) {
return;
}
const data = {
submissionId: this.submissionId,
};
const customEvent = new CustomEvent('DbpFormalizeFormDeleteSubmission', {
bubbles: true,
composed: true,
detail: data,
});
this.dispatchEvent(customEvent);
}
/**
* Sends an save submission event with the submission ID to PATCH.
*/
async sendSaveSubmission() {
if (!this.submissionId) {
return;
}
const formElement = this.formElement;
// Validate the form before proceeding
const validationResult = await validateRequiredFields(formElement);
if (!validationResult) {
this.scrollToFirstInvalidField(formElement);
return;
}
const data = {
submissionId: this.submissionId,
formData: gatherFormDataFromElement(formElement),
};
const customEvent = new CustomEvent('DbpFormalizeFormSaveSubmission', {
bubbles: true,
composed: true,
detail: data,
});
this.dispatchEvent(customEvent);
}
disableLeavePageWarning() {
const disableEvent = new CustomEvent('disableBeforeunloadWarning', {
bubbles: true,
composed: true,
});
this.dispatchEvent(disableEvent);
}
static get properties() {
return {
...super.properties,
lang: {type: String},
person: {type: Object},
additionalType: {type: String, attribute: 'additional-type'},
// For some reason, the attribute this.data was reset every time a new auth
// object was set by Keycloak, so we use this.formData instead
formData: {type: Object, attribute: false},
data: {type: Object},
auth: {type: Object},
submitted: {type: Boolean, attribute: false},
entryPointUrl: {type: String, attribute: 'entry-point-url'},
formIdentifier: {type: String, attribute: 'form-identifier'},
formUrlSlug: {type: String, attribute: 'form-url-slug'},
submissionError: {type: Boolean, attribute: false},
hideForm: {type: Boolean, attribute: false},
readOnly: {type: Boolean, attribute: 'read-only'},
isFormValid: {type: Boolean, attribute: false},
tagIsInline: {type: String, attribute: false},
allowedSubmissionStates: {type: Number, attribute: 'allowed-submission-states'},
maxNumberOfSubmissionsPerUser: {type: String, attribute: 'max-number-of-submissions'},
lastModifiedCreatorName: {type: String, attribute: false},
formProperties: {type: Object},
userAllSubmissions: {type: Array},
conditionalFields: {type: Object, attribute: false},
saveButtonEnabled: {type: Boolean, attribute: false},
// Buttons
isViewModeButtonAllowed: {type: Boolean, attribute: false},
isDeleteSubmissionButtonAllowed: {type: Boolean, attribute: false},
isDraftButtonAllowed: {type: Boolean, attribute: false},
isSubmitButtonEnabled: {type: Boolean, attribute: false},
isPrintButtonAllowed: {type: Boolean, attribute: false},
isDownloadButtonAllowed: {type: Boolean, attribute: false},
};
}
/** @returns {CSSResultGroup} */
static get styles() {
// language=css
return css`
@layer theme, utility, formalize, print;
@layer theme {
${commonStyles.getGeneralCSS(false)}
${commonStyles.getButtonCSS()}
${getSelectorFixCSS()}
.button-row {
margin-top: 1em;
text-align: right;
}
}
@layer formalize {
${getGeneralFormCSS()}
${getFormHeaderCSS()}
${getTagsCSS()}
${getFileUploadWidgetCSS()}
.after-submission-button-container {
display: flex;
justify-content: flex-start;
gap: 1em;
margin-top: 2em;
}
.after-submission-button-container .button dbp-icon {
padding-right: 0.5em;
}
}
`;
}
/**
* Reset submission / state related fields end emit DbpFormalizeFormReset event.
*/
resetForm(event) {
event.preventDefault();
this.saveButtonEnabled = true;
this.currentSubmission = {};
this.formData = {};
this.data = {};
this.currentState = null;
this.lastModifiedCreatorName = '';
this.submissionBinaryState = 0;
this.submissionId = '';
const customEvent = new CustomEvent('DbpFormalizeFormReset', {
bubbles: true,
composed: true,
});
this.dispatchEvent(customEvent);
}
/**
* Render the buttons needed for the form.
* @returns {import('lit').TemplateResult} HTML for the button row.
*/
getButtonRowHtml() {
const i18n = this._i18n;
this.formActions = [];
if (this.isUserAllowedToEditSubmission) {
this.formActions.push({
value: 'cancel',
label: this.readOnly
? i18n.t('render-form.forms.base-object.edit-mode')
: i18n.t('render-form.forms.base-object.view-mode'),
iconName: this.readOnly ? 'pencil' : 'close',
});
}
if (this.isUserAllowedToEditPermission) {
this.formActions.push({
value: 'edit-permissions',
label: i18n.t('render-form.forms.base-object.edit-permission-button-text'),
iconName: 'edit-permission',
});
}
if (this.isUserAllowedToDownloadPdf) {
this.formActions.push({
value: 'download',
label: i18n.t('render-form.forms.base-object.download-button-text'),
iconName: 'download',
});
}
if (this.isUserAllowedToDeleteSubmission) {
this.formActions.push({
value: 'delete',
label:
this.currentState === SUBMISSION_STATES.SUBMITTED
? i18n.t(
'render-form.forms.base-object.delete-submission-button-text-label',
)
: i18n.t('render-form.forms.base-object.discard-draft-button-text-label'),
iconName: 'trash',
});
}
return html`
<div class="header-top">
<div class="submission-info-wrapper">${this.renderSubmissionInfo()}</div>
<div class="tag-management">${this.renderHeaderTags()}</div>
</div>
<div class="buttons-wrapper">
<div class="status-tags-wrapper">
${this.renderStatusTags()}
${this.readOnly ? '' : this.renderFormValidityIndicator()}
</div>
<div class="action-buttons" id="action-buttons" tabindex="-1">
${
this.formActions.length > 0
? html`
<dbp-select
id="action-dropdown"
class="action-dropdown"
label="${i18n.t(
'render-form.forms.base-object.actions-dropdown-label',
)}"
.options="${this.formActions}"></dbp-select>
`
: ''
}
${
this.isViewModeButtonAllowed
? html`
<dbp-button
id="toggle-edit-mode"
class="toggle-edit-mode"
type="is-secondary"
no-spinner-on-click
title="${i18n.t(
'render-form.forms.base-object.toggle-edit-submission-button-title',
)}"
@click="${() => {
if (this.readOnly) {
this.redirectToEditForm(true);
return;
}
const confirmed = confirm(
this._i18n.t('render-form.form-exit-warning-message'),
);
if (confirmed) {
const form = this.formElement;
const data = gatherFormDataFromElement(form);
if (Object.keys(data).length) {
this.formData = data;
}
this.disableLeavePageWarning();
this.redirectToReadonlyForm();
this.readOnly = !this.readOnly;
return;
} else {
// Do nothing if cancel was clicked
return;
}
}}">
${
this.readOnly
? html`
<dbp-icon name="pencil"></dbp-icon>
<span class="button-label">
${i18n.t('render-form.forms.base-object.edit-mode')}
</span>
`
: html`
<dbp-icon name="close"></dbp-icon>
<span class="button-label">
${i18n.t('render-form.forms.base-object.view-mode')}
</span>
`
}
</dbp-button>
`
: ''
}
${
this.isDraftButtonAllowed
? html`
<dbp-button
class="form-save-draft-button"
type="is-secondary"
no-spinner-on-click
@click=${this.sendSaveDraft}
title="${i18n.t(
'render-form.forms.base-object.save-draft-button-text',
)}"
aria-label="${i18n.t(
'render-form.forms.base-object.save-draft-button-text',
)}">
<dbp-icon name="save" aria-hidden="true"></dbp-icon>
<span class="button-label">
${i18n.t(
'render-form.forms.base-object.save-draft-button-text',
)}
</span>
</dbp-button>
`
: ''
}
${
this.isSaveButtonEnabled
? html`
<dbp-button
class="form-save-button"
type="is-primary"
no-spinner-on-click
@click=${this.sendSaveSubmission}
title="${i18n.t(
'render-form.forms.base-object.save-button-text',
)}"
aria-label="${i18n.t(
'render-form.forms.base-object.save-button-text',
)}">
<dbp-icon
name="checkmark-circle"
aria-hidden="true"></dbp-icon>
<span class="button-label">
${i18n.t('render-form.forms.base-object.save-button-text')}
</span>
</dbp-button>
`
: ''
}
${
this.isSubmitButtonEnabled
? html`
<dbp-button
class="form-submit-button"
type="is-primary"
no-spinner-on-click
@click=${(event) => {
if (this.readOnly) {
this.toggleSubmissionState(event);
} else {
void this.validateAndSendSubmission(event);
}
}}
title="${i18n.t(
'render-form.forms.base-object.submit-button-text',
)}"
aria-label="${i18n.t(
'render-form.forms.base-object.submit-button-text',
)}">
<dbp-icon name="send-diagonal" aria-hidden="true"></dbp-icon>
<span class="button-label">
${i18n.t('render-form.forms.base-object.submit-button-text')}
</span>
</dbp-button>
`
: ''
}
</div>
</div>
`;
}
/**
* Render form tag management in the header.
* @returns {import('lit').TemplateResult} - HTML for the tag management section in the header.
*/
renderHeaderTags() {
if (Object.keys(this.allowedTags).length === 0) {
return html``;
}
if (
this.formProperties.tagPermissionsForSubmitters === TAG_PERMISSIONS.NONE &&
!this.isAdmin
) {
return html``;
}
if (this.readOnly) {
if (
this.formProperties.tagPermissionsForSubmitters >= TAG_PERMISSIONS.READ ||
this.isAdmin
) {
return Object.values(this.selectedTags).length > 0
? html`
<dbp-form-enum-view
subscribe="lang"
name="formTags"
display-mode="tags"
layout-type="inline"
label="Tags:"
.value=${Object.values(this.selectedTags)}></dbp-form-enum-view>
`
: html``;
}
return html``;
} else {
// Edit mode
if (
this.isAdmin ||
this.formProperties.tagPermissionsForSubmitters >= TAG_PERMISSIONS.READ_ADD
) {
return html`
<dbp-form-enum-element
id="form-tags"
name="formTags"
subscribe="lang"
label="Tags:"
@change="${(event) => {
if (this.allowedTags && Object.keys(this.allowedTags).length > 0) {
this.setCurrentTags(event);
}
}}"
display-mode="tags"
layout-type="${this.tagIsInline}"
multiple
.tagPlaceholder=${{en: 'Select tags', de: 'Wähle Tags'}}
.value=${Object.values(this.selectedTags)}
.items=${this.allowedTags}></dbp-form-enum-element>
`;
} else {
if (
this.formProperties.tagPermissionsForSubmitters >= TAG_PERMISSIONS.READ &&
Object.values(this.selectedTags).length > 0
) {
return html`
<dbp-form-enum-view
subscribe="lang"
name="formTags"
display-mode="tags"
layout-type="inline"
label="Tags:"
.value=${Object.values(this.selectedTags)}></dbp-form-enum-view>
`;
}
return html``;
}
}
}
/**
* Handle responsive layout based on viewport width
*/
handleResize() {
this.tagIsInline = this.mediaQuery?.matches ? 'inline' : '';
}
/**
* Set the current selected tags from the event.
* @param {CustomEvent} event - The change event containing selected tags.
*/
setCurrentTags(event) {
if (Object.keys(this.allowedTags).length === 0) return;
let selectedTags = {};
const selectedTagsFromEvent = event.detail.value;
if (selectedTagsFromEvent.length > 0) {
for (const tagName of selectedTagsFromEvent) {
selectedTags[tagName] = tagName;
}
}
this.selectedTags = selectedTags;
}
/**
* Render status tags for the form.
* @returns {import('lit').TemplateResult} - HTML for the status tags.
*/
renderStatusTags() {
const stateTag = this.currentState;
const tagTranslations = {
draft: this._i18n.t('render-form.forms.base-object.draft-state-tag-label'),
submitted: this._i18n.t('render-form.forms.base-object.submitted-state-tag-label'),
};
const modeTag = !this.readOnly
? this._i18n.t('render-form.forms.base-object.edit-mode-tag-label')
: '';
return html`
<div class="tag-container">
${
stateTag
? html`
<span class="tag tag--state">${tagTranslations[stateTag]}</span>
`
: ''
}
${
modeTag
? html`
<span class="tag tag--mode">${modeTag}</span>
`
: ''
}
</div>
`;
}
/**
* Render form validity indicator based on the isFormValid property.
* @returns {import('lit').TemplateResult} - HTML for the form validity indicator.
*/
renderFormValidityIndicator() {
if (!this.submissionId) return html``;
if (this.isFormValid) {
return html`
<div class="form-validity-indicator valid">
<dbp-icon name="checkmark-circle" aria-hidden="true"></dbp-icon>
<span class="validity-text">
${this._i18n.t('render-form.forms.base-object.form-validity-valid-text')}
</span>
<dbp-button-tooltip
text-content="${this._i18n.t(
'render-form.forms.base-object.form-validity-valid-tooltip',
)}"
type="button"
button-text=" "
icon-name="question-circle"></dbp-button-tooltip>
</div>
`;
} else {
return html`
<div class="form-validity-indicator invalid">
<dbp-icon name="cross-circle" aria-hidden="true"></dbp-icon>
<span class="validity-text">
${this._i18n.t('render-form.forms.base-object.form-validity-invalid-text')}
</span>
<dbp-button-tooltip
text-content="${this._i18n.t(
'render-form.forms.base-object.form-validity-invalid-tooltip',
)}"
button-text=" "
type="button"
icon-name="question-circle"></dbp-button-tooltip>
</div>
`;
}
}
/**
* Redirects to the readonly form by appending '/readonly' to the current URL.
*/
redirectToReadonlyForm() {
const currentUrl = window.location.href;
const url = new URL(currentUrl);
const pathname = url.pathname.replace(/\/+$/, '');
url.pathname = !pathname.match(/\/readonly$/) ? pathname + '/readonly' : pathname;
url.searchParams.delete('validate');
window.history.pushState({}, '', url);
// Redirect to the new URL
window.location.href = url.toString();
}
/**
* Redirects to the edit form by removing '/readonly' from the current URL.
*/
redirectToEditForm(requireValidation = false) {
const currentUrl = window.location.href;
const url = new URL(currentUrl);
if (url.pathname.endsWith('/readonly')) {
// Remove '/readonly' from the pathname
const readOnlyPath = url.pathname.replace(/\/readonly$/, '');
url.pathname = readOnlyPath;
if (requireValidation) {
// Append 'validate=true' query parameter
url.searchParams.set('validate', 'true');
}
window.history.pushState({}, '', url);
// Redirect to the new URL
window.location.href = url.toString();
}
}
/**
* Evaluates whether a condition is met for a given value.
* @param {string|string[]} value - The field value to check
* @param {string} condition - The condition string from data-condition attribute
* @returns {boolean} Whether the condition is met
*/
evaluateCondition(value, condition) {
// Try to parse condition as JSON array
/** @type {string[] | null} */
let conditionArray = null;
if (condition.startsWith('[') && condition.endsWith(']')) {
try {
conditionArray = JSON.parse(condition);
} catch (e) {
console.warn('Failed to parse condition as JSON array:', e);
// If parsing fails, treat as string
conditionArray = null;
}
}
let isConditionMet;
// Handle array conditions
if (conditionArray !== null && Array.isArray(conditionArray)) {
if (Array.isArray(value)) {
// Check if any value item is in condition array
isConditionMet = value.some((v) => conditionArray.includes(v));
} else {
// Check if value is in condition array
isConditionMet = conditionArray.includes(value);
}
}
// Handle negation with ! prefix
else if (condition.startsWith('!')) {
const negatedCondition = condition.substring(1);
if (Array.isArray(value)) {
isConditionMet = value.includes(negatedCondition) ? false : true;
} else {
isConditionMet = value !== negatedCondition;
}
} else {
if (Array.isArray(value)) {
isConditionMet = value.includes(condition) ? true : false;
} else {
isConditionMet = value === condition;
}
}
return isConditionMet;
}
/**
* Update formData if field value changes
* @param {CustomEvent} event
*/
handleChangeEvents(event) {
// Action dropdown buttons
if (event.detail && event.detail.option && event.detail.value) {
const option = event.detail.option;
const value = event.detail.value;
// Toggle readOnly / edit mode
if (option.value === 'cancel' && value === 'cancel') {
if (this.readOnly) {
this.redirectToEditForm(true);
return;
}
const confirmed = confirm(
this._i18n.t('render-form.forms.base-object.form-exit-warning-message'),
);
if (confirmed) {
const form = this.formElement;
const data = gatherFormDataFromElement(form);
if (Object.keys(data).length) {
this.formData = data;
}
this.disableLeavePageWarning();
this.redirectToReadonlyForm();
this.readOnly = !this.readOnly;
return;
} else {
// Do nothing if cancel was clicked
return;
}
}
if (option.value === 'delete' && value === 'delete') {
this.sendDeleteSubmission();
return;
}
if (option.value === 'save' && value === 'save') {
void this.sendSaveSubmission();
return;
}
}
// Form elements
if (event.detail && event.detail.fieldName && event.detail.value) {
const fieldName = event.detail.fieldName;
const value = event.detail.value;
// Update form data
this.formData[fieldName] = value;
// Handle conditional fields
const field = this._(`[name=${fieldName}]`);
const condition = field.dataset?.condition;
if (!condition) return;
const isConditionMet = this.evaluateCondition(value, condition);
// Update conditional fields
this.conditionalFields = {
...this.conditionalFields,
[fieldName]: isConditionMet,
};
}
}
/**
* Handle conditional fields initialization.
*/
async processConditionalFields() {
const conditionalFields = this._a('[data-condition]');
const conditionalFieldsCount = conditionalFields.length;
if (conditionalFields.length === 0) return;
conditionalFields.forEach((field) => {
const value = field.value;
if (value === undefined || value === '') return;
const fieldName = field.getAttribute('name');
const condition = field.dataset.condition;
const isConditionMet = this.evaluateCondition(value, condition);
if (this.conditionalFields[fieldName] !== undefined) {
this.conditionalFields = {
...this.conditionalFields,
[fieldName]: isConditionMet,
};
}
});
await this.updateComplete;
// Run again to handle conditional fields inside other conditional fields just rendered
const newConditionalFieldsCount = this._a('[data-condition]').length;
if (newConditionalFieldsCount > conditionalFieldsCount) {
await this.processConditionalFields();
}
}
/**
* Render submission details
* Creation date, last modified, last modified by.
* @returns {import('lit').TemplateResult} The HTML template result
*/
renderSubmissionInfo() {
const i18n = this._i18n;
const dateCreated = formatDate(this.currentSubmission.dateCreated);
const dateLastModified = formatDate(this.currentSubmission.dateLastModified);
const deadLine = formatDate(this.currentSubmission.availabilityEnds);
return html`
<div class="submission-info">
${
deadLine
? html`
<div class="submission-deadline">
<span class="label">
${i18n.t(
'render-form.forms.base-object.submission-deadline-label',
)}:
</span>
<span class="value">${deadLine}</span>
</div>
`
: ''
}
<div class="submission-date">
<span class="label">
${i18n.t('render-form.forms.base-object.submission-creation-date-label')}:
</span>
<span class="value">
${
dateCreated ||
html`
—
`
}
</span>
</div>
<div class="last-modified">
<span class="label">
${i18n.t('render-form.forms.base-object.last-modified-date-label')}:
</span>
<span class="value">
${
dateLastModified ||
html`
—
`
}
</span>
</div>
<div class="last-modified-by">
<span class="label">
${i18n.t('render-form.forms.base-object.last-modified-by-name-label')}:
</span>
<span class="value">
${
this.lastModifiedCreatorName ||
html`
—
`
}
</span>
</div>
</div>
`;
}
update(changedProperties) {
changedProperties.forEach((oldValue, propName) => {
switch (propName) {
case 'lang':
void this._i18n.changeLanguage(this.lang);
break;
}
});
super.update(changedProperties);
if (changedProperties.has('data')) {
// @ts-ignore
// this.updateComplete.then(async () => {
// await this.processConditionalFields();
// // If query parameter 'validate' is set to true, validate required fields
// const urlParams = new URLSearchParams(window.location.search);
// if (this.readOnly === false && urlParams.get('validate') === 'true') {
// const formElement = this.shadowRoot.querySelector('form');
// this.isFormValid = await validateRequiredFields(formElement);
// if (!this.isFormValid) {
// this.scrollToFirstInvalidField(formElement, true);
// // Show notification
// sendNotification({
// summary: this._i18n.t('errors.warning-title'),
// body: this._i18n.t('errors.form-validation-warning-notification-body'),
// type: 'warning',
// timeout: 5,
// });
// }
// }
// });
}
if (changedProperties.has('userAllSubmissions') || changedProperties.has('readOnly')) {
this.setButtonStates();
}
if (changedProperties.has('formProperties')) {
if (Object.keys(this.formProperties).length > 0) {
this.allowedActionsWhenSubmitted = this.formProperties.allowedActionsWhenSubmitted;
this.formGrantedSubmissionCollectionActions =
this.formProperties.grantedSubmissionCollectionActions;
this.formGrantedFormActions = this.formProperties.grantedFormActions;
this.allowedTags = arrayToObject(this.formProperties.availableTags);
this.fileUploadLimits = this.getFileUploadLimits(
this.formProperties?.dataFeedSchema,
);
this.isAdmin = this.formGrantedSubmissionCollectionActions.some(
(grant) =>
grant === SUBMISSION_COLLECTION_PERMISSIONS.MANAGE ||
grant === SUBMISSION_COLLECTION_PERMISSIONS.UPDATE,
);
this.setButtonStates();
}
}
}
/**
* Get file groups from the form schema.
* @returns {string[]} Array of file group names
*/
getFileGroupsFromSchema() {
if (!this.formProperties?.dataFeedSchema) {
return [];
}
try {
const schema = JSON.parse(this.formProperties.dataFeedSchema);
// Check for catch-all schema with additionalFiles
if (schema.additionalFiles === true && !schema.files) {
// Catch-all schema allows any file groups
// Return empty array, groups will be created dynamically as needed
return [];
}
// Return defined file groups from schema
if (schema.files && typeof schema.files === 'object') {
return Object.keys(schema.files);
}
return [];
} catch (e) {
console.error('Failed to parse schema for file groups:', e);
return [];
}
}
/**
* Initialize file groups structure based on schema.
* Creates the nested Map structure for each file group.
*/
initializeFileGroups() {
const fileGroups = this.getFileGroupsFromSchema();
// Initialize structure for each defined file group
for (const fileGroup of fileGroups) {
if (!this.filesByGroup[fileGroup]) {
this.filesByGroup[fileGroup] = {
submittedFiles: new Map(),
filesToSubmit: new Map(),
filesToRemove: new Map(),
};
}
}
}
/**
* Get or create a file group structure.
* Handles dynamic creation for catch-all schemas.
* @param {string} fileGroup - The file group name
* @returns {FileGroup} The file group structure
*/
getOrCreateFileGroup(fileGroup) {
if (!this.filesByGroup[fileGroup]) {
this.filesByGroup[fileGroup] = {
submittedFiles: new Map(),
filesToSubmit: new Map(),
filesToRemove: new Map(),
};
}
return this.filesByGroup[fileGroup];
}
/**
* Sets the button states based on the submission state and user permissions.
*/
setButtonStates() {
this.isViewModeButtonAllowed = false;
this.isDraftButtonAllowed = false;
this.isDeleteSubmissionButtonAllowed = false;
this.isSubmitButtonEnabled = false;
this.isPrintButtonAllowed = false;
this.isDownloadButtonAllowed = false;
this.isSaveButtonEnabled = false;
// Dropdown actions
this.isUserAllowedToEditSubmission = false;
this.isUserAllowedToEditPermission = false;
this.isUserAllowedToDeleteSubmission = false;
this.isUserAllowedToDownloadPdf = false;
// No state
if (this.submissionBinaryState === SUBMISSION_STATES_BINARY.NONE) {
this.isDraftButtonAllowed =
isDraftStateEnabled(this.allowedSubmissionStates) && this.canUserSubmitSubmission();
this.isSubmitButtonEnabled =
isSubmittedStateEnabled(this.allowedSubmissionStates) &&
this.canUserSubmitSubmission();
}
// DRAFT
if (this.currentState === SUBMISSION_STATES.DRAFT) {
this.isSubmitButtonEnabled =
isSubmittedStateEnabled(this.allowedSubmissionStates) &&
(this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.UPDATE));
if (!this.readOnly) {
// edit mode
this.isViewModeButtonAllowed = true;
this.isDraftButtonAllowed =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.UPDATE);
} else {
// view mode
this.isUserAllowedToDownloadPdf = true;
this.isUserAllowedToEditSubmission =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.UPDATE);
this.isUserAllowedToEditPermission = this.submissionGrantedActions.includes(
SUBMISSION_PERMISSIONS.MANAGE,
);
this.isUserAllowedToDeleteSubmission =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.DELETE);
}
}
// SUBMITTED
if (this.currentState === SUBMISSION_STATES.SUBMITTED) {
if (!this.readOnly) {
// edit mode
this.isViewModeButtonAllowed = true;
this.isSaveButtonEnabled =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.UPDATE);
} else {
// view mode
this.isUserAllowedToDownloadPdf = true;
this.isUserAllowedToEditSubmission =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.UPDATE);
this.isUserAllowedToEditPermission = this.submissionGrantedActions.includes(
SUBMISSION_PERMISSIONS.MANAGE,
);
this.isUserAllowedToDeleteSubmission =
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.MANAGE) ||
this.submissionGrantedActions.includes(SUBMISSION_PERMISSIONS.DELETE);
}
}
}
/**
* @typedef {object} AttachmentApiFile
* @property {string} fileName - name of the file
* @property {string} downloadUrl - blob download URL
* @property {number} [fileSize] - size of the file
* @property {string} fileAttributeName - file attribute name
* @property {string} identifier - file identifier uuid
* @property {string} mimeType - file MIME type
*/
/**
* Transforms the API response to a File object.
* @param {AttachmentApiFile[]} apiFileResponse
* @returns {Promise<Map<string, File>>} A promise that resolves to a map of file identifiers to File objects
*/
async transformApiResponseToFile(apiFileResponse) {
if (!apiFileResponse || apiFileResponse.length === 0) {
return new Map();
}
const attachedFiles = new Map();
try {
for (const apiFile of apiFileResponse) {
// Fetch the file content from the download URL
const options = {
method: 'GET',
headers: {
Authorization: 'Bearer ' + this.getToken(),
},
};
const response = await fetch(apiFile.downloadUrl, options);
if (!response.ok) {
sendNotification({
summary: this._i18n.t('errors.other-title'),
body: this._i18n.t('errors.other-body'),
type: 'danger',
timeout: 0,
});
} else {
const fileBlob = await response.blob();
// Create a new File object
const attachmentFile = new File([fileBlob], apiFile.fileName, {
type: apiFile.mimeType,
});
attachedFiles.set(apiFile.identifier, attachmentFile);
}
}
return attachedFiles;
} catch (error) {
console.error('Error transforming API response to File:', error);
throw error;
}
}
/**
* Handle files selected for upload from file source component.
* @param {CustomEvent} event - The event object containing the file data.
*/
handleFilesToSubmit(event) {
const fileGroup = this.currentUploadGroup || 'attachments';
const groupData = this.getOrCreateFileGroup(fileGroup);
const files = event.detail.file ? [event.detail.file] : [];
if (files && files.length > 0) {
for (const file of files) {
const identifier = crypto.randomUUID();
groupData.filesToSubmit.set(identifier, file);
}
}
this.requestUpdate();
}
/**
* Handle removing files from the list of attachments.
* @param {string} fileIdentifier - uuid
* @param {string} fileGroup - The group of files to handle (e.g., 'attachments', 'voting')
*/
deleteAttachment(fileIdentifier, fileGroup) {
const groupData = this.getOrCreateFileGroup(fileGroup);
const submittedFile = groupData.submittedFiles.get(fileIdentifier);
if (submittedFile !== undefined) {
groupData.filesToRemove.set(fileIdentifier, submittedFile);
groupData.submittedFiles.delete(fileIdentifier);
} else if (groupData.filesToSubmit.has(fileIdentifier)) {
groupData.filesToSubmit.delete(fileIdentifier);
}
this.requestUpdate();
}
/**
* Renders attached file list with action buttons.
* @param {string} fileGroup - The group of files to render (e.g., 'attachments', 'voting')
* @returns {Array|null} An array of rendered file elements or null if no files are present.
*/
renderAttachedFilesHtml(fileGroup = 'attachments') {
const groupData = this.getOrCreateFileGroup(fileGroup);
const i18n = this._i18n;
let results = [];
// Render submitted files
if (groupData.submittedFiles.size > 0) {
const submittedFilesHtml = html`
<div class="fileblock-container submitted-files">
${Array.from(groupData.submittedFiles).map(([identifier, file]) => {
return this.addFileBlock(file, identifier, fileGroup);
})}
</div>
`;
results.push(submittedFilesHtml);
}
// Render files to submit
if (groupData.filesToSubmit.size > 0) {
const filesToSubmitHtml = html`
<div class="fileblock-container files-to-upload">
<div class="attachment-header">
<dbp-icon name="upload"></dbp-icon>
<h5>
${i18n.t('render-form.download-widget.attachment-upload-file-text')}
<span class="attachment-warning">
${i18n.t(
'render-form.download-widget.attachment-upload-warning-text',
)}
</span>
</h5>
</div>
${Array.from(groupData.filesToSubmit).map(([identifier, file]) => {
return this.addFileBlock(file, identifier, fileGroup);
})}
</div>
`;
results.push(filesToSubmitHtml);
}
// Render files marked for removal
if (groupData.filesToRemove.size > 0) {
const filesToRemoveHtml = html`
<div class="fileblock-container files-to-remove">
<div class="attachment-header">
<dbp-icon name="trash"></dbp-icon>
<h5>
${i18n.t('render-form.download-widget.attachment-remove-file-text')}
<span class="attachment-warning">
${i18n.t(
'render-form.download-widget.attachment-deletion-warning-text',
)}
</span>
</h5>
</div>
${Array.from(groupData.filesToRemove).map(([identifier, file]) => {
return this.addFileBlock(file, identifier, fileGroup);
})}
</div>
`;
results.push(filesToRemoveHtml);
}
return results;
}
/**
* Renders a single file block with action buttons.
* @param {File} file - The file object
* @param {string} identifier - Unique identifier for the file
* @param {string} fileGroup - The group of files
* @param {boolean} viewPdf - Whether to show the PDF viewer button
* @returns {import('lit').TemplateResult} The rendered file block template.
*/
addFileBlock(file, identifier, fileGroup = 'attachments', viewPdf = true) {
const groupData = this.getOrCreateFileGroup(fileGroup);
const isPdf = file.type === 'application/pdf';
const canViewPdf =
viewPdf && isPdf && this._('#pdf-view-modal') && this._('dbp-pdf-viewer');
return html`
<div class="file-block">
<span class="file-info">
<strong class="file-name">${file.name}</strong>
<span class="additional-data">
<span class="file-type">(${file.type})</span>
<span class="file-size">${(file.size / 1024).toFixed(2)} KB</span>
</span>
</span>
<div class="file-action-buttons">
${
canViewPdf
? html`
<button
class="view-file-button button is-secondary"
title="${this._i18n.t('render-form.download-widget.view-attachment-title', {filename: file.name})}"
aria-label="${this._i18n.t('render-form.download-widget.view-attachment-title', {filename: file.name})}"
@click=${(e) => {
e.preventDefault();
const pdfModal = this._('#pdf-view-modal');
const pdfViewer = this._('dbp-pdf-viewer');
if (pdfModal && pdfViewer) {
pdfModal.open();
pdfViewer.showPDF(file);
}
}}>
<dbp-icon name="eye" aria-hidden="true"></dbp-icon>
${this._i18n.t('render-form.download-widget.view-attachment')}
</button>
`
: ''
}
<button
class="download-file-button button is-secondary"
title="${this._i18n.t('render-form.download-widget.download-attachment-title', {filename: file.name})}"
aria-label="${this._i18n.t('render-form.download-widget.download-attachment-title', {filename: file.name})}"
@click=${(e) => {
e.preventDefault();
this._('#file-sink').files = [file];
}}>
<dbp-icon name="download" aria-hidden="true"></dbp-icon>
${this._i18n.t('render-form.download-widget.download-attachment')}
</button>
<button
class="delete-file-button button is-secondary"
.disabled=${groupData.filesToRemove.has(identifier) || this.readOnly}
title="${this._i18n.t('render-form.download-widget.delete-attachment-title', {filename: file.name})}"
aria-label="${this._i18n.t('render-form.download-widget.delete-attachment-title', {filename: file.name})}"
@click=${(e) => {
e.preventDefault();
this.deleteAttachment(identifier, fileGroup);
}}>
<dbp-icon name="trash" aria-hidden="true"></dbp-icon>
${this._i18n.t('render-form.download-widget.delete-attachment')}
</button>
</div>
</div>
`;
}
/**
* Get allowed file upload counts from the data feed schema.
* @param {string} dataFeedSchema
* @returns {object} - An object mapping file types to their limits.
*/
getFileUploadLimits(dataFeedSchema) {
if (!dataFeedSchema) return {};
let formSchemaFields;
let fileUploadLimits = {
allowedFileUploadCount: {},
fileSizeLimit: {},
minFileUploadCount: {},
};
try {
formSchemaFields = JSON.parse(dataFeedSchema);
if (formSchemaFields.files === undefined) return {};
for (const [type, fileField] of Object.entries(formSchemaFields.files)) {
fileUploadLimits.allowedFileUploadCount[type] = fileField.maxNumber;
fileUploadLimits.minFileUploadCount[type] = fileField.minNumber;
fileUploadLimits.fileSizeLimit[type] = fileField.maxSizeMb;
}
return fileUploadLimits;
} catch (e) {
console.log('Failed parsing json data', e);
return {};
}
}
/**
* Loop through errorDetails object keys and format messages
* @param {object} responseBody
*/
displayValidationErrors(responseBody) {
const errorDetails = responseBody['relay:errorDetails'];
let errorDetailsMessages = [];
Object.keys(errorDetails).forEach((fieldName) => {
const fieldErrors = errorDetails[fieldName];
fieldErrors.forEach((errorMessage) => {
fieldName = fieldName.replace(/^\//, '');
errorDetailsMessages.push(`${fieldName}: ${errorMessage}`);
});
});
sendNotification({
summary: this._i18n.t('errors.error-title'),
body: this._i18n.t('errors.validation-failed', {
details: errorDetailsMessages.join('; '),
}),
type: 'danger',
timeout: 0,
});
}
async displayErrors(response) {
let responseBody = await response.json();
if (responseBody['relay:errorId'] === 'formalize:submission-data-feed-invalid-schema') {
this.displayValidationErrors(responseBody);
} else if (responseBody['relay:errorId'] === FILE_SECURITY_VALIDATION_ERROR_ID) {
sendNotification({
summary: this._i18n.t('errors.error-title'),
body: this._i18n.t('errors.file-security-validation-failed'),
type: 'danger',
timeout: 0,
});
} else {
sendNotification({
summary: this._i18n.t('errors.error-title'),
body: this._i18n.t('errors.form-submission-failed', {
status: response.status,
details: responseBody.detail,
}),
type: 'danger',
timeout: 0,
});
}
}
/**
* Gets user details from API
* @param {string} userIdentifier
* @returns {Promise<Response>} response
*/
async apiGetUserDetails(userIdentifier) {
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + this.getToken(),
},
};
return await fetch(this.entryPointUrl + `/base/people/${userIdentifier}`, options);
}
/**
* Build request options for the fetch call.
* @param {object} formData - The form data to be sent in the request body.
* @param {string} method - The HTTP method to use (POST, PATCH, etc.)
* @returns {object} The request options object.
*/
_buildRequestOptions(formData, method) {
return {
method: method,
headers: {
Authorization: `Bearer ${this.getToken()}`,
},
body: formData,
};
}
/**
* Build the submission URL. If submissionId is provided, it will be included to the URL.
* @param {string | null} [submissionId]
* @returns {string} The submission URL.
*/
_buildSubmissionUrl(submissionId = null) {
const baseUrl = `${this.entryPointUrl}/formalize/submissions`;
return submissionId ? `${baseUrl}/${submissionId}` : `${baseUrl}`;
}
/**
* Add a 'is-pinned' class to the form header when it's sticky.
*/
stickyHeaderObserver() {
// Only attach observer if .form-header exists and not already observed
if (!this._formHeaderObserved) {
const formHeader = this._('.form-header');
if (formHeader) {
const options = {
rootMargin: '0px',
scrollMargin: '0px',
threshold: 0.45,
};
const callback = (entries, observer) => {
entries.forEach((entry) => {
entry.target.classList.toggle('is-pinned', entry.intersectionRatio < 0.45);
});
};
const observer = new IntersectionObserver(callback, options);
observer.observe(formHeader);
this._formHeaderObserved = true;
this._formHeaderObserver = observer;
}
}
}
/**
* Checks if the user have the permission to submit a form.
* @returns {boolean}
*/
canUserSubmitSubmission() {
const numSubmissions = this.formProperties?.numSubmissionsByCurrentUser ?? 0;
const maxSubmissions = this.formProperties?.maxNumSubmissionsPerCreator ?? 10;
return (
numSubmissions < maxSubmissions &&
(this.formGrantedSubmissionCollectionActions?.includes(
SUBMISSION_COLLECTION_PERMISSIONS.MANAGE,
) ||
this.formGrantedSubmissionCollectionActions?.includes(
SUBMISSION_COLLECTION_PERMISSIONS.CREATE_SUBMISSIONS,
) || // first two criteria are deprecated and can be removed from formalize API v0.5.36
this.formGrantedFormActions?.includes(FORM_PERMISSIONS.MANAGE) ||
this.formGrantedFormActions?.includes(FORM_PERMISSIONS.CREATE_SUBMISSIONS))
);
}
/**
* Checks if the user can view their submissions.
* Returns true if the user has collection-level manage permissions,
* or if the form allows reading/managing submissions after submission.
* @returns {boolean}
*/
areSubmissionReadableAfterSubmission() {
return (
this.formGrantedSubmissionCollectionActions?.includes(
SUBMISSION_COLLECTION_PERMISSIONS.MANAGE,
) ||
this.formProperties?.allowedActionsWhenSubmitted?.includes(
SUBMISSION_PERMISSIONS.READ,
) ||
this.formProperties?.allowedActionsWhenSubmitted?.includes(
SUBMISSION_PERMISSIONS.MANAGE,
)
);
}
render() {
console.log('-- Render BaseFormElement --');
return html`
<form class="formalize-form">
Please implement render() in your subclass! ${this.getButtonRowHtml()}
</form>
`;
}
}