@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
JavaScript
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>