@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,193 lines • 97.7 kB
JavaScript
import {BaseFormElement, BaseObject} from '../form/base-object.js';
import {html, css} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
import * as commonStyles from '@dbp-toolkit/common/styles.js';
import {getMediaTransparencyFormCSS} from '../styles.js';
import {Translated, sendNotification} from '@dbp-toolkit/common';
import {GrantPermissionDialog} from '@dbp-toolkit/grant-permission-dialog';
import {Modal} from '@dbp-toolkit/common/src/modal.js';
import {FileSource, FileSink} from '@dbp-toolkit/file-handling';
import {PdfViewer} from '@dbp-toolkit/pdf-viewer';
import {
DbpStringElement,
DbpDateElement,
DbpBooleanElement,
DbpEnumElement,
DbpStringView,
DbpDateView,
DbpEnumView,
} from '@dbp-toolkit/form-elements';
import {
getFormRenderUrl,
getDeletionConfirmation,
handleDeletionConfirm,
handleDeletionCancel,
SUBMISSION_STATES,
SUBMISSION_STATES_BINARY,
getFormManageFormsUrl,
} from '../utils.js';
import {validateRequiredFields} from '@dbp-toolkit/form-elements/src/utils.js';
/**
* @typedef {object} MediaFormData
* @property {string} [mediaName] - Selected media name.
* @property {string} [otherMediumOwnersName] - Selected or custom medium owner name.
* @property {string} [otherMediumOwnersNameCustom] - Custom medium owner name.
*/
const OTHER_MEDIA_VALUE = 'Sonstiges';
const NOTHING_SELECTED = 'nothing-selected';
/**
* Lookup maps: camelCase key → full i18n translation key.
* Keep in sync with MEDIA_NAME_OWNER_MAPPING.
*/
const SUBCATEGORY_TRANSLATION_KEYS = {
// online subcategories
Audio: 'render-form.forms.media-transparency-form.sub-categories-audio',
App: 'render-form.forms.media-transparency-form.sub-categories-app',
'Soziales Netzwerk': 'render-form.forms.media-transparency-form.sub-categories-social-media',
Website: 'render-form.forms.media-transparency-form.sub-categories-website',
Text: 'render-form.forms.media-transparency-form.sub-categories-text',
Video: 'render-form.forms.media-transparency-form.sub-categories-video',
Sonstiges: 'render-form.forms.media-transparency-form.sub-categories-else',
// outOfHome subcategories
Plakat: 'render-form.forms.media-transparency-form.sub-categories-billboard',
Verkehrsmittel: 'render-form.forms.media-transparency-form.sub-categories-transportation',
'Digitaler Screen': 'render-form.forms.media-transparency-form.sub-categories-digital-screen',
Bande: 'render-form.forms.media-transparency-form.sub-categories-banner',
// outdoorAdvertising:
// 'render-form.forms.media-transparency-form.sub-categories-outdoor-advertising',
Kino: 'render-form.forms.media-transparency-form.sub-categories-cinema',
};
const CATEGORY_TRANSLATION_KEYS = {
Online: 'render-form.forms.media-transparency-form.categories-online',
Print: 'render-form.forms.media-transparency-form.categories-print',
'Out of Home': 'render-form.forms.media-transparency-form.categories-out-of-home',
Hörfunk: 'render-form.forms.media-transparency-form.categories-radio',
Fernsehen: 'render-form.forms.media-transparency-form.categories-television',
};
/**
* Never called at runtime. The i18next-cli extractor uses static analysis and
* only preserves keys it finds as string literals in t() calls. Since the keys
* above are used via dynamic lookup (not direct t() calls), they would be
* pruned by `i18next:fix`. This function prevents that. Do not remove.
*/
// oxlint-disable-next-line no-unused-vars
const _i18nExtractKeys = (t) => {
// subcategory keys — keep in sync with SUBCATEGORY_TRANSLATION_KEYS
t('render-form.forms.media-transparency-form.sub-categories-audio');
t('render-form.forms.media-transparency-form.sub-categories-app');
t('render-form.forms.media-transparency-form.sub-categories-social-media');
t('render-form.forms.media-transparency-form.sub-categories-website');
t('render-form.forms.media-transparency-form.sub-categories-text');
t('render-form.forms.media-transparency-form.sub-categories-video');
t('render-form.forms.media-transparency-form.sub-categories-else');
t('render-form.forms.media-transparency-form.sub-categories-billboard');
t('render-form.forms.media-transparency-form.sub-categories-banner');
t('render-form.forms.media-transparency-form.sub-categories-transportation');
t('render-form.forms.media-transparency-form.sub-categories-digital-screen');
t('render-form.forms.media-transparency-form.sub-categories-outdoor-advertising');
t('render-form.forms.media-transparency-form.sub-categories-cinema');
// category keys — keep in sync with CATEGORY_TRANSLATION_KEYS
t('render-form.forms.media-transparency-form.categories-online');
t('render-form.forms.media-transparency-form.categories-print');
t('render-form.forms.media-transparency-form.categories-out-of-home');
t('render-form.forms.media-transparency-form.categories-radio');
t('render-form.forms.media-transparency-form.categories-television');
};
/**
* Mapping of category → subcategory → mediaName → mediaOwnerName driven by CSV data.
* Keys use form enum lowerCamelCase values for category and subcategory.
* Categories with subcategories use subcategory keys directly.
* Categories without subcategories use the sentinel key `_items` for their media names.
*/
export const MEDIA_NAME_OWNER_MAPPING = {
Online: {
Audio: {
'Amazon Music': 'Amazon.com, Inc.',
'Antenna Pod': 'Antenna Pod',
'Apple Podcasts': 'Apple Inc.',
Deezer: 'Deezer S.A.',
fyyd: 'Christian Bednarek',
'Listen Notes': 'Listen Notes, Inc.',
'Pocket Casts': 'Automattic, Inc.',
Spotify: 'Spotify AB',
},
App: {
'Google Play': 'Google LLC',
iTunes: 'Apple Inc.',
'Podcast Addict': 'Guillemane Xavier - Podcast & Radio Addict',
},
'Soziales Netzwerk': {
Facebook: 'Meta Platforms Ireland Limited',
Instagram: 'Meta Platforms Ireland Limited',
LinkedIn: 'LinkedIn Ireland Unlimited Company',
Snapchat: 'Snap Inc.',
'X (Twitter)': 'X Corp.',
YouTube: 'Google Ireland Limited',
},
Website: {
'c.socceruhd.online': 'socceruhd.online',
'e-flux.com': 'e-flux Inc.',
'google.com': 'Google Ireland Limited',
'grazer.at': 'Media 21 GmbH',
'industriemagazin.at': 'WEKA Industrie Medien GmbH',
'kleinezeitung.at': 'Kleine Zeitung GmbH & Co KG',
'science.apa.at': 'APA - Austria Presse Agentur eG',
'spiritofstyria.at': 'GRAZETTA GmbH',
},
Text: {
'Industriemagazin Newsletter': 'WEKA Industrie Medien GmbH',
'Newsletter Automobil + Motoren': 'Springer Fachmedien Wiesbaden GmbH',
},
Video: {},
Sonstiges: {},
},
Print: {
_items: {
'ADDITIVE FERTIGUNG': 'x-Technik IT & Medien GesmbH',
Bildungsguide: 'Tamedia Publikationen Deutschschweiz AG',
'COOL! Mädchen': 'Gonzomedia GesmbH',
DerGrazer: 'Media 21 GmbH',
'Die Presse': '"Die Presse" Verlags-Gesellschaft m.b.H. & Co KG',
Forschung: 'STANDARD Verlagsgesellschaft m.b.H.',
'Jahresbericht Bundesrealgymnasium 8010 Graz, Petersgasse 110':
'Bundesrealgymnasium 8010 Graz, Petersgasse 110',
'Jahresbericht Höhere technische Bundeslehranstalt 5280 Braunau am Inn, Osternbergerstraße 55':
'Höhere technische Bundeslehranstalt 5280 Braunau am Inn, Osternbergerstraße 55',
'KLEINE ZEITUNG': 'Kleine Zeitung GmbH & Co KG',
'Maturazeitung Bundes-Oberstufenrealgymnasium 8010 Graz, Monsbergergasse 16':
'Bundes-Oberstufenrealgymnasium 8010 Graz, Monsbergergasse 16',
'Maturazeitung Bundesgymnasium, Bundesrealgymnasium und Bundes-Oberstufenrealgymnasium (HIB) 8041 Graz-Liebenau, Kadettengasse 19-23':
'Bundesgymnasium, Bundesrealgymnasium und Bundes-Oberstufenrealgymnasium (HIB) 8041 Graz-Liebenau, Kadettengasse 19-23',
'Maturazeitung Höhere technische Bundeslehr- und Versuchsanstalt 7423 Pinkafeld, Meierhofplatz 1':
'Höhere technische Bundeslehr- und Versuchsanstalt 7423 Pinkafeld, Meierhofplatz 1',
'Maturazeitung Höhere technische Bundeslehranstalt 8051 Graz-Gösting, Ibererstraße 15-21':
'Höhere technische Bundeslehranstalt 8051 Graz-Gösting, Ibererstraße 15-21',
'Maturazeitung Höhere technische Bundeslehranstalt 8430 Leibnitz, Kaindorf, Grazer Straße 202':
'Höhere technische Bundeslehranstalt 8430 Leibnitz, Kaindorf, Grazer Straße 202',
MEGAPHON: 'Caritas der Diözese Graz-Seckau',
MTZ: 'Springer Fachmedien Wiesbaden GmbH',
'Rebell*innen Kalender':
'AMAZONE, Verein zur Herstellung von Geschlechtergerechtigkeit',
sheconomy: 'SHE Wirtschaftsmedien Beteiligungs GmbH',
'Spirit OF STYRIA': 'SPIRIT Medienhaus GmbH',
'Verkehrsmalbuch für Kinder': 'IPA Verlagsgesellschaft m.b.H.',
'Weekend Magazin Steiermark': 'Weekend Magazin Steiermark GmbH',
ZukunftsBranchen: 'Weber Ulrich, "WeberMedia"',
},
},
'Out of Home': {
Plakat: {
Ankünder: 'Ankünder GmbH',
'BS Vertriebsagentur e.U.': 'BS Vertriebsagentur e.U.',
'City Light': 'City Light Ankünder GmbH',
'GFW Gesellschaft für Wirtschaftsdokumentationen':
'GFW Gesellschaft für Wirtschaftsdokumentationen Gesellschaft m.b.H. & Co. KG',
},
Verkehrsmittel: {
'Ankünder GmbH': 'Ankünder GmbH',
},
'Digitaler Screen': {},
outdoorAdvertising: {},
Bande: {},
Kino: {},
Sonstiges: {
'Gazzetta GmbH': 'Gazzetta GmbH',
'CompanyCode Werbe GmbH': 'CompanyCode Werbe GmbH',
},
},
Fernsehen: {
_items: {
'ORF 2': 'Österreichischer Rundfunk',
},
},
Hörfunk: {
_items: {
'Radio Soundportal (Graz)': 'Soundportal Graz GmbH',
},
},
};
export default class extends BaseObject {
getUrlSlug() {
return 'media-transparency';
}
/**
* Returns the form component class for the media transparency form.
*
* @returns {typeof BaseFormElement} The class of the form component.
*/
getFormComponent() {
return FormalizeFormElement;
}
hasReadOnlyMode() {
return true;
}
getFormIdentifier() {
return '019bdb56-6fc7-760d-938a-b128a89958a8';
}
}
class FormalizeFormElement extends BaseFormElement {
constructor() {
super();
// Advertisement category/media related
this.selectedCategory = null;
this.otherMediumNameEnabled = false;
// Conditional fields
this.conditionalFields = {
category: false,
advertisementSubcategory: false,
mediaName: false,
outOfHomeOtherMediunOwner: false,
};
this.submissionIsSaved = false;
// Event handlers
this.handleFormSubmission = this.handleFormSubmission.bind(this);
this.handleSaveDraft = this.handleSaveDraft.bind(this);
this.handleFormSaveSubmission = this.handleFormSaveSubmission.bind(this);
}
static get properties() {
return {
...super.properties,
selectedCategory: {type: String, attribute: false},
otherMediumNameEnabled: {type: Boolean, attribute: false},
submissionIsSaved: {type: Boolean, attribute: false},
};
}
static get scopedElements() {
return {
...super.scopedElements,
'dbp-form-string-element': DbpStringElement,
'dbp-form-date-element': DbpDateElement,
'dbp-form-boolean-element': DbpBooleanElement,
'dbp-form-enum-element': DbpEnumElement,
'dbp-form-string-view': DbpStringView,
'dbp-form-date-view': DbpDateView,
'dbp-form-enum-view': DbpEnumView,
'dbp-translated': Translated,
'dbp-grant-permission-dialog': GrantPermissionDialog,
'dbp-modal': Modal,
'dbp-file-source': FileSource,
'dbp-file-sink': FileSink,
'dbp-pdf-viewer': PdfViewer,
};
}
connectedCallback() {
super.connectedCallback();
void this.updateComplete.then(() => {
// Event listener for form submission
this.addEventListener('DbpFormalizeFormSubmission', this.handleFormSubmission);
// Event listener for saving draft
this.addEventListener('DbpFormalizeFormSaveDraft', this.handleSaveDraft);
// Event listener for delete submission
this.addEventListener(
'DbpFormalizeFormDeleteSubmission',
this.handleFormDeleteSubmission,
);
// Listen to the event from file source
this.addEventListener('dbp-file-source-file-selected', this.handleFilesToSubmit);
// Event listener for save/PATCH submission
this.addEventListener('DbpFormalizeFormSaveSubmission', this.handleFormSaveSubmission);
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('DbpFormalizeFormSubmission', this.handleFormSubmission);
this.removeEventListener('DbpFormalizeFormSaveDraft', this.handleSaveDraft);
this.removeEventListener('DbpFormalizeFormSaveSubmission', this.handleFormSaveSubmission);
this.removeEventListener(
'DbpFormalizeFormDeleteSubmission',
this.handleFormDeleteSubmission,
);
this.removeEventListener('dbp-file-source-file-selected', this.handleFilesToSubmit);
}
async update(changedProperties) {
super.update(changedProperties);
const i18n = this._i18n;
// console.log('changedProperties', changedProperties);
if (changedProperties.has('data')) {
if (Object.keys(this.data).length > 0) {
await this.processFormData();
}
// this.initializeFileGroups();
this.setButtonStates();
// @ts-ignore
void 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.formElement;
this.isFormValid = await validateRequiredFields(formElement);
// Validate minimum file upload counts
const fileValidation = this.validateMinimumFileUploads();
if (!fileValidation.isValid) {
this.isFormValid = false;
const failed = fileValidation.failedGroups[0];
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t(
'render-form.forms.media-transparency-form.min-file-upload-error',
{currentCount: failed.currentCount, minCount: failed.minCount},
),
type: 'warning',
timeout: 0,
});
}
if (!this.isFormValid) {
this.scrollToFirstInvalidField(formElement, true);
// Show notification
sendNotification({
summary: i18n.t('errors.warning-title'),
body: i18n.t('errors.form-validation-warning-notification-body'),
type: 'warning',
timeout: 0,
});
}
}
});
}
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('data')) {
// Reset observer so it can re-attach if needed
this._formHeaderObserved = false;
this.stickyHeaderObserver();
}
}
static get styles() {
return [
super.styles,
// language=css
css`
@layer theme, utility, formalize, print;
@layer theme {
${commonStyles.getGeneralCSS(false)}
${commonStyles.getButtonCSS()}
${commonStyles.getModalDialogCSS()}
}
@layer formalize {
${getMediaTransparencyFormCSS()}
}
`,
];
}
async processFormData() {
const i18n = this._i18n;
try {
this.currentSubmission = this.data;
this.submissionId = this.data.identifier;
this.lastModifiedCreatorId = this.data.lastModifiedById;
this.formData = JSON.parse(this.data.dataFeedElement);
this.submissionBinaryState = this.data.submissionState;
this.submissionGrantedActions = this.data.grantedActions;
this.selectedTags = this.data.tags;
// Initialize subcategory items if category is already set
if (this.formData?.category) {
this.setSubcategoryItemsByCategoryValue(this.formData.category);
}
// Initialize media name items if subcategory is already set
if (this.formData?.advertisementSubcategory) {
this.setMediaNameItemsByValue(this.formData.advertisementSubcategory);
}
// Set conditional field for mediaName if 'Sonstiges' is selected
if (this.formData?.mediaName === OTHER_MEDIA_VALUE) {
this.conditionalFields.mediaName = true;
}
if (this.formData?.category === 'Out of Home') {
this.conditionalFields.mediaName = false;
this.conditionalFields.mediumOwnersName = false;
// Check if the saved otherMediumOwnersName is a known dropdown option or a custom value
const savedOwnerName = this.formData.otherMediumOwnersName;
const mediaNameItems = this.getMediaNameItems();
const isKnownOption = savedOwnerName && savedOwnerName in mediaNameItems;
if (isKnownOption) {
// Known dropdown option — no custom text input needed
this.conditionalFields.outOfHomeOtherMediunOwner = false;
} else {
// Custom value or "Sonstiges" — show custom text input
this.conditionalFields.outOfHomeOtherMediunOwner = true;
if (savedOwnerName && savedOwnerName !== OTHER_MEDIA_VALUE) {
// Move the custom value to the temporary field for the text input
this.formData.otherMediumOwnersNameCustom = savedOwnerName;
// Set the dropdown to "Sonstiges" so it displays correctly
this.formData.otherMediumOwnersName = OTHER_MEDIA_VALUE;
}
}
}
// Initialize file groups from schema
this.initializeFileGroups();
// Process submitted files by group
const submittedFiles = {};
for (const file of this.data.submittedFiles) {
if (!submittedFiles[file.fileAttributeName]) {
submittedFiles[file.fileAttributeName] = [];
}
submittedFiles[file.fileAttributeName].push(file);
}
this.fileUploadCounts = {};
for (const [fileGroup, files] of Object.entries(submittedFiles)) {
this.fileUploadCounts[fileGroup] = files.length;
// Get or create the file group structure (handles catch-all schemas)
const groupData = this.getOrCreateFileGroup(fileGroup);
groupData.submittedFiles = await this.transformApiResponseToFile(files);
}
switch (Number(this.submissionBinaryState)) {
case 1:
this.currentState = SUBMISSION_STATES.DRAFT;
break;
case 4:
this.currentState = SUBMISSION_STATES.SUBMITTED;
break;
default:
this.currentState = null;
break;
}
if (this.formData) {
try {
const lastModifierDetailsResponse = await this.apiGetUserDetails(
this.lastModifiedCreatorId,
);
if (!lastModifierDetailsResponse.ok) {
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.failed-to-get-last-modifier-details', {
status: lastModifierDetailsResponse.status,
}),
type: 'danger',
timeout: 0,
});
} else {
const lastModifierDetails = await lastModifierDetailsResponse.json();
this.lastModifiedCreatorName = `${lastModifierDetails?.givenName} ${lastModifierDetails?.familyName}`;
}
} catch (e) {
console.log(e);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.failed-to-get-last-modifier-details'),
type: 'danger',
timeout: 0,
});
}
}
} catch (e) {
console.error('Error parsing submission data:', e);
}
}
/**
* Clean up form data before submission.
* - Merges the custom owner name field for the Out of Home / Sonstiges case.
* - Converts the NOTHING_SELECTED placeholder back to an empty string so it
* is not persisted as a real value.
* @param {MediaFormData} formData - The form data object to clean up.
*/
_cleanFormDataBeforeSubmit(formData) {
// Merge custom owner name for Out of Home / Sonstiges case
if (formData.otherMediumOwnersNameCustom !== undefined) {
formData.otherMediumOwnersName = formData.otherMediumOwnersNameCustom;
delete formData.otherMediumOwnersNameCustom;
}
// Convert placeholder values back to empty strings
if (formData.mediaName === NOTHING_SELECTED) {
formData.mediaName = '';
}
if (formData.otherMediumOwnersName === NOTHING_SELECTED) {
formData.otherMediumOwnersName = '';
}
}
/**
* Handle saving submission.
* @param {CustomEvent} event - The event object containing the form data.
*/
async handleFormSubmission(event) {
const i18n = this._i18n;
// Access the data from the event detail
const data = event.detail;
// Merge custom owner name for Out of Home / Sonstiges case
this._cleanFormDataBeforeSubmit(data.formData);
// Validate minimum file upload counts
const fileValidation = this.validateMinimumFileUploads();
if (!fileValidation.isValid) {
const failed = fileValidation.failedGroups[0]; // Show first failure
sendNotification({
summary: i18n.t('errors.error-title'),
body: `${i18n.t('render-form.forms.media-transparency-form.min-file-upload-error', {currentCount: failed.currentCount, minCount: failed.minCount})}`,
type: 'danger',
timeout: 0,
});
this.saveButtonEnabled = true;
return;
}
// POST or PATCH
let isExistingDraft = false;
if (data.submissionId) {
isExistingDraft = true;
}
// Include unique identifier for person who first submitted the form (creator)
data.formData.identifier = isExistingDraft ? this.lastModifiedCreatorId : this.getUserId();
const formData = new FormData();
// Iterate over all file groups dynamically
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
// Set files to upload for this group
if (groupData.filesToSubmit.size > 0) {
groupData.filesToSubmit.forEach((fileToAttach) => {
formData.append(`${fileGroup}[]`, fileToAttach, fileToAttach.name);
});
}
// Set files to remove for this group
if (groupData.filesToRemove.size > 0) {
groupData.filesToRemove.forEach((fileObject, fileIdentifier) => {
formData.append(`submittedFiles[${fileIdentifier}]`, 'null');
});
}
}
formData.append('form', '/formalize/forms/' + this.formIdentifier);
formData.append('dataFeedElement', JSON.stringify(data.formData));
formData.append('submissionState', String(SUBMISSION_STATES_BINARY.SUBMITTED));
// Add tags
const selectedTags = Object.values(this.selectedTags);
formData.append('tags', JSON.stringify(selectedTags));
const method = isExistingDraft ? 'PATCH' : 'POST';
const options = this._buildRequestOptions(formData, method);
const url = this._buildSubmissionUrl(isExistingDraft ? this.submissionId : null);
// Backup all file groups in case of error
const filesByGroupBackup = {};
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
filesByGroupBackup[fileGroup] = {
submittedFiles: new Map(groupData.submittedFiles),
filesToSubmit: new Map(groupData.filesToSubmit),
filesToRemove: new Map(groupData.filesToRemove),
};
}
try {
const response = await fetch(url, options);
if (!response.ok) {
this.submissionError = true;
await this.displayErrors(response);
} else {
this.submissionError = false;
this.currentState = SUBMISSION_STATES.SUBMITTED;
this.submitted = true;
// Clear all file groups after successful submission
for (const fileGroup of Object.keys(this.filesByGroup)) {
this.filesByGroup[fileGroup].filesToSubmit = new Map();
this.filesByGroup[fileGroup].filesToRemove = new Map();
}
// Hide form after successful submission
this.hideForm = true;
this.disableLeavePageWarning();
sendNotification({
summary: i18n.t('success.success-title'),
body: 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);
// Restore all file groups if something went wrong
for (const [fileGroup, backup] of Object.entries(filesByGroupBackup)) {
this.filesByGroup[fileGroup] = backup;
}
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.unknown-error-on-form-submission'),
type: 'danger',
timeout: 0,
});
}
}
/**
* Handle save (PATCH) submission.
* @param {CustomEvent} event - The event object containing the form data.
*/
async handleFormSaveSubmission(event) {
const i18n = this._i18n;
if (!event.detail.submissionId) return;
const data = event.detail;
// Merge custom owner name for Out of Home / Sonstiges case
this._cleanFormDataBeforeSubmit(data.formData);
// Include unique identifier for person who is submitting
data.formData.identifier = this.lastModifiedCreatorId;
const formData = new FormData();
// Iterate over all file groups dynamically
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
// Set files to upload for this group
if (groupData.filesToSubmit.size > 0) {
groupData.filesToSubmit.forEach((fileToAttach) => {
formData.append(`${fileGroup}[]`, fileToAttach, fileToAttach.name);
});
}
// Set files to remove for this group
if (groupData.filesToRemove.size > 0) {
groupData.filesToRemove.forEach((fileObject, fileIdentifier) => {
formData.append(`submittedFiles[${fileIdentifier}]`, 'null');
});
}
}
formData.append('dataFeedElement', JSON.stringify(data.formData));
// Add tags
const selectedTags = Object.values(this.selectedTags);
formData.append('tags', JSON.stringify(selectedTags));
const options = this._buildRequestOptions(formData, 'PATCH');
const url = this._buildSubmissionUrl(event.detail.submissionId);
// Backup all file groups in case of error
const filesByGroupBackup = {};
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
filesByGroupBackup[fileGroup] = {
submittedFiles: new Map(groupData.submittedFiles),
filesToSubmit: new Map(groupData.filesToSubmit),
filesToRemove: new Map(groupData.filesToRemove),
};
}
try {
const response = await fetch(url, options);
if (!response.ok) {
await this.displayErrors(response);
this.submissionIsSaved = false;
} else {
const responseBody = await response.json();
// Process all submitted files by group
const submittedFilesByGroup = {};
for (const file of responseBody.submittedFiles) {
if (!submittedFilesByGroup[file.fileAttributeName]) {
submittedFilesByGroup[file.fileAttributeName] = [];
}
submittedFilesByGroup[file.fileAttributeName].push(file);
}
// Update each file group
for (const [fileGroup, files] of Object.entries(submittedFilesByGroup)) {
const groupData = this.getOrCreateFileGroup(fileGroup);
groupData.submittedFiles = await this.transformApiResponseToFile(files);
groupData.filesToSubmit = new Map();
groupData.filesToRemove = new Map();
}
this.submissionIsSaved = true;
// formDataUpdated event to notify parent component
this.dispatchEvent(
new CustomEvent('dbpFormDataUpdated', {
detail: {
needUpdate: true,
},
bubbles: true,
composed: true,
}),
);
sendNotification({
summary: i18n.t('success.success-title'),
body: i18n.t('success.form-saved-successfully'),
type: 'success',
timeout: 5,
});
}
} catch (error) {
// Restore all file groups if something went wrong
for (const [fileGroup, backup] of Object.entries(filesByGroupBackup)) {
this.filesByGroup[fileGroup] = backup;
}
this.submissionIsSaved = false;
this.requestUpdate();
console.error(error);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.unknown-error-on-save-submission'),
type: 'danger',
timeout: 0,
});
}
}
/**
* Handle saving draft submission.
* @param {CustomEvent} event - The event object containing the form data.
*/
async handleSaveDraft(event) {
const i18n = this._i18n;
// Access the data from the event detail
const data = event.detail;
// const validationResult = data.validationResult;
// Merge custom owner name for Out of Home / Sonstiges case
this._cleanFormDataBeforeSubmit(data.formData);
// POST or PATCH
let isExistingDraft = false;
if (data.submissionId) {
isExistingDraft = true;
}
// Include unique identifier for person who last modified the form
data.formData.identifier = isExistingDraft ? this.lastModifiedCreatorId : this.getUserId();
const formData = new FormData();
// Iterate over all file groups dynamically
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
// Set files to upload for this group
if (groupData.filesToSubmit.size > 0) {
groupData.filesToSubmit.forEach((fileToAttach) => {
formData.append(`${fileGroup}[]`, fileToAttach, fileToAttach.name);
});
}
// Set files to remove for this group
if (groupData.filesToRemove.size > 0) {
groupData.filesToRemove.forEach((fileObject, fileIdentifier) => {
formData.append(`submittedFiles[${fileIdentifier}]`, 'null');
});
}
}
formData.append('form', '/formalize/forms/' + this.formIdentifier);
formData.append('dataFeedElement', JSON.stringify(data.formData));
formData.append('submissionState', String(SUBMISSION_STATES_BINARY.DRAFT));
// Add tags
const selectedTags = Object.values(this.selectedTags);
formData.append('tags', JSON.stringify(selectedTags));
const method = isExistingDraft ? 'PATCH' : 'POST';
const options = this._buildRequestOptions(formData, method);
const url = this._buildSubmissionUrl(isExistingDraft ? this.submissionId : null);
// Backup all file groups in case of error
const filesByGroupBackup = {};
for (const [fileGroup, groupData] of Object.entries(this.filesByGroup)) {
filesByGroupBackup[fileGroup] = {
submittedFiles: new Map(groupData.submittedFiles),
filesToSubmit: new Map(groupData.filesToSubmit),
filesToRemove: new Map(groupData.filesToRemove),
};
}
try {
const response = await fetch(url, options);
let responseBody = await response.json();
if (!response.ok) {
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.failed-to-save-draft', {
status: response.status,
detail: responseBody.detail,
}),
type: 'danger',
timeout: 0,
});
} else {
this.data = responseBody;
this.newSubmissionId = responseBody.identifier;
this.submissionBinaryState = responseBody.submissionState;
// Process all submitted files by group
const submittedFilesByGroup = {};
for (const file of responseBody.submittedFiles) {
if (!submittedFilesByGroup[file.fileAttributeName]) {
submittedFilesByGroup[file.fileAttributeName] = [];
}
submittedFilesByGroup[file.fileAttributeName].push(file);
}
// Update each file group
for (const [fileGroup, files] of Object.entries(submittedFilesByGroup)) {
const groupData = this.getOrCreateFileGroup(fileGroup);
groupData.submittedFiles = await this.transformApiResponseToFile(files);
groupData.filesToSubmit = new Map();
groupData.filesToRemove = new Map();
}
// Update URL with the submission ID
const newSubmissionUrl =
getFormRenderUrl(this.formUrlSlug, this.lang) + `/${this.newSubmissionId}`;
window.history.pushState({}, '', newSubmissionUrl.toString());
this.disableLeavePageWarning();
this.redirectToReadonlyForm();
// formDataUpdated event to notify parent component
this.dispatchEvent(
new CustomEvent('dbpFormDataUpdated', {
detail: {
needUpdate: true,
},
bubbles: true,
composed: true,
}),
);
}
} catch (error) {
// Restore all file groups if something went wrong
for (const [fileGroup, backup] of Object.entries(filesByGroupBackup)) {
this.filesByGroup[fileGroup] = backup;
}
this.requestUpdate();
console.error(error);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.unknown-error-on-save-draft'),
type: 'danger',
timeout: 0,
});
}
}
/**
* Update formData if field value changes
* @param {CustomEvent} event
*/
async handleChangeEvents(event) {
// First call parent to handle common cases
super.handleChangeEvents(event);
// Handle form-specific actions
if (event.detail && event.detail.option && event.detail.value) {
const option = event.detail.option;
const value = event.detail.value;
if (option.value === 'download' && value === 'download') {
this.downloadAllFiles();
return;
}
if (option.value === 'edit-permissions' && value === 'edit-permissions') {
this._('#grant-permission-dialog').open();
return;
}
}
// Silently update validation widget status (without showing field errors)
// Only run if in edit mode (not readonly)
if (!this.readOnly) {
await this.updateComplete;
const requiredFieldsValidated = await validateRequiredFields(this.formElement, true);
const fileValidation = this.validateMinimumFileUploads();
// Update widget based on both required fields and file uploads
this.isFormValid = requiredFieldsValidated && fileValidation.isValid;
}
}
/**
* Download all attachments and pdf version of the form as a zip file.
*/
downloadAllFiles() {
const allFiles = this.getFileGroupsFromSchema().flatMap((groupName) => {
const group = this.getOrCreateFileGroup(groupName);
return Array.from(group.submittedFiles.values());
});
this._('#file-sink').files = allFiles;
}
/**
* Handle deleting submission.
* @param {CustomEvent} event - The event object containing the submission id to delete.
*/
async handleFormDeleteSubmission(event) {
const i18n = this._i18n;
const data = event.detail;
const submissionId = data.submissionId;
if (!submissionId) {
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.no-submission-id-provided'),
type: 'danger',
timeout: 0,
});
return;
}
const confirmed = await getDeletionConfirmation(this);
if (!confirmed) return;
try {
const response = await fetch(
this.entryPointUrl + `/formalize/submissions/${submissionId}`,
{
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + this.getToken(),
},
},
);
if (!response.ok) {
this.deleteSubmissionError = true;
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.failed-to-delete-submission-status', {
status: response.status,
}),
type: 'danger',
timeout: 0,
});
} else {
this.wasDeleteSubmissionSuccessful = true;
this.deleteSubmissionError = false;
}
} catch (error) {
console.error(error);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('errors.unknown-error-on-delete-submission'),
type: 'danger',
timeout: 0,
});
} finally {
if (this.wasDeleteSubmissionSuccessful) {
sendNotification({
summary: i18n.t('success.success-title'),
body: i18n.t('success.form-submission-deleted-successfully'),
type: 'success',
timeout: 5,
});
const emptyFormUrl = getFormRenderUrl(this.formUrlSlug, this.lang);
window.history.pushState({}, '', emptyFormUrl.toString());
this.resetForm(event);
this.sendSetPropertyEvent('routing-url', `/${this.formUrlSlug}`);
}
}
}
/**
* Validates minimum file upload counts for all file groups.
* @returns {{isValid: boolean, failedGroups: Array<{fileGroup: string, currentCount: number, minCount: number}>}}
*/
validateMinimumFileUploads() {
const failedGroups = [];
if (this.fileUploadLimits.minFileUploadCount) {
for (const [fileGroup, minCount] of Object.entries(
this.fileUploadLimits.minFileUploadCount,
)) {
const submittedCount = this.filesByGroup[fileGroup]?.submittedFiles.size || 0;
const toSubmitCount = this.filesByGroup[fileGroup]?.filesToSubmit.size || 0;
const currentCount = submittedCount + toSubmitCount;
if (currentCount < minCount) {
failedGroups.push({fileGroup, currentCount, minCount});
}
}
}
return {
isValid: failedGroups.length === 0,
failedGroups,
};
}
/**
* Renders the editable file upload widget for one schema file group.
* @param {string} groupName
* @returns {import('lit').TemplateResult}
*/
renderFileUploadGroup(groupName) {
const i18n = this._i18n;
return html`
<div class="file-upload-container">
<div class="file-upload-title-container">
<h4 class="attachments-title">
${i18n.t('render-form.forms.media-transparency-form.attachments-title')}
${
this.fileUploadLimits?.minFileUploadCount?.[groupName] > 0
? html`
<span class="required-mark">*</span>
`
: ''
}
</h4>
<span class="file-upload-limit-warning">
${i18n.t('render-form.download-widget.file-upload-count-limit-warning', {
count: this.fileUploadLimits?.allowedFileUploadCount?.[groupName],
})}
${i18n.t('render-form.download-widget.file-upload-size-limit-warning', {
size: this.fileUploadLimits?.fileSizeLimit?.[groupName],
})}
</span>
<dbp-translated subscribe="lang">
<div slot="de">
<p>Bitte benennen Sie Ihr Sujet nach folgenden Kriterien:</p>
<ol>
<li>Fortlaufende zweistellige Nummer: 01, 02, 03, …</li>
<li>Abkürzung der OE/Institutes/ …</li>
<li>Kategorie</li>
<li>Medium</li>
<li>Thema</li>
</ol>
<p>
Zum Beispiel:
<code>01_KM_Print_HelleKoepfe_DerGrazer_Motorenforschung.pdf</code>
</p>
<p>Punkte 2-5 dürfen keine Ziffern enthalten!</p>
<p>Die zulässige Dateiformate sind PDF, JPG, PNG, MP3 und MP4.</p>
</div>
<div slot="en">
<p>Please name your file according to the following criteria:</p>
<ol>
<li>Sequential two-digit number: 01, 02, 03, …</li>
<li>Abbreviation of the organizational unit/institute/ …</li>
<li>Category</li>
<li>Medium</li>
<li>Topic</li>
</ol>
<p>
For example:
<code>01_KM_Print_HelleKoepfe_DerGrazer_Motorenforschung.pdf</code>
</p>
<p>Items 2–5 must not contain any numbers!</p>
<p>The accepted file formats are PDF, JPG, PNG, MP3, and MP4.</p>
</div>
</dbp-translated>
</div>
<div class="uploaded-files">${this.renderAttachedFilesHtml(groupName)}</div>
<button
class="button is-secondary upload-button upload-button--attachment"
.disabled=${
this.fileUploadCounts[groupName] >=
this.fileUploadLimits?.allowedFileUploadCount?.[groupName] &&
this.filesByGroup['Sujet-Speichername']?.filesToRemove?.size === 0
}
@click="${(event) => {
this.currentUploadGroup = groupName;
this.openFilePicker(event);
}}">
<dbp-icon name="upload" aria-hidden="true"></dbp-icon>
${
!isNaN(this.fileUploadLimits?.allowedFileUploadCount?.[groupName])
? i18n.t('render-form.download-widget.upload-file-button-label', {
count: this.fileUploadLimits?.allowedFileUploadCount?.[groupName],
})
: i18n.t(
'render-form.download-widget.upload-file-button-label-no-limit',
)
}
</button>
</div>
`;
}
/**
* Renders the read-only file list for one schema file group.
* @param {string} groupName
* @returns {import('lit').TemplateResult}
*/
renderFileViewGroup(groupName) {
const i18n = this._i18n;
return html`
<div class="file-upload-container">
<h4 class="attachments-title">
${i18n.t('render-form.download-widget.attachments-title')}
</h4>
<div class="uploaded-files">${this.renderAttachedFilesHtml(groupName)}</div>
</div>
`;
}
/**
* Check if a category has subcategories.
* Categories with subcategories have named subcategory keys.
* Categories without subcategories use the sentinel key `_items`.
* @param {string | null} category
* @returns {boolean}
*/
categoryHasSubcategories(category) {
const categoryData = MEDIA_NAME_OWNER_MAPPING[category];
return !!categoryData && !('_items' in categoryData);
}
/**
* Get all categories from MEDIA_NAME_OWNER_MAPPING with translations.
* Single source of truth for categories.
* @returns {object} Object with category keys and translated values
*/
getCategoryItems() {
const i18n = this._i18n;
const categories = {};
Object.keys(MEDIA_NAME_OWNER_MAPPING).forEach((categoryKey) => {
const translationKey =
CATEGORY_TRANSLATION_KEYS[categoryKey] ??
`render-form.forms.media-transparency-form.categories-${this.camelToKebab(categoryKey)}`;
categories[categoryKey] = i18n.t(translationKey);
});
return categories;
}
/**
* Get list of categories that have subcategories.
* Used for data-condition on category field.
* @returns {Array<string>}
*/
getCategoriesWithSubcategories() {
return Object.keys(MEDIA_NAME_OWNER_MAPPING).filter((category) =>
this.categoryHasSubcategories(category),
);
}
/**
* Convert camelCase to kebab-case for translation keys
* @param {string} str
* @returns {string}
*/
camelToKebab(str) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/**
* Get translated subcategory items for the currently selected category.
* Called inline in the template so it is always fresh on language change.
* @returns {object} Object with subcategory keys and translated labels
*/
getSubcategoryItems() {
const i18n = this._i18n;
if (!this.selectedCategory || !this.categoryHasSubcategories(this.selectedCategory)) {
return {};
}
const items = {};
Object.keys(MEDIA_NAME_OWNER_MAPPING[this.selectedCategory]).forEach((subcategoryKey) => {
const translationKey =
SUBCATEGORY_TRANSLATION_KEYS[subcategoryKey] ??
`render-form.forms.media-transparency-form.sub-categories-${this.camelToKebab(subcategoryKey)}`;
items[subcategoryKey] = i18n.t(translationKey);
});
return items;
}
/**
* Get translated media name items for the current category/subcategory.
* Called inline in the template so it is always fresh on language change.
* @returns {object} Object with media name keys and display values
*/
getMediaNameItems() {
const i18n = this._i18n;
const subcategoryKey = this.categoryHasSubcategories(this.selectedCategory)
? this.formData?.advertisementSubcategory
: '_items';
const mapping = MEDIA_NAME_OWNER_MAPPING[this.selectedCategory]?.[subcategoryKey] || {};
if (Object.keys(mapping).length === 0) {
return {[OTHER_MEDIA_VALUE]: OTHER_MEDIA_VALUE};
}
const items = {
[NOTHING_SELECTED]: i18n.t(
'render-form.forms.media-transparency-form.please-select-media',
),
};
Object.keys(mapping).forEach((name) => {
items[name] = name;
});
// Add 'Other' option if there are known media names or if category is Out of Home (which has only subcategories but no known media names)
if (subcategoryKey !== OTHER_MEDIA_VALUE || this.selectedCategory === 'Out of Home') {
items[OTHER_MEDIA_VALUE] = OTHER_MEDIA_VALUE;
}
return items;
}
getDeadlinePeriodItems() {
const i18n = this._i18n;
const items = {};
const currentYear = new Date().getFullYear();
const nextYear = currentYear + 1;
items[''] = i18n.t('render-form.forms.media-transparency-form.please-select-deadline');
for (let year = currentYear; year <= nextYear; year++) {
items[`${year}/1`] = `${year}/1`;
items[`${year}/2`] = `${year}/2`;
}
return items;
}
/**
* Set conditional field visibility and reset related fields based on selected category value.
* @param {string} selectedValue
*/
setSubcategoryItemsByCategoryValue(selectedValue) {
this.selectedCategory = selectedValue;
if (!this.categoryHasSubcategories(selectedValue)) {
// Categories without subcategories (print, television, radio, radio).
// Always show the mediaName dropdown; when _items is empty 'Sonstiges' will be
// pre-selected by the caller so the required field remains satisfied.
this.conditionalFields.advertisementSubcategory = true;
}
// reset media name conditional field
this.conditionalFields.mediaName = false;
}
/**
* Set conditional field visibility and reset related fields based on selected subcategory value.
* @param {CustomEvent} e - change event from subcategory radio button group, used to get the selected subcategory value
*/
setSubcategoryItems(e) {
const selectedValue = /** @type {DbpEnumElement} */ (e.currentTarget).value;
if (typeof selectedValue !== 'string') return;
this.setSubcategoryItemsByCategoryValue(selectedValue);
// Get the data object from the form
const data = this.formData;
// Reset fields based on category type
data.advertisementSubcategory = '';
data.mediumOwnersName = '';
data.otherMediumName = '';
data.otherMediumOwnersName = '';
if (this.categoryHasSubcategories(selectedValue)) {
// Category with subcategories (online, outOfHome): wait for subcategory selection
data.mediaName = NOTHING_SELECTED;
this.conditionalFields.advertisementSubcategory = false;
// After the parent and child have both finished rendering,
// explicitly clear the `checked` property on every radio button so the
// group shows no preselected value.
void this.updateComplete.then(async () => {
await this.uncheckRadioButtons();
});
} else {
// Category without subcategories (print, television, radio)
// Check whether there are any known media names under _items
const hasMediaNames =
Object.keys(MEDIA_NAME_OWNER_MAPPING[selectedValue]._items).length > 0;
if (hasMediaNames) {
// Reset to 'Please select'
data.mediaName = NOTHING_SELECTED;
this.conditionalFields.mediaName = false;
} else {
// No known media names — only 'Other' available
data.mediaName = OTHER_MEDIA_VALUE;
this.conditionalFields.mediaName = true;
}
}
this.requestUpdate();
}
/**
* Update conditional field visibility for the media name dropdown.
* Items are computed at render time by getMediaNameItems().
* @param {string} subcategory
*/
setMediaNameItemsByValue(subcategory) {
// Store the selected subcategory so getMediaNameItems() uses the correct mapping.
// This is needed because the inline @change handler fires before the base-class
// handleChangeEvents can update formData.advertisementSubcategory.
if (this.formData) {
this.formData.advertisementSubcategory = subcategory;
}
// Always show the mediaName dropdown so its `required` attribute can be satisfied.
// When the mapping is empty the only available option is 'Sonstiges', which is
// pre-selected by the caller; hiding the field would break browser/schema validation.
this.conditionalFields.advertisementSubcategory = true;
this.requestUpdate();
}
/**
* Get the owner name for a given media name based on the current category and subcategory selection.
* @param {string} mediaName
* @returns {string|null} The owner name for the given media name, or null if not found
*/
getOwnerForMediaName(mediaName) {
const categoryData = MEDIA_NAME_OWNER_MAPPING[this.selectedCategory];
if (!categoryData) return null;
const subcategoryKey = this.categoryHasSubcategories(this.selectedCategory)
? this.formData.advertisementSubcategory
: '_items';
return categoryData[subcategoryKey]?.[mediaName] ?? null;
}
async uncheckRadioButtons() {
const subcategoryEl = this._('[name="advertisementSubcategory"]');
if (subcategoryEl) {
// Wait for the child component to finish its own render cycle
await subcategoryEl.updateComplete;
const radios = subcategoryEl.shadowRoot?.querySelectorAll('input[type="radio"]');
if (radios) {
radios.forEach((radio) => {
radio.checked = false;
});
}
}
}
render() {
return html`
${
this.readOnly
? html`
${this.renderFormViews()}
`
: html`
${this.renderFormElements()}
`
}
`;
}
/**
* Renders the form elements
* @returns {import('lit').TemplateResult} The rendered form elements
*/
renderFormElements() {
const i18n = this._i18n;
const data = this.formData || {};
return html`
<form
id="media-transparency-form"
aria-labelledby="form-title"
class="${classMap({
hidden: this.hideForm,
'formalize-form': true,
'media-transparency-form': true,
'readonly-mode': this.readOnly,
'edit-mode': !this.readOnly,
})}">
<div class="form-header">${this.getButtonRowHtml()}</div>
${
this.submissionIsSaved &&
(this.canUserSubmitSubmission() || this.areSubmissionReadableAfterSubmission())
? html`
<div class="after-submission-button-container after-saving">
${
this.canUserSubmitSubmission()
? html`
<a
href="${getFormRenderUrl(this.formUrlSlug, this.lang)}"
class="button button--new-submission"
@click="${() => {
this.disableLeavePageWarning();
}}">
<dbp-icon
name="plus"
aria-hidden="true"></dbp-icon>
${i18n.t('success.create-new-submission-button-label')}
</a>
`
: ''
}
${
this.areSubmissionReadableAfterSubmission()
? html`
<a
href="${getFormManageFormsUrl(
this.formIdentifier,
this.lang,
)}"
class="button button--back-to-submissions-list"
@click="${() => {
this.disableLeavePageWarning();
}}">
<dbp-icon
name="list"
aria-hidden="true"></dbp-icon>
${i18n.t(
'success.back-to-submissions-list-button-label',
)}
</a>
`
: ''
}
</div>
`
: ''
}
<h2 class="form-title">
${i18n.t('render-form.forms.media-transparency-form.title')}
</h2>
<div class="form-description">
<dbp-translated subscribe="lang">
<div slot="de">
<p>
In diesem Formular können Sie Ihre Werbemaßnahmen eintragen, um die
Transparenz von Werbung an der TU Graz zu erhöhen. Bitte füllen Sie
alle Pflichtfelder aus (gekennzeichnet mit einem roten Stern) und
laden Sie die erforderlichen Dateien hoch, damit Ihre Einreichung
bearbeitet werden kann. Bei Fragen, melden Sie sich bitte bei
<a href="mailto:ulla.lehrmayer@tugraz.at" target="_blank">
Ulla Lehrmayer
</a>
.
</p>
</div>
<div slot="en">
<p>
In this form, you can enter your advertising activities to increase
the transparency of advertising at TU Graz. Please fill in all
required fields (marked with a red asterisk) and upload the
necessary files to ensure that your submission can be processed. If
you have any questions, please contact
<a href="mailto:ulla.lehrmayer@tugraz.at" target="_blank">
Ulla Lehrmayer
</a>
.
</p>
</div>
</dbp-translated>
</div>
<p>
<span class="red-marked-asterisk">
${i18n.t('render-form.required-files-asterisk')}
</span>
${i18n.t('render-form.required-files-text')}
</p>
<dbp-form-enum-element
subscribe="lang"
name="category"
data-condition="${JSON.stringify(this.getCategoriesWithSubcategories())}"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-category-label',
)}"
display-mode="list"
.items=${this.getCategoryItems()}
@change=${(e) => {
this.setSubcategoryItems(e);
}}
.value=${data.category || ''}
required></dbp-form-enum-element>
${
this.conditionalFields.category
? html`
${
Object.keys(this.getSubcategoryItems()).length > 0
? html`
<dbp-form-enum-element
class="${classMap({
'fade-in':
Object.keys(this.getSubcategoryItems())
.length > 0,
})}"
subscribe="lang"
data-condition="!Sonstiges"
name="advertisementSubcategory"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-advertisement-subcategory-label',
)}"
display-mode="list"
.items=${this.getSubcategoryItems()}
.value=${data.advertisementSubcategory || ''}
@change=${(e) => {
this.setMediaNameItemsByValue(
e.currentTarget.value,
); // populate mediaName list on subcategory change
// Reset media name to empty or 'Sonstiges' depending on available options
const mapping =
MEDIA_NAME_OWNER_MAPPING[
this.selectedCategory
]?.[e.currentTarget.value] || {};
if (Object.keys(mapping).length === 0) {
// Only 'Sonstiges' is available
data.mediaName = OTHER_MEDIA_VALUE;
this.conditionalFields.mediaName = true;
} else {
// Reset to 'Please select'
data.mediaName = NOTHING_SELECTED;
this.conditionalFields.mediaName = false;
}
// Clear related fields
data.mediumOwnersName = '';
data.otherMediumName = '';
data.otherMediumOwnersName = '';
this.requestUpdate();
}}
required></dbp-form-enum-element>
`
: ''
}
`
: ''
}
${
this.conditionalFields.advertisementSubcategory
? html`
<dbp-form-enum-element
subscribe="lang"
name="mediaName"
data-condition="Sonstiges"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-media-name-label',
)}"
display-mode="dropdown"
.items=${this.getMediaNameItems()}
.customValidator=${(value) => {
return value === NOTHING_SELECTED
? [
i18n.t(
'render-form.forms.media-transparency-form.media-name-validation-error',
),
]
: [];
}}
@change=${(e) => {
const selectedValue = e.currentTarget.value;
data.mediaName = selectedValue;
// If 'Other' or placeholder is selected, show other medium fields
if (
selectedValue === OTHER_MEDIA_VALUE ||
selectedValue === NOTHING_SELECTED
) {
this.conditionalFields.mediaName = true;
data.mediumOwnersName = '';
} else {
this.conditionalFields.mediaName = false;
// Auto-fill owner name from mapping
const owner = this.getOwnerForMediaName(selectedValue);
data.mediumOwnersName = owner ?? '';
}
this.requestUpdate();
}}
.value=${data.mediaName || NOTHING_SELECTED}
required></dbp-form-enum-element>
${
!this.conditionalFields.mediaName
? html`
<dbp-form-string-element
subscribe="lang"
name="mediumOwnersName"
maxlength="1000"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-media-owners-name-label',
)}"
disabled
required
.value=${
data.mediumOwnersName || ''
}></dbp-form-string-element>
`
: ''
}
`
: ''
}
${
this.conditionalFields.mediaName
? html`
<dbp-form-string-element
subscribe="lang"
name="otherMediumName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-name-label',
)}"
required
maxlength="1000"
.value=${data.otherMediumName || ''}></dbp-form-string-element>
<dbp-form-string-element
subscribe="lang"
name="otherMediumOwnersName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-owners-name-label',
)}"
required
maxlength="1000"
.value=${data.otherMediumOwnersName || ''}></dbp-form-string-element>
`
: ''
}
${
data.category === 'Out of Home' && data.advertisementSubcategory === 'Sonstiges'
? html`
<div
style="visibility:hidden; height:0; margin:0; padding:0; border:0;">
<dbp-form-string-element
subscribe="lang"
name="otherMediumName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-name-label',
)}"
maxlength="1000"
value=""></dbp-form-string-element>
</div>
<dbp-form-enum-element
subscribe="lang"
name="otherMediumOwnersName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-owners-name-label',
)}"
display-mode="dropdown"
.items=${this.getMediaNameItems()}
@change=${(e) => {
const selectedValue = e.currentTarget.value;
// If 'Other' or placeholder is selected, show other medium fields
if (
selectedValue === OTHER_MEDIA_VALUE ||
selectedValue === NOTHING_SELECTED
) {
this.conditionalFields.outOfHomeOtherMediunOwner = true;
data.otherMediumOwnersName = '';
data.otherMediumOwnersNameCustom = '';
} else {
this.conditionalFields.outOfHomeOtherMediunOwner = false;
data.otherMediumOwnersNameCustom = '';
// Auto-fill owner name from mapping
const owner = this.getOwnerForMediaName(selectedValue);
data.otherMediumOwnersName = owner ?? '';
}
this.requestUpdate();
}}
.customValidator=${(value) => {
return value === NOTHING_SELECTED
? [
i18n.t(
'render-form.forms.media-transparency-form.media-name-validation-error',
),
]
: [];
}}
.value=${data.otherMediumOwnersName || NOTHING_SELECTED}
required></dbp-form-enum-element>
${
this.conditionalFields.outOfHomeOtherMediunOwner
? html`
<dbp-form-string-element
subscribe="lang"
name="otherMediumOwnersNameCustom"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-media-owners-name-label',
)}"
required
maxlength="1000"
.value=${
data.otherMediumOwnersNameCustom || ''
}></dbp-form-string-element>
`
: ''
}
`
: ''
}
<dbp-form-string-element
subscribe="lang"
name="amountInEuro"
maxlength="20"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-amount-in-euro-label',
)}"
description="${i18n.t('render-form.forms.media-transparency-form.field-amount-in-euro-description')}"
.customValidator=${(value) => {
const re = /^\d+(?:,\d{1,2})?$/;
return re.test(value)
? null
: i18n.t(
'render-form.forms.media-transparency-form.validation-amount-in-euro-label',
);
}}
.value=${data.amountInEuro || ''}
required></dbp-form-string-element>
<dbp-form-string-element
subscribe="lang"
name="campaignTitle"
maxlength="1000"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-campaign-title-label',
)}"
.value=${data.campaignTitle || ''}></dbp-form-string-element>
<!-- Notes -->
<dbp-form-string-element
subscribe="lang"
name="notes"
rows="5"
maxlength="1000"
label="${i18n.t('render-form.forms.media-transparency-form.field-notes-label')}"
.value=${data.notes || ''}></dbp-form-string-element>
<!-- Reporting deadline period -->
<dbp-form-enum-element
subscribe="lang"
name="reportingDeadline"
.items=${this.getDeadlinePeriodItems()}
required
label="${i18n.t(
'render-form.forms.media-transparency-form.field-reporting-deadline-period-label',
)}"
description="${i18n.t(
'render-form.forms.media-transparency-form.field-reporting-deadline-period-description',
)}"
.customValidator=${(value) => {
return value === 'Bitte wählen Sie den Anmeldezeitraum aus' ||
value === 'Please select deadline period'
? [
i18n.t(
'render-form.forms.media-transparency-form.reporting-deadline-validation-error',
),
]
: [];
}}
.value=${data.reportingDeadline || ''}></dbp-form-enum-element>
${this.getFileGroupsFromSchema().map((groupName) =>
this.renderFileUploadGroup(groupName),
)}
</form>
<dbp-file-source
id="file-source"
class="file-source"
lang="${this.lang}"
allowed-mime-types="application/pdf,image/jpeg,image/png,image/gif,video/mp4,video/mpeg,
video/webm,audio/mpeg,audio/ogg,audio/flac,audio/mp4"
max-file-size="100000"
number-of-files="1"
enabled-targets="local,clipboard,nextcloud"
subscribe="nextcloud-auth-url,nextcloud-web-dav-url,nextcloud-name,nextcloud-file-url"></dbp-file-source>
<dbp-file-sink
id="file-sink"
class="file-sink"
lang="${this.lang}"
decompress-zip
enabled-targets="local,clipboard,nextcloud"
filename="media-transparency-form-${this.formData?.id || ''}-attachments.zip"
subscribe="nextcloud-auth-url,nextcloud-web-dav-url,nextcloud-name,nextcloud-file-url"></dbp-file-sink>
<dbp-modal
id="pdf-view-modal"
class="pdf-view-modal"
modal-id="pdf-viewer-modal"
subscribe="lang">
<div slot="content">
<dbp-pdf-viewer
id="dbp-pdf-viewer"
lang="${this.lang}"
auto-resize="cover"></dbp-pdf-viewer>
</div>
</dbp-modal>
${this.renderResult(this.submitted)}
`;
}
/**
* Renders the read-only form elements
* @returns {import('lit').TemplateResult} The rendered form elements
*/
renderFormViews() {
const i18n = this._i18n;
const data = this.formData || {};
return html`
<form
id="media-transparency-form"
aria-labelledby="form-title"
class="${classMap({
hidden: this.hideForm,
'formalize-form': true,
'media-transparency-form': true,
'readonly-mode': this.readOnly,
'edit-mode': !this.readOnly,
})}">
<div class="form-header">${this.getButtonRowHtml()}</div>
<h2 class="form-title">
${i18n.t('render-form.forms.media-transparency-form.title')}
</h2>
<dbp-form-enum-view
subscribe="lang"
name="category"
data-condition="${JSON.stringify(this.getCategoriesWithSubcategories())}"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-category-label',
)}"
display-mode="list"
.value=${data.category || ''}></dbp-form-enum-view>
${
this.conditionalFields.advertisementSubcategory &&
Object.keys(this.getSubcategoryItems()).length > 0
? html`
<dbp-form-enum-view
class="${classMap({
'fade-in': Object.keys(this.getSubcategoryItems()).length > 0,
})}"
subscribe="lang"
name="advertisementSubcategory"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-advertisement-subcategory-label',
)}"
display-mode="list"
.items=${this.getSubcategoryItems()}
.value=${data.advertisementSubcategory || ''}></dbp-form-enum-view>
`
: ''
}
${
data.mediaName
? html`
<dbp-form-enum-view
subscribe="lang"
name="mediaName"
data-condition="!Sonstiges"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-media-name-label',
)}"
display-mode="dropdown"
.value=${data.mediaName || ''}></dbp-form-enum-view>
`
: ''
}
${
data.mediumOwnersName
? html`
<dbp-form-string-view
subscribe="lang"
name="mediumOwnersName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-media-owners-name-label',
)}"
.value=${data.mediumOwnersName || ''}></dbp-form-string-view>
`
: ''
}
${
data.otherMediumName || data.otherMediumOwnersName
? html`
<dbp-form-string-view
subscribe="lang"
name="otherMediumName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-name-label',
)}"
.value=${data.otherMediumName || ''}></dbp-form-string-view>
<dbp-form-string-view
subscribe="lang"
name="otherMediumOwnersName"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-other-medium-owners-name-label',
)}"
.value=${data.otherMediumOwnersName || ''}></dbp-form-string-view>
`
: ''
}
<dbp-form-string-view
subscribe="lang"
name="amountInEuro"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-amount-in-euro-label',
)}"
.value=${data.amountInEuro || ''}></dbp-form-string-view>
${
data.campaignTitle
? html`
<dbp-form-string-view
subscribe="lang"
name="campaignTitle"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-campaign-title-label',
)}"
.value=${data.campaignTitle || ''}></dbp-form-string-view>
`
: ''
}
<!-- Notes -->
${
data.notes
? html`
<dbp-form-string-view
subscribe="lang"
name="notes"
rows="5"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-notes-label',
)}"
.value=${data.notes || ''}></dbp-form-string-view>
`
: ''
}
<!-- Reporting deadline -->
${
data.reportingDeadline
? html`
<dbp-form-enum-view
subscribe="lang"
name="reportingDeadline"
label="${i18n.t(
'render-form.forms.media-transparency-form.field-reporting-deadline-label',
)}"
.value=${data.reportingDeadline || ''}></dbp-form-enum-view>
`
: ''
}
${this.getFileGroupsFromSchema().map((groupName) =>
this.renderFileViewGroup(groupName),
)}
</form>
<dbp-file-sink
id="file-sink"
class="file-sink"
lang="${this.lang}"
decompress-zip
enabled-targets="local,clipboard,nextcloud"
filename="media-transparency-form-${this.formData?.id || ''}-attachments.zip"
subscribe="nextcloud-auth-url,nextcloud-web-dav-url,nextcloud-name,nextcloud-file-url"></dbp-file-sink>
<dbp-modal
id="pdf-view-modal"
class="pdf-view-modal"
modal-id="pdf-viewer-modal"
subscribe="lang">
<div slot="content">
<dbp-pdf-viewer
id="dbp-pdf-viewer"
lang="${this.lang}"
auto-resize="cover"></dbp-pdf-viewer>
</div>
</dbp-modal>
<dbp-grant-permission-dialog
id="grant-permission-dialog"
lang="${this.lang}"
modal-title="${i18n.t('manage-forms.edit-permission-modal-title')}"
subscribe="auth"
entry-point-url="${this.entryPointUrl}"
resource-identifier="${this.submissionId}"
resource-class-identifier="DbpRelayFormalizeSubmission"></dbp-grant-permission-dialog>
<!-- Deletion Confirmation Modal -->
<dbp-modal
id="deletion-confirmation-modal--formalize"
class="modal modal--confirmation"
modal-id="deletion-confirmation-modal"
title="${i18n.t('manage-forms.delete-confirmation-title')}"
subscribe="lang">
<div slot="content">
<p>${i18n.t('manage-forms.delete-confirmation-message')}</p>
</div>
<menu slot="footer" class="footer-menu">
<dbp-button
type="is-secondary"
no-spinner-on-click
@click="${() => handleDeletionCancel(this)}">
${i18n.t('manage-forms.abort')}
</dbp-button>
<dbp-button
type="is-danger"
no-spinner-on-click
@click="${() => handleDeletionConfirm(this)}">
${i18n.t('manage-forms.delete')}
</dbp-button>
</menu>
</dbp-modal>
`;
}
/**
* Render text after successfull submission.
* @param {boolean} submitted
* @returns {import('lit').TemplateResult} HTML for the result of the submission.
*/
renderResult(submitted) {
const i18n = this._i18n;
if (submitted) {
return html`
<div class="container">
<h2>
${i18n.t(
'render-form.forms.media-transparency-form.submission-result-thanks',
)}
</h2>
<p>
${i18n.t(
'render-form.forms.media-transparency-form.submission-result-notification',
)}
</p>
<div class="after-submission-button-container">
${
this.canUserSubmitSubmission()
? html`
<a
href="${getFormRenderUrl(this.formUrlSlug, this.lang)}"
class="button button--new-submission">
<dbp-icon name="plus" aria-hidden="true"></dbp-icon>
${i18n.t('success.create-new-submission-button-label')}
</a>
`
: ''
}
${
this.areSubmissionReadableAfterSubmission()
? html`
<a
href="${getFormManageFormsUrl(
this.formIdentifier,
this.lang,
)}"
class="button button--back-to-submissions-list">
<dbp-icon name="list" aria-hidden="true"></dbp-icon>
${i18n.t('success.back-to-submissions-list-button-label')}
</a>
`
: ''
}
</div>
</div>
`;
}
return html``;
}
/**
* Opens the file picker dialog.
* @param {Event} event - Click event
*/
openFilePicker(event) {
event.preventDefault();
const fileSource = this._('dbp-file-source');
fileSource.setAttribute('dialog-open', '');
}
/**
* Override deleteAttachment to update validation widget when files are removed
* @param {string} fileIdentifier - uuid
* @param {string} fileGroup - The group of files to handle
*/
async deleteAttachment(fileIdentifier, fileGroup) {
// Call parent method to handle deletion
super.deleteAttachment(fileIdentifier, fileGroup);
// Update validation widget if in edit mode
if (!this.readOnly) {
const requiredFieldsValidated = await validateRequiredFields(this.formElement, true);
const fileValidation = this.validateMinimumFileUploads();
this.isFormValid = requiredFieldsValidated && fileValidation.isValid;
}
}
/**
* Handle files to submit event from file source component.
* @param {CustomEvent} event - The event object containing the file data.
*/
async handleFilesToSubmit(event) {
// Call base class method to handle file addition
super.handleFilesToSubmit(event);
// Update validation widget status
const requiredFieldsValidated = await validateRequiredFields(this.formElement, true);
const fileValidation = this.validateMinimumFileUploads();
this.isFormValid = requiredFieldsValidated && fileValidation.isValid;
}
}