UNPKG

@digital-blueprint/formalize-app

Version:

[GitHub Repository](https://github.com/digital-blueprint/formalize-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/formalize-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/formalize-app/) | [Formalize Bundle](https:

1,322 lines (1,184 loc) 223 kB
import {BaseFormElement, BaseObject} from '../form/base-object.js'; import {html, css} from 'lit'; import {classMap} from 'lit-html/directives/class-map.js'; import * as commonStyles from '@dbp-toolkit/common/styles.js'; import {Button, Icon} from '@dbp-toolkit/common'; import {send} from '@dbp-toolkit/common/notification.js'; import {FileSource, FileSink} from '@dbp-toolkit/file-handling'; import {GrantPermissionDialog} from '@dbp-toolkit/grant-permission-dialog'; import {Modal} from '@dbp-toolkit/common/src/modal.js'; import {PdfViewer} from '@dbp-toolkit/pdf-viewer'; import {getFormRenderUrl, formatDate, httpGetAsync} from '../utils.js'; import {getEthicsCommissionFormCSS, getEthicsCommissionFormPrintCSS} from '../styles.js'; import { DbpStringElement, DbpDateElement, DbpBooleanElement, DbpEnumElement, DbpStringView, DbpDateView, DbpEnumView, } from '@dbp-toolkit/form-elements'; import {SUBMISSION_STATE_DRAFT, SUBMISSION_STATE_SUBMITTED} from '../utils.js'; import { gatherFormDataFromElement /*, validateRequiredFields*/, } from '@dbp-toolkit/form-elements/src/utils.js'; import html2pdf from 'html2pdf.js'; export default class extends BaseObject { getUrlSlug() { return 'ethics-commission'; } /** * Returns the form component class for the ethics commission form. * * @returns {typeof BaseFormElement} The class of the form component. */ getFormComponent() { return FormalizeFormElement; } getFormIdentifier() { return '32297d33-1352-4cf2-ba06-1577911c3537'; } } class FormalizeFormElement extends BaseFormElement { constructor() { super(); this.isDraftMode = false; this.isSubmittedMode = false; this.submitted = false; this.submissionError = false; this.scrollTimeout = null; this.submitterName = null; this.newSubmissionId = null; this.resourceActions = []; this.isSavingDraft = false; this.draftSaveError = false; // Button this.isDraftButtonAllowed = false; this.isDeleteSubmissionButtonAllowed = false; this.isAcceptButtonEnabled = false; this.isSubmitButtonEnabled = true; this.isPrintButtonAllowed = false; this.isDownloadButtonAllowed = false; this.userAllDraftSubmissions = []; this.userAllSubmittedSubmissions = []; this.submittedFiles = new Map(); this.submittedFilesCount = 0; this.filesToSubmit = new Map(); this.filesToSubmitCount = 0; this.filesToRemove = []; this.fileUploadError = false; this.handleSaveDraft = this.handleSaveDraft.bind(this); this.handleFormSubmission = this.handleFormSubmission.bind(this); this.handleFormDeleteSubmission = this.handleFormDeleteSubmission.bind(this); this.handleFormAcceptSubmission = this.handleFormAcceptSubmission.bind(this); this.handleScrollToTopBottom = this.handleScrollToTopBottom.bind(this); this.permissionModalClosedHandler = this.permissionModalClosedHandler.bind(this); this.humanTestSubjectsQuestionsEnabled = false; this.humanStemCellsQuestionsEnabled = false; this.stemCellFromHumanEmbryosQuestionsEnabled = false; this.cellsObtainedInResearchQuestionsEnabled = true; this.harmfulSubstancesOnSubjects = false; this.animalQuestionsEnabled = false; this.nonEuCountriesQuestionsEnabled = false; this.questionResearchFoundsQuestionsEnabled = false; this.ethicalIssuesListQuestion = false; this.hasConflictOfInterestSubQuestion = false; this.hasConfidentialPartSubQuestion = false; this.hasConflictInContentControlSubQuestion = false; this.stakeholderParticipationPlannedSubQuestion = false; this.riskSubQuestion = false; this.stemCellFromEmbryosQuestionsEnabled = false; } static get properties() { return { ...super.properties, submitted: {type: Boolean}, submissionError: {type: Boolean}, submittedFilesCount: {type: Number}, filesToSubmitCount: {type: Number}, resourceActions: {type: Object}, // Buttons isDeleteSubmissionButtonAllowed: {type: Boolean}, isDraftButtonAllowed: {type: Boolean}, isAcceptButtonEnabled: {type: Boolean}, isSubmitButtonEnabled: {type: Boolean}, humanTestSubjectsQuestionsEnabled: {type: Boolean}, humanStemCellsQuestionsEnabled: {type: Boolean}, stemCellFromHumanEmbryosQuestionsEnabled: {type: Boolean}, cellsObtainedInResearchQuestionsEnabled: {type: Boolean}, harmfulSubstancesOnSubjects: {type: Boolean}, animalQuestionsEnabled: {type: Boolean}, nonEuCountriesQuestionsEnabled: {type: Boolean}, questionResearchFoundsQuestionsEnabled: {type: Boolean}, ethicalIssuesListQuestion: {type: Boolean}, hasConflictOfInterestSubQuestion: {type: Boolean}, hasConfidentialPartSubQuestion: {type: Boolean}, hasConflictInContentControlSubQuestion: {type: Boolean}, stakeholderParticipationPlannedSubQuestion: {type: Boolean}, riskSubQuestion: {type: Boolean}, stemCellFromEmbryosQuestionsEnabled: {type: Boolean}, }; } static get scopedElements() { return { '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-file-source': FileSource, 'dbp-file-sink': FileSink, 'dbp-pdf-viewer': PdfViewer, 'dbp-grant-permission-dialog': GrantPermissionDialog, 'dbp-modal': Modal, 'dbp-button': Button, 'dbp-icon': Icon, }; } async firstUpdated() {} async update(changedProperties) { // console.log('changedProperties', changedProperties); if (changedProperties.has('data')) { if (Object.keys(this.data).length > 0) { await this.processFormData(); } this.updateComplete.then(async () => { await this.processConditionalFields(); }); } if (changedProperties.has('formProperties')) { if (Object.keys(this.formProperties).length > 0) { this.allowedActionsWhenSubmitted = this.formProperties.allowedActionsWhenSubmitted; this.setButtonStates(); } } if (changedProperties.has('formIdentifier')) { if (this.formIdentifier) { await this.getUsersGrants(); } } if (changedProperties.has('userAllSubmissions')) { this.userAllSubmittedSubmissions = this.userAllSubmissions.filter( (submission) => submission.submissionState === 4, ); this.userAllDraftSubmissions = this.userAllSubmissions.filter( (submission) => submission.submissionState === 1, ); this.setButtonStates(); } super.update(changedProperties); } /** * Sets the button states based on the submission state and user permissions. */ setButtonStates() { // Show draft button if DRAFT state is allowed and not yet submitted if (this.allowedSubmissionStates === 5) { this.isDraftButtonAllowed = !this.isSubmittedMode; } // Don't show submit button if form already submitted if (this.isSubmittedMode) { this.isSubmitButtonEnabled = false; } if (this.readOnly) { this.isPrintButtonAllowed = true; this.isDownloadButtonAllowed = true; this.isDraftButtonAllowed = false; this.isSubmitButtonEnabled = false; } // Show delete button if the user has delete permission // @TODO: Do we need to check for 'manage' permission here? if (this.submissionId && Object.keys(this.formProperties).length > 0) { this.isDeleteSubmissionButtonAllowed = !!this.formProperties.allowedActionsWhenSubmitted.includes('delete'); } // Show accept button if user has manage permission and the submission state is SUBMITTED if (this.userAllSubmittedSubmissions.length > 0) { const isSubmission = this.userAllSubmittedSubmissions.filter( (submission) => submission.identifier === this.submissionId, ).length === 1; if (this.submissionId && isSubmission && Object.keys(this.formProperties).length > 0) { this.isAcceptButtonEnabled = !!this.formProperties.grantedActions.includes('manage'); } } } async processFormData() { try { this.submissionId = this.data.identifier; this.formData = JSON.parse(this.data.dataFeedElement); this.submissionState = this.data.submissionState; this.grantedActions = this.data.grantedActions; this.submittedFiles = await this.transformApiResponseToFile(this.data.submittedFiles); this.submittedFilesCount = this.submittedFiles.size; this.isDraftMode = this.submissionState === 1; this.isSubmittedMode = this.submissionState === 4; if (this.formData) { try { const submitterDetailsResponse = await this.apiGetUserDetails( this.formData.identifier, ); if (!submitterDetailsResponse.ok) { send({ summary: 'Error', body: `Failed to get submitter details. Response status: ${submitterDetailsResponse.status}`, type: 'danger', timeout: 5, }); } const submitterDetails = await submitterDetailsResponse.json(); this.submitterName = `${submitterDetails.givenName} ${submitterDetails.familyName}`; } catch (e) { console.log(e); send({ summary: 'Error', body: `Failed to get submitter details`, type: 'danger', timeout: 5, }); } } } catch (e) { console.error('Error parsing submission data:', e); } } async getUsersGrants() { try { // Get user permissions for the form const resourceActionsResponse = await this.apiGetResourceActionGrants(); if (!resourceActionsResponse.ok) { send({ summary: 'Error', body: `Failed to get permission details. Response status: ${resourceActionsResponse.status}`, type: 'danger', timeout: 5, }); } const resourceActionsBody = await resourceActionsResponse.json(); let resourceActions = []; if (resourceActionsBody['hydra:member'].length > 0) { for (const resourceAction of resourceActionsBody['hydra:member']) { // Only process user grant, skip group permissions if (resourceAction.userIdentifier) { const userDetailsResponse = await this.apiGetUserDetails( resourceAction.userIdentifier, ); if (!userDetailsResponse.ok) { send({ summary: 'Error', body: `Failed to get submitter details. Response status: ${userDetailsResponse.status}`, type: 'danger', timeout: 5, }); } const userDetails = await userDetailsResponse.json(); const userFullName = `${userDetails.givenName} ${userDetails.familyName}`; // Group permissions by user id let userEntry = resourceActions.find( (entry) => entry.userId === resourceAction.userIdentifier, ); if (!userEntry) { userEntry = { userId: resourceAction.userIdentifier, userName: userFullName, actions: [], }; resourceActions.push(userEntry); } userEntry.actions.push(resourceAction.action); } } this.resourceActions = resourceActions; } } catch (e) { console.log(e); send({ summary: 'Error', body: `Failed to process user permissions`, type: 'danger', timeout: 5, }); } } /** * Handle conditional fields initialization. */ async processConditionalFields() { const conditionalFields = this._a('.conditional-field'); const conditionalFieldsCount = conditionalFields.length; conditionalFields.forEach((field) => { const value = field.value; if (!value) return; if (field.dataset.targetVariable) { const targetVariable = field.dataset.targetVariable; const condition = field.dataset.condition || 'yes'; if (this[targetVariable] !== undefined) { this[targetVariable] = value === condition; } } }); await this.updateComplete; // Run again to handle conditional fields inside other conditional fields const newConditionalFieldsCount = this._a('.conditional-field').length; if (newConditionalFieldsCount > conditionalFieldsCount) { this.processConditionalFields(); } } static get styles() { // language=css return css` ${commonStyles.getGeneralCSS(false)} ${commonStyles.getButtonCSS()} ${commonStyles.getModalDialogCSS()} ${getEthicsCommissionFormCSS()} `; } /** * Opens the file picker dialog. * @param {object} event - Click event */ openFilePicker(event) { event.preventDefault(); const fileSource = this._('dbp-file-source'); fileSource.setAttribute('dialog-open', ''); } /** * Renders attached file list with action buttons * @returns {Array|null} An array of rendered file elements or null if no files are present. */ renderAttachedFilesHtml() { if (this.submittedFiles.size === 0 && this.filesToSubmit.size === 0) { return null; } let results = []; const allAttachments = new Map([...this.submittedFiles, ...this.filesToSubmit]); allAttachments.forEach((file, identifier) => { results.push(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"> <button class="view-file-button button is-secondary" @click=${(e) => { e.preventDefault(); // Open modal this._('#pdf-view-modal').open(); // Open PDF viewer this._('dbp-pdf-viewer').showPDF(file); }}> <dbp-icon name="eye"></dbp-icon> ${this._i18n.t( 'render-form.forms.ethics-commission-form.view-attachment', )} </button> <button class="download-file-button button is-secondary" @click=${(e) => { e.preventDefault(); this._('#file-sink').files = [file]; }}> <dbp-icon name="download"></dbp-icon> ${this._i18n.t( 'render-form.forms.ethics-commission-form.download-attachment', )} </button> <button class="delete-file-button button is-secondary" @click=${(e) => { e.preventDefault(); this.deleteAttachment(identifier); }}> <dbp-icon name="trash"></dbp-icon> ${this._i18n.t( 'render-form.forms.ethics-commission-form.delete-attachment', )} </button> </div> </div> `); }); return results; } connectedCallback() { super.connectedCallback(); this.updateComplete.then(() => { // Handle scroller icon changes window.addEventListener('scroll', this.handleScrollToTopBottom); // Listen to the event from file source this.addEventListener('dbp-file-source-file-selected', (event) => { this.filesToSubmit.set(event.detail.file.name, event.detail.file); this.filesToSubmitCount = this.filesToSubmit.size; }); this.addEventListener('dbp-modal-closed', this.permissionModalClosedHandler); // Event listener for saving draft this.addEventListener('DbpFormalizeFormSaveDraft', this.handleSaveDraft); // Event listener for form submission this.addEventListener('DbpFormalizeFormSubmission', this.handleFormSubmission); // Event listener for form submission this.addEventListener( 'DbpFormalizeFormDeleteSubmission', this.handleFormDeleteSubmission, ); // Event listener for accepting submission this.addEventListener( 'DbpFormalizeFormAcceptSubmission', this.handleFormAcceptSubmission, ); }); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('DbpFormalizeFormSaveDraft', this.handleSaveDraft); this.removeEventListener('DbpFormalizeFormSubmission', this.handleFormSubmission); this.removeEventListener( 'DbpFormalizeFormDeleteSubmission', this.handleFormDeleteSubmission, ); this.removeEventListener( 'DbpFormalizeFormAcceptSubmission', this.handleFormAcceptSubmission, ); this.removeEventListener('dbp-modal-closed', this.permissionModalClosedHandler); window.removeEventListener('scroll', this.handleScrollToTopBottom); } permissionModalClosedHandler(event) { if (event.detail.id && event.detail.id === 'grant-permission-modal') { this.getUsersGrants(); } } handleScrollToTopBottom() { const i18n = this._i18n; clearTimeout(this.scrollTimeout); this.scrollTimeout = setTimeout(() => { // Update scroller icon based on scroll position const html = document.documentElement; const form = this._('#ethics-commission-form'); const icon = this._('#form-scroller dbp-icon'); if (!icon) { return; } const screenReaderText = this._('#form-scroller .visually-hidden'); if (html.scrollTop < form.scrollHeight / 2) { icon.setAttribute('name', 'chevron-down'); icon.setAttribute( 'title', i18n.t('render-form.forms.ethics-commission-form.scroll-to-bottom-text'), ); screenReaderText.textContent = i18n.t( 'render-form.forms.ethics-commission-form.scroll-to-bottom-text', ); } else { icon.setAttribute('name', 'chevron-up'); icon.setAttribute( 'title', i18n.t('render-form.forms.ethics-commission-form.scroll-to-top-text'), ); screenReaderText.textContent = i18n.t( 'render-form.forms.ethics-commission-form.scroll-to-top-text', ); } }, 150); } /** * Handle saving draft submission. * @param {object} event - The event object containing the form data. */ async handleSaveDraft(event) { // Access the data from the event detail const data = event.detail; // Include unique identifier for person who is submitting data.formData.identifier = this.auth['user-id']; this.isSavingDraft = true; const formData = new FormData(); // Upload attached files if (this.filesToSubmitCount > 0) { this.filesToSubmit.forEach((fileToAttach) => { formData.append('file[]', fileToAttach, fileToAttach.name); }); // Remove files added to the request this.filesToSubmit = new Map(); } // Set file to be removed if (this.filesToRemove.length > 0) { formData.append('submittedFilesToDelete', this.filesToRemove.join(',')); // Remove files added to the request this.filesToRemove = []; } formData.append('form', '/formalize/forms/' + this.formIdentifier); formData.append('dataFeedElement', JSON.stringify(data.formData)); formData.append('submissionState', String(SUBMISSION_STATE_DRAFT)); console.log('this.userAllDraftSubmissions', this.userAllDraftSubmissions); // POST or PATCH const isExistingDraft = this.userAllDraftSubmissions?.find( (item) => item.identifier === this.submissionId, ); console.log('formData', [...formData]); console.log('data', data); const method = isExistingDraft ? 'PATCH' : 'POST'; const options = this._buildRequestOptions(formData, method); const url = this._buildSubmissionUrl(isExistingDraft ? this.submissionId : null); try { const response = await fetch(url, options); if (!response.ok) { this.draftSaveError = true; send({ summary: 'Error', body: `Failed to save form DRAFT. Response status: ${response.status}`, type: 'danger', timeout: 5, }); } else { let responseBody = await response.json(); this.data = responseBody; this.newSubmissionId = responseBody.identifier; this.draftSaveSuccessful = true; this.draftSaveError = false; } } catch (error) { console.error(error); send({ summary: 'Error', body: error.message, type: 'danger', timeout: 5, }); } finally { if (this.draftSaveSuccessful) { send({ summary: 'Success', body: 'Draft saved successfully', type: 'success', timeout: 5, }); } this.isSavingDraft = false; // Update URL with the submission ID const newSubmissionUrl = getFormRenderUrl(this.formUrlSlug) + `/${this.newSubmissionId}`; window.history.pushState({}, '', newSubmissionUrl.toString()); } } /** * Handle saving submission. * @param {object} event - The event object containing the form data. */ async handleFormSubmission(event) { // Access the data from the event detail const data = event.detail; // Include unique identifier for person who is submitting data.formData.identifier = this.auth['user-id']; this.isPostingSubmission = true; const formData = new FormData(); // Set file to be removed if (this.filesToRemove.length > 0) { formData.append('submittedFilesToDelete', this.filesToRemove.join(',')); } // Upload attached files if (this.filesToSubmitCount > 0) { this.filesToSubmit.forEach((file) => { formData.append('attachments[]', file, file.name); }); } formData.append('form', '/formalize/forms/' + this.formIdentifier); formData.append('dataFeedElement', JSON.stringify(data.formData)); formData.append('submissionState', String(SUBMISSION_STATE_SUBMITTED)); // If we have a draft submission, we need to update it const isExistingDraft = this.userAllDraftSubmissions?.find( (item) => item.identifier === this.submissionId, ); const method = isExistingDraft ? 'PATCH' : 'POST'; const options = this._buildRequestOptions(formData, method); const url = this._buildSubmissionUrl(isExistingDraft ? this.submissionId : null); try { const response = await fetch(url, options); let responseBody = await response.json(); if (!response.ok) { this.submissionError = true; send({ summary: 'Error', body: `Failed to submit form. Response status: ${response.status}<br>${responseBody.description}`, type: 'danger', timeout: 5, }); } else { this.wasSubmissionSuccessful = true; this.submissionError = false; // Hide form after successful submission this._('#ethics-commission-form').style.display = 'none'; } } catch (error) { console.error(error.message); send({ summary: 'Error', body: error.message, type: 'danger', timeout: 5, }); } finally { if (this.wasSubmissionSuccessful) { send({ summary: 'Success', body: 'Form submitted successfully', type: 'success', timeout: 5, }); } this.submitted = true; this.isPostingSubmission = false; } } /** * Handle deleting submission. * @param {object} event - The event object containing the submission id to delete. */ async handleFormDeleteSubmission(event) { const data = event.detail; const submissionId = data.submissionId; if (!submissionId) { send({ summary: 'Error', body: `No submission id provided`, type: 'danger', timeout: 5, }); return; } try { const response = await fetch( this.entryPointUrl + `/formalize/submissions/${submissionId}`, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.auth.token, }, }, ); if (!response.ok) { this.deleteSubmissionError = true; send({ summary: 'Error', body: `Failed to delete submission. Response status: ${response.status}`, type: 'danger', timeout: 5, }); } else { this.wasDeleteSubmissionSuccessful = true; this.deleteSubmissionError = false; // Hide form after successful deletion this._('#ethics-commission-form').style.display = 'none'; } } catch (error) { console.error(error.message); send({ summary: 'Error', body: error.message, type: 'danger', timeout: 5, }); } finally { if (this.wasDeleteSubmissionSuccessful) { send({ summary: 'Success', body: 'Form submission deleted successfully. You will be redirected to the empty form.', type: 'success', timeout: 5, }); // Redirect to submission list page or to the empty form? // Wait 5 sec before redirecting to allow user to read the success message? setTimeout(() => { const emptyFormUrl = getFormRenderUrl(this.formUrlSlug); window.history.pushState({}, '', emptyFormUrl.toString()); // Reload the page to reflect the new submission ID window.location.reload(); }, 5000); } } } /** * Handle accepting submission. * @param {object} event - The event object containing the form data. */ async handleFormAcceptSubmission(event) { send({ summary: 'Warning', body: 'Not yet implemented', type: 'warning', timeout: 5, }); } /** * 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.auth.token}`, }, body: formData, }; } /** * Build the submission URL. If submissionId is provided, it will be included to the URL. * @param {string} submissionId * @returns {string} The submission URL. */ _buildSubmissionUrl(submissionId = null) { const baseUrl = `${this.entryPointUrl}/formalize/submissions`; return submissionId ? `${baseUrl}/${submissionId}/multipart` : `${baseUrl}/multipart`; } /** * Handle removing files from the list of attachments. * @param {string} identifier */ deleteAttachment(identifier) { this.filesToRemove.push(identifier); this.filesToSubmit.delete(identifier); this.filesToSubmitCount = this.filesToSubmit.size; this.submittedFiles.delete(identifier); this.submittedFilesCount = this.submittedFiles.size; this.requestUpdate(); } /** * Generates a PDF from the form content. */ async generatePDF(save = true) { const form = this._('#ethics-commission-form'); // Set print style form.classList.add('print'); // this.extractShadowContent(form); const restoreElements = this.extractShadowContent(form); // window.scrollTo(0, 0); const opt = { margin: [70, 50], // Don't change vertical margin or lines can break when printing. filename: 'Ethical_Review_Application.pdf', image: {type: 'jpeg', quality: 0.98}, html2canvas: { scale: 2, dpi: 192, scrollY: 0, // Scrolls to page top }, jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait', }, pagebreak: { // mode: ['css'], // before: [ '.section-title' ], }, }; try { const formPdf = await html2pdf() .set(opt) .from(form) .toPdf() .get('pdf') .then((pdf) => { // console.log(pdf); var totalPages = pdf.internal.getNumberOfPages(); for (let i = 1; i <= totalPages; i++) { pdf.setPage(i); this.addHeader(pdf, i); } return pdf; }); if (save) { await formPdf.save(opt.filename); } else { // Convert pdf to blob and create File object const pdfBlob = formPdf.output('blob'); const pdfOutput = new File([pdfBlob], opt.filename, {type: 'application/pdf'}); return pdfOutput; } } finally { // Remove print style after PDF is generated or if there's an error form.classList.remove('print'); // Restore original elements restoreElements(); // Force a re-render // this.requestUpdate(); } } /** * Add header and footer to the PDF. * @param {object} pdf * @param {number} pageNumber */ addHeader(pdf, pageNumber) { const MARGIN_INLINE = 50; const MARGIN_BLOCK = 25; pdf.setFontSize(9); pdf.setTextColor(25); // Header pdf.text('TU Graz', pdf.internal.pageSize.getWidth() - MARGIN_INLINE - 10, MARGIN_BLOCK, { align: 'right', }); // Add a TU graz red square to the header pdf.setDrawColor(255, 0, 0); pdf.setFillColor(255, 0, 0); pdf.setLineWidth(1); // Set the line width pdf.rect( pdf.internal.pageSize.getWidth() - MARGIN_INLINE - 8, MARGIN_BLOCK - 8, 8, 8, 'F', {align: 'right'}, ); pdf.setDrawColor(0, 0, 0); pdf.line( MARGIN_INLINE, MARGIN_BLOCK + 3, pdf.internal.pageSize.getWidth() - MARGIN_INLINE, MARGIN_BLOCK + 3, 'S', ); pdf.setFontSize(8); pdf.text( 'Antrag auf Prüfung der ethischen Vertretbarkeit', MARGIN_INLINE, MARGIN_BLOCK + 12, {align: 'left'}, ); // Footer pdf.text( 'Ethikkommission TU Graz / Geschäftsstelle', pdf.internal.pageSize.getWidth() / 2, pdf.internal.pageSize.getHeight() - MARGIN_BLOCK, {align: 'center'}, ); // Page number pdf.text( String(pageNumber), pdf.internal.pageSize.getWidth() - MARGIN_INLINE, pdf.internal.pageSize.getHeight() - MARGIN_BLOCK, {align: 'right'}, ); } /** * Replace shadow DOM content with its inner HTML. * This is a workaround for the issue with html2pdf.js not being able to handle shadow DOM. * @param {HTMLElement} element */ extractShadowContent(element) { console.log('extractShadowContent', element); console.log('element instanceof HTMLElement', element instanceof HTMLElement); // Store original elements and their clones const shadowElements = []; element.querySelectorAll('*').forEach((el) => { if (el.tagName.startsWith('DBP-FORM') && el.shadowRoot) { const shadowContent = el.shadowRoot.innerHTML; const wrapper = document.createElement('div'); wrapper.innerHTML = shadowContent; const slot = el.querySelector('[slot="label"]'); if (slot) { const slotLabel = slot.textContent; const label = document.createElement('label'); label.textContent = slotLabel; const fieldset = wrapper.querySelector('fieldset'); fieldset.prepend(label); } // Store original element and its clone for later restoration shadowElements.push({ original: el, clone: wrapper, }); // Hide original element and insert clone el.style.display = 'none'; el.insertAdjacentElement('afterend', wrapper); } }); // Return function to restore original state return function restoreElements() { shadowElements.forEach(({original, clone}) => { // Remove clone and restore original element clone.remove(); original.style.display = ''; }); }; } /** * Download all attachments and pdf version of the form as a zip file. * @param {object} event */ async downloadAllFiles(event) { // Get PDF as File object const pdfFile = await this.generatePDF(false); const attachmentFiles = Array.from(this.submittedFiles.values()); this._('#file-sink').files = [pdfFile, ...attachmentFiles]; } /** * Handles scolling up and down the form. * @param {object} event - The click event object. */ handleScroller(event) { const i18n = this._i18n; event.preventDefault(); const html = document.documentElement; const form = this._('#ethics-commission-form'); const icon = this._('#form-scroller dbp-icon'); const screenReaderText = this._('#form-scroller .visually-hidden'); if (html.scrollTop < form.scrollHeight / 2) { html.scrollTo({top: form.scrollHeight, behavior: 'smooth'}); setTimeout(() => { icon.setAttribute('name', 'chevron-up'); icon.setAttribute( 'title', i18n.t('render-form.forms.ethics-commission-form.scroll-to-top-text'), ); screenReaderText.textContent = i18n.t( 'render-form.forms.ethics-commission-form.scroll-to-top-text', ); }, 1500); } else { html.scrollTo({top: 0, behavior: 'smooth'}); setTimeout(() => { icon.setAttribute('name', 'chevron-down'); icon.setAttribute( 'title', i18n.t('render-form.forms.ethics-commission-form.scroll-to-bottom-text'), ); screenReaderText.textContent = i18n.t( 'render-form.forms.ethics-commission-form.scroll-to-bottom-text', ); }, 1500); } } /** * Transforms the API response to a File object. * @param {object} apiFileResponse * @returns {Promise<Map<any, any>>} 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.auth.token, }, }; const response = await fetch(apiFile.downloadUrl, options); if (!response.ok) { // this.handleErrorResponse(response); send({ summary: this._i18n.t('errors.other-title'), body: this._i18n.t('errors.other-body'), type: 'danger', timeout: 5, }); } 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; } } render() { return html` ${this.readOnly ? html` ${this.renderFormViews()} ` : html` ${this.renderFormElements()} `} `; } /** * Gets user details from API * @param {string} userIdentifier * @returns {Promise<object>} response */ async apiGetUserDetails(userIdentifier) { const options = { method: 'GET', headers: { 'Content-Type': 'application/ld+json', Authorization: 'Bearer ' + this.auth.token, }, }; return await httpGetAsync(this.entryPointUrl + `/base/people/${userIdentifier}`, options); } /** * Gets the list of Resource Action Grants * @returns {Promise<object>} response */ async apiGetResourceActionGrants() { const options = { method: 'GET', headers: { 'Content-Type': 'application/ld+json', Authorization: 'Bearer ' + this.auth.token, }, }; return await httpGetAsync( this.entryPointUrl + `/authorization/resource-action-grants?resourceClass=DbpRelayFormalizeForm&resourceIdentifier=${this.formIdentifier}&page=1&perPage=9999`, options, ); } /** * Render submission details * submission date, submitter, last changed. * @returns {import('lit').TemplateResult} The HTML template result */ renderSubmissionDetails() { const i18n = this._i18n; const currentSubmission = this.userAllSubmissions.find( (submission) => submission.identifier === this.submissionId, ); const dateCreated = this.newSubmissionId ? formatDate(this.data.dateCreated) : formatDate(currentSubmission?.dateCreated); const dateLastModified = this.newSubmissionId ? formatDate(this.data.dateLastModified) : formatDate(currentSubmission?.dateLastModified); const deadLine = this.newSubmissionId ? formatDate(this.data.availabilityEnds) : formatDate(currentSubmission?.availabilityEnds); return html` <div class="submission-details"> <div class="submission-dates"> ${deadLine ? html` <div class="submission-deadline"> <span class="label">Submission deadline:</span> <span class="value">${deadLine}</span> </div> ` : ''} ${dateCreated ? html` <div class="submission-date"> <span class="label">Submission date:</span> <span class="value">${dateCreated}</span> </div> ` : ''} ${dateLastModified ? html` <div class="last-modified"> <span class="label">Last modified:</span> <span class="value">${dateLastModified}</span> </div> ` : ''} ${this.submitterName ? html` <div class="submitter"> <span class="label">Submitter:</span> <span class="value">${this.submitterName}</span> </div> ` : ''} </div> <div class="submission-permissions"> <div class="permissions-header"> <span class="user-permissions-title">Access management</span> <dbp-button class="edit-permissions" no-spinner-on-click type="is-primary" @click=${() => this._('#grant-permission-dialog').open()}> <dbp-icon name="lock" aria-hidden="true"></dbp-icon> <span class="button-text"> ${i18n.t( 'render-form.forms.ethics-commission-form.edit-permission-button-text', )} </span> </dbp-button> </div> <span class="users-permissions"> ${this.resourceActions.map( (userEntry) => html` <div class="user-entry"> <span class="person-name">${userEntry.userName}:</span> <span class="person-permissions"> ${userEntry.actions.map( (action) => html` <span class="person-permission">${action}</span> `, )} </span> </div> `, )} </span> </div> </div> `; } /** * Renders the form in read-only mode. * @returns {import('lit').TemplateResult} The HTML for the button row. */ renderFormViews() { const i18n = this._i18n; console.log('-- Render FormalizeFormView --'); console.log('this.formData', this.formData); const data = this.formData || {}; return html` <style> /* Style needs to be inline for html2pdf.js */ ${getEthicsCommissionFormPrintCSS()} </style> <form id="ethics-commission-form" aria-labelledby="form-title"> <div class="scroller-container"> <button id="form-scroller" class="scroller" @click=${this.handleScroller}> <dbp-icon name="chevron-down" title=${i18n.t('render-form.forms.ethics-commission-form.scroll-to-bottom-text')}></dbp-icon> <span class="visually-hidden">${i18n.t('render-form.forms.ethics-commission-form.scroll-to-bottom-text')}</span> </button> </div> ${this.getButtonRowHtml()} <div class="form-details"> ${this.renderSubmissionDetails()} </div> <h2 class="form-title">${i18n.t('render-form.forms.ethics-commission-form.title')}</h2> <div class="type-container"> <dbp-form-enum-view subscribe="lang" label="Typ" .items=${{ study: i18n.t('render-form.forms.ethics-commission-form.study'), publication: i18n.t( 'render-form.forms.ethics-commission-form.publication', ), }} .value=${data.type || ''}> </dbp-form-enum-view> </div> <article> <h3 class="form-sub-title">${i18n.t('render-form.forms.ethics-commission-form.sub-title')}</h3> <dbp-form-string-view subscribe="lang" label="${i18n.t('render-form.forms.ethics-commission-form.applicant-label')}" value=${data.applicant || ''}> </dbp-form-string-view>