@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,114 lines (1,011 loc) • 44.7 kB
JavaScript
// @ts-nocheck
/**
* API helpers for the Manage Forms activity.
*
* Every function receives the calling element (`host`) as its first argument so
* it can access `host.auth`, `host.entryPointUrl`, `host._i18n`, etc.
* This keeps the functions stateless and easy to test.
*/
import {sendNotification} from '@dbp-toolkit/common';
import {
getFormRenderUrl,
SUBMISSION_STATES_BINARY,
FORM_PERMISSIONS,
SUBMISSION_PERMISSIONS,
} from './utils.js';
import xss from 'xss';
import {
createDefaultManageFormsOverviewActions,
createManageFormsOverviewActionContext,
resolveManageFormsOverviewAction,
} from './manage-forms-overview-actions.js';
// ---------------------------------------------------------------------------
// Utility helpers used only by the functions in this module
// ---------------------------------------------------------------------------
/**
* Converts a timestamp to "YYYY-MM-DD HH:MM".
* @param {string} value
* @returns {string}
*/
export function humanReadableDate(value) {
const d = Date.parse(value);
const timestamp = new Date(d);
const year = timestamp.getFullYear();
const month = ('0' + (timestamp.getMonth() + 1)).slice(-2);
const date = ('0' + timestamp.getDate()).slice(-2);
const hours = ('0' + timestamp.getHours()).slice(-2);
const minutes = ('0' + timestamp.getMinutes()).slice(-2);
return year + '-' + month + '-' + date + ' ' + hours + ':' + minutes;
}
/**
* Send a generic "something went wrong" notification.
* @param {object} host
*/
function throwSomethingWentWrongNotification(host) {
const i18n = host._i18n;
sendNotification({
summary: i18n.t('manage-forms.something-went-wrong-title'),
body: i18n.t('manage-forms.something-went-wrong-body'),
type: 'danger',
timeout: 0,
});
}
// ---------------------------------------------------------------------------
// Module loading
// ---------------------------------------------------------------------------
/**
* Load form modules and create ID -> slug mapping.
*
* @param {object} host - The ManageForms element.
* @returns {Promise<void>}
*/
export function loadModules(host) {
// If a load is already in flight, return the same promise so concurrent
// callers await the same completion instead of returning early and then
// proceeding while modules are still resolving.
if (host.loadModulesPromise) return host.loadModulesPromise;
host.isLoadingModules = true;
host.loadModulesPromise = (async () => {
try {
const response = await fetch(host.basePath + 'modules.json');
const data = await response.json();
for (const path of Object.values(data['forms'])) {
// Resolve the module path relative to basePath so that relative paths
// in modules.json (e.g. "./jobOfferForm.js") work correctly even when
// the bundled API file lives in a sub-directory (e.g. /dist/shared/).
const absolutePath = new URL(path, window.location.origin + host.basePath).href;
const module = await import(absolutePath);
const object = new module.default();
if (object.getFormIdentifier && object.getUrlSlug) {
host.forms.set(object.getFormIdentifier(), {
formName: null,
formId: object.getFormIdentifier(),
formSlug: object.getUrlSlug(),
moduleInstance: object,
});
// Also store in a dedicated map so getCreatableModules() can enumerate
// only module definitions and is not confused by backend form instances.
host.loadedModules.set(object.getUrlSlug(), {
formId: object.getFormIdentifier(),
formSlug: object.getUrlSlug(),
formName: null,
moduleInstance: object,
});
}
}
} catch (error) {
console.error('Error loading modules:', error);
} finally {
host.isLoadingModules = false;
// Clear the in-flight promise so a future call can trigger a fresh load.
host.loadModulesPromise = null;
}
})();
return host.loadModulesPromise;
}
// ---------------------------------------------------------------------------
// Forms list
// ---------------------------------------------------------------------------
/**
* Creates the standard icon button used for an action in a manage-forms table row.
*
* @param {object} host - The ManageForms element.
* @param {{id: string, iconName?: string, title: string, ariaLabel: string, handler: (context: object) => void}} action
* @param {object|(() => object)} context - Data passed to the action handler.
* @returns {HTMLElement}
*/
export function createManageFormsOverviewActionButton(host, action, context) {
const button = host.createScopedElement('dbp-formalize-get-details-button');
button.setAttribute('subscribe', 'lang');
button.dataset.action = action.id;
if (action.iconName) {
button.iconName = action.iconName;
}
const initialContext = typeof context === 'function' ? context() : context;
const resolvedAction = resolveManageFormsOverviewAction(action, initialContext);
button.title = resolvedAction.title;
button.ariaLabel = resolvedAction.ariaLabel;
button.addEventListener('click', (event) => {
const contextValue = typeof context === 'function' ? context() : context;
const currentContext = {...contextValue, event};
const currentAction = resolveManageFormsOverviewAction(action, currentContext);
if (currentAction.visible && currentAction.enabled) {
action.handler(currentContext);
}
});
return button;
}
/**
* Fetch the list of all forms and populate `host.allForms`.
*
* Forms are filtered by `host.allowListFrontendKeys` and `host.denyListFrontendKeys`,
* which contain `frontendKey` values. A single frontendKey may match multiple
* forms, so one entry in the list can show a whole group of forms.
*
* @param {object} host - The ManageForms element.
* @returns {Promise<void>}
*/
export async function getListOfAllForms(host) {
const i18n = host._i18n;
// Make sure module loading has finished before building the list. Several
// reactive triggers (lang, allow/deny lists, routing) can call this while a
// loadModules() started by another trigger is still in flight; awaiting it
// here guarantees moduleInstance is resolved so the edit button is included.
if (host.loadModulesPromise) {
await host.loadModulesPromise;
}
try {
host.loadCourses = false;
// Only show the loading spinner if the table hasn't been built yet.
// On subsequent calls (e.g. triggered by a token refresh) we keep the
// existing table visible so the user doesn't see a disruptive flash.
const isInitialLoad = !host.allForms || host.allForms.length === 0;
if (isInitialLoad) {
host.loadingFormsTable = true;
}
const response = await fetch(host.entryPointUrl + '/formalize/forms' + '?perPage=9999', {
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + host.auth.token,
},
});
if (!response.ok) {
host.handleErrorResponse(response);
} else {
let data = [];
let forms = [];
try {
data = await response.json();
} catch (e) {
host.sendErrorAnalyticsEvent('LoadListOfAllCourses', 'WrongResponse', e);
throwSomethingWentWrongNotification(host);
host.loadCourses = true;
return;
}
const allowList = Array.isArray(host.allowListFrontendKeys)
? host.allowListFrontendKeys
: [];
const denyList = Array.isArray(host.denyListFrontendKeys)
? host.denyListFrontendKeys
: [];
// Build a set of form identifiers that were loaded from modules.json.
const loadedModuleFormIds = new Set();
for (const entry of host.loadedModules.values()) {
loadedModuleFormIds.add(entry.formId);
}
// Reset the per-form permission map so it reflects the current API response.
if (host.formsGrantedActions instanceof Map) {
host.formsGrantedActions.clear();
}
let id = 0;
const overviewActions = new Map();
for (let x = 0; x < data['hydra:member'].length; x++) {
const entry = data['hydra:member'][x];
let localizedFormName = entry['localizedNames'].find((localizedName) => {
return localizedName.languageTag === host.lang;
});
const formName = localizedFormName ? localizedFormName.name : entry['name'];
const formId = entry['identifier'];
const frontendKey = entry['frontendKey'] ?? null;
const dateCreated = entry['dateCreated'] ?? null;
// Apply allow-list: if non-empty, only include forms whose frontendKey is in the list
if (
allowList.length > 0 &&
(frontendKey === null || !allowList.includes(frontendKey))
) {
continue;
}
// Apply deny-list: skip forms whose frontendKey is in the list
if (denyList.length > 0 && frontendKey !== null && denyList.includes(frontendKey)) {
continue;
}
const grantedFormActions = entry['grantedFormActions'] ?? [];
const grantedSubmissionCollectionActions =
entry['grantedSubmissionCollectionActions'] ?? [];
const hasAllowedFormAction = grantedFormActions.some((action) =>
[
FORM_PERMISSIONS.UPDATE,
FORM_PERMISSIONS.DELETE,
FORM_PERMISSIONS.MANAGE,
].includes(action),
);
const hasAllowedSubmissionCollectionAction =
grantedSubmissionCollectionActions.some((action) =>
[SUBMISSION_PERMISSIONS.READ, SUBMISSION_PERMISSIONS.MANAGE].includes(
action,
),
);
if (!hasAllowedFormAction && !hasAllowedSubmissionCollectionAction) {
continue;
}
// Skip forms whose identifier does not match any locally loaded
// module from modules.json. This ensures that a whitelabel build
// (which ships a reduced modules.json) does not display forms it
// has no frontend code for.
// if (!loadedModuleFormIds.has(formId)) {
// continue;
// }
id++;
const allowedActionsWhenSubmitted = entry['allowedActionsWhenSubmitted'];
const tagPermissionsForSubmitters = entry['tagPermissionsForSubmitters'];
const allowedSubmissionStates = entry['allowedSubmissionStates'];
const dataFeedSchema = entry['dataFeedSchema'];
const grantedActions = entry['grantedActions'] ?? [];
const additionalData = entry['additionalData'] ?? null;
const localizedNames = entry['localizedNames'] ?? [];
// Find the module instance whose frontendKey matches this form's frontendKey.
// Modules are enumerated from host.loadedModules (keyed by unique getUrlSlug()),
// because host.forms keys by getFormIdentifier() which defaults to 'uuid' and
// collides across modules. A form type is identified by its frontendKey, not by
// a single UUID, since each backend form instance has its own generated UUID.
let matchedModuleInstance = host.forms.get(formId)?.moduleInstance ?? null;
if (!matchedModuleInstance && frontendKey) {
for (const moduleEntry of host.loadedModules.values()) {
if (
moduleEntry.moduleInstance &&
typeof moduleEntry.moduleInstance.getFormFrontendKey === 'function' &&
moduleEntry.moduleInstance.getFormFrontendKey() === frontendKey
) {
matchedModuleInstance = moduleEntry.moduleInstance;
break;
}
}
}
host.forms.set(formId, {
...host.forms.get(formId),
formName,
dateCreated,
formId,
formSlug:
host.forms.get(formId)?.formSlug ??
matchedModuleInstance?.getUrlSlug?.() ??
formId,
moduleInstance: matchedModuleInstance,
allowedSubmissionStates,
allowedActionsWhenSubmitted,
dataFeedSchema,
tagPermissionsForSubmitters,
additionalData,
localizedNames,
grantedActions,
});
// Store current grants before modules build permission-gated row actions.
if (host.formsGrantedActions instanceof Map) {
host.formsGrantedActions.set(formId, grantedActions);
}
const managedForm = host.forms.get(formId);
// Build the row action button container. All actions use descriptors so
// toolbar/dropdown actions can be moved into rows without new button markup.
const actionContainer = document.createElement('span');
actionContainer.style.cssText =
'display: inline-flex; gap: 0.5rem; align-items: center;';
const actionContext = createManageFormsOverviewActionContext(host, [managedForm]);
const getCurrentActionContext = () =>
createManageFormsOverviewActionContext(host, [host.forms.get(formId)]);
const defaultActions = createDefaultManageFormsOverviewActions();
const customizedActions = matchedModuleInstance?.getManageFormsOverviewActions?.(
actionContext,
defaultActions,
);
const rowActions = Array.isArray(customizedActions)
? customizedActions
: defaultActions;
rowActions.forEach((action) => {
const placements = Array.isArray(action?.placements)
? action.placements
: ['row'];
if (action?.id) {
overviewActions.set(action.id, action);
}
const resolvedAction = resolveManageFormsOverviewAction(action, actionContext);
if (
placements.includes('row') &&
action?.id &&
typeof action.handler === 'function' &&
resolvedAction.visible &&
resolvedAction.enabled
) {
actionContainer.appendChild(
createManageFormsOverviewActionButton(
host,
action,
getCurrentActionContext,
),
);
}
});
const employer =
frontendKey === 'job-offer'
? additionalData?.jobOfferType === 'external'
? additionalData?.companyName || ''
: 'TU Graz'
: '';
let new_form = {
id: id,
name: formName,
employer: employer,
formId: formId,
dateCreated: dateCreated,
grantedActions: grantedActions,
actionButton: actionContainer,
};
forms.push(new_form);
}
host.formsOverviewActionDefinitions = [...overviewActions.values()];
// Avoid replacing allForms when the set of forms hasn't changed.
// A token refresh can cause this function to be re-invoked even
// though the API returns the same data. Skipping the assignment
// prevents the Lit reactive cycle from re-triggering
// updated('allForms'), which would rebuild/reset the tabulator
// tables, show a loading spinner, and disrupt the user.
const prevRows = (host.allForms || []).map(
({formId, name, employer, grantedActions}) => [
formId,
name,
employer,
grantedActions,
],
);
const nextRows = forms.map(({formId, name, employer, grantedActions}) => [
formId,
name,
employer,
grantedActions,
]);
if (
JSON.stringify(prevRows) === JSON.stringify(nextRows) &&
(host.allForms || []).length > 0
) {
// Forms unchanged — just make sure the loading spinner is hidden.
host.loadingFormsTable = false;
return;
}
host.allForms = forms;
host.options_forms.data = host.allForms;
}
} catch (e) {
host.loadCourses = true;
console.error('[updated] Error getting list of forms:', e);
sendNotification({
summary: i18n.t('manage-forms.failed-to-get-forms-title'),
body: i18n.t('manage-forms.failed-to-get-forms-body'),
type: 'danger',
timeout: 0,
});
}
}
// ---------------------------------------------------------------------------
// Submissions
// ---------------------------------------------------------------------------
/**
* Fetch all submissions for a form, separate by state, and prepare table data.
*
* @param {object} host - The ManageForms element.
* @param {string} formId - Form identifier.
* @returns {Promise<Response|undefined>}
*/
export async function getAllFormSubmissions(host, formId) {
const i18n = host._i18n;
let response;
let data;
host.rawSubmissions = [];
host.submittedFileDetails = {draft: new Map(), submitted: new Map()};
host.submissionsHasAttachment = {draft: false, submitted: false};
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + host.auth.token,
},
};
try {
response = await fetch(
host.entryPointUrl +
'/formalize/submissions?formIdentifier=' +
formId +
'&perPage=9999',
options,
);
data = await response.json();
} catch (e) {
host.sendErrorAnalyticsEvent('getAllSubmissions', 'WrongResponse', e);
throwSomethingWentWrongNotification(host);
throw e;
}
host.rawSubmissions = data['hydra:member'];
host.submissionsGrantedActions = new Map();
host.rawSubmissions.forEach((submission) => {
host.submissionsGrantedActions.set(submission.identifier, submission.grantedActions);
});
if (data['hydra:member'].length === 0) {
host.noSubmissionAvailable = {draft: true, submitted: true};
return response;
}
// Separate submissions by their state
const submissions = {};
submissions.submitted = data['hydra:member'].filter(
(s) => s.submissionState === SUBMISSION_STATES_BINARY.SUBMITTED,
);
submissions.draft = data['hydra:member'].filter(
(s) => s.submissionState === SUBMISSION_STATES_BINARY.DRAFT,
);
for (const state of Object.keys(host.submissions)) {
if (submissions[state].length === 0) {
host.noSubmissionAvailable = {...host.noSubmissionAvailable, [state]: true};
continue;
} else {
host.noSubmissionAvailable = {...host.noSubmissionAvailable, [state]: false};
}
let submissions_list = [];
const activeForm = host.forms.get(formId);
const enumTranslations = activeForm?.moduleInstance?.getEnumTranslations
? activeForm.moduleInstance.getEnumTranslations(host.lang)
: {};
const commonTranslations = enumTranslations._common || {};
for (let [x, submission] of submissions[state].entries()) {
let dateCreated = humanReadableDate(submission['dateCreated']);
let dataFeedElement = JSON.parse(submission['dataFeedElement']);
let submissionId = submission['identifier'];
const submissionTags = submission['tags'] || [];
const submittedFiles = submission['submittedFiles'] || [];
host.submittedFileDetails[state].set(submissionId, submittedFiles);
if (submittedFiles.length > 0) {
host.submissionsHasAttachment[state] = true;
}
// Group submitted file names by their attribute name so tabulator
// can display them in the corresponding form_files-* columns.
const fileFieldData = {};
for (const file of submittedFiles) {
const fieldName = `form_files-${file.fileAttributeName}`;
if (fileFieldData[fieldName] === undefined) {
fileFieldData[fieldName] = [];
}
fileFieldData[fieldName].push(file.fileName);
}
for (const fieldName of Object.keys(fileFieldData)) {
fileFieldData[fieldName] = fileFieldData[fieldName].join(', ');
}
// Flatten array values, resolve user identifiers
for (const [key, value] of Object.entries(dataFeedElement)) {
if (Array.isArray(value)) {
dataFeedElement[key] = value.join(', ');
}
if (key === 'identifier' && value) {
if (!host.userNameCache.has(value)) {
try {
const resp = await host.apiGetUserDetails(value);
const userObject = await resp.json();
const fullName = `${userObject['givenName']} ${userObject['familyName']}`;
dataFeedElement[key] = fullName;
host.userNameCache.set(value, fullName);
} catch (e) {
console.error(e);
dataFeedElement[key] = value;
}
} else {
dataFeedElement[key] = host.userNameCache.get(value);
}
}
}
// Translate enum keys to human-readable labels
for (const [key, value] of Object.entries(dataFeedElement)) {
if (typeof value !== 'string') continue;
const fieldMap = enumTranslations[key] || commonTranslations;
if (!fieldMap || Object.keys(fieldMap).length === 0) continue;
dataFeedElement[key] = value
.split(', ')
.map((v) => fieldMap[v.trim()] || v.trim())
.join(', ');
}
const id = x + 1;
let cols = {
dateCreated: dateCreated,
tags: Array.isArray(submissionTags)
? submissionTags.map((tag) => `<span class="tag">${xss(tag)}</span>`).join(' ')
: '',
...dataFeedElement,
...fileFieldData,
submissionId: submissionId,
};
let actionButtonsDiv = host.createScopedElement('div');
// Add link to manage form entry in render form view (only for forms with a slug and read-only mode)
if (
activeForm.formSlug &&
activeForm.moduleInstance &&
typeof activeForm.moduleInstance.hasReadOnlyMode === 'function' &&
activeForm.moduleInstance.hasReadOnlyMode()
) {
const submissionDetailsFormButton = host.createScopedElement(
'dbp-formalize-get-submission-link',
);
let formSubmissionUrl =
getFormRenderUrl(activeForm.formSlug, host.lang) + `/${submissionId}/readonly`;
/*
t('manage-forms.open-detailed-view-form')
*/
submissionDetailsFormButton.ariaLabel = i18n.t(
'manage-forms.open-detailed-view-form',
{id: id},
);
submissionDetailsFormButton.submissionUrl = formSubmissionUrl;
submissionDetailsFormButton.iconName = 'open-new-window';
submissionDetailsFormButton.title = i18n.t('manage-forms.open-detailed-view-form', {
id: id,
});
submissionDetailsFormButton.id = id.toString();
submissionDetailsFormButton.setAttribute('subscribe', 'lang');
submissionDetailsFormButton.addEventListener('click', (event) => {
event.stopPropagation();
});
actionButtonsDiv.appendChild(submissionDetailsFormButton);
}
// Add button to show submission details in a modal
const submissionDetailsButton = host.createScopedElement(
'dbp-formalize-get-details-button',
);
/*
t('manage-forms.open-detailed-view-modal')
*/
submissionDetailsButton.ariaLabel = i18n.t('manage-forms.open-detailed-view-modal', {
id: id,
});
submissionDetailsButton.title = i18n.t('manage-forms.open-detailed-view-modal', {
id: id,
});
submissionDetailsButton.id = id.toString();
submissionDetailsButton.setAttribute('subscribe', 'lang');
submissionDetailsButton.addEventListener('click', (event) => {
event.stopPropagation();
const routingData = host.getRoutingData();
const routeFormId = routingData.pathSegments[0];
if (routeFormId.match(/[0-9a-f-]+/)) {
host.setSubmissionDetailsRoute(submissionId);
host.requestDetailedSubmission(state, cols, id);
}
});
actionButtonsDiv.appendChild(submissionDetailsButton);
actionButtonsDiv.classList.add('actions-buttons');
cols.htmlButtons = actionButtonsDiv;
submissions_list.push(cols);
}
// Remove attachment column if all empty
const noAttachments = submissions_list.every((submission) => submission.attachments === '');
submissions_list = noAttachments
? submissions_list.map(({attachments, ...rest}) => rest)
: submissions_list;
host.submissions = {...host.submissions, [state]: submissions_list};
host.options_submissions[state].autoColumnsDefinitions = (definitions) => {
definitions.forEach((columnDefinition) => {
if (columnDefinition.field === 'submissionId') {
columnDefinition.visible = true;
columnDefinition.title = host._i18n.t('manage-forms.submission-id');
}
if (columnDefinition.field === 'dateCreated') {
columnDefinition.visible = true;
columnDefinition.title = host.lang === 'de' ? 'Erstellt am' : 'Date created';
}
if (columnDefinition.field === 'tags') {
columnDefinition.formatter = 'html';
}
if (columnDefinition.field === 'htmlButtons') {
columnDefinition.title = '';
columnDefinition.formatter = 'html';
columnDefinition.hozAlign = 'right';
columnDefinition.vertAlign = 'middle';
columnDefinition.headerSort = false;
columnDefinition.minWidth = 64;
columnDefinition.frozen = true;
columnDefinition.headerHozAlign = 'right';
columnDefinition.download = false;
} else {
columnDefinition.sorter = 'string';
}
if (columnDefinition.field.includes('date')) {
columnDefinition.sorter = (a, b) => {
return dateToTimestamp(a) - dateToTimestamp(b);
};
}
});
return [
{
title: 'ID',
formatter: function (cell) {
const row = cell.getRow();
const table = row.getTable();
const page = table.getPage();
const pageSize = table.getPageSize();
const position = row.getPosition(true);
return (page - 1) * pageSize + position;
},
field: 'rowIndex',
hozAlign: 'center',
headerHozAlign: 'center',
headerSort: false,
frozen: true,
width: 30,
download: false,
},
...definitions,
];
};
host.options_submissions[state].data = host.submissions[state];
host.totalNumberOfItems[state] = submissions_list.length;
}
return response;
}
// ---------------------------------------------------------------------------
// Submission mutations
// ---------------------------------------------------------------------------
/**
* Delete a single submission.
*
* @param {object} host
* @param {string} submissionId
* @returns {Promise<boolean>}
*/
export async function apiDeleteSubmission(host, submissionId) {
if (!submissionId) {
sendNotification({
summary: host._i18n.t('errors.error-title'),
body: host._i18n.t('errors.no-submission-id-provided'),
type: 'danger',
timeout: 0,
});
return false;
}
try {
const response = await fetch(
host.entryPointUrl + `/formalize/submissions/${submissionId}`,
{
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + host.auth.token,
},
},
);
if (!response.ok) {
console.warn(`Failed to delete submission. Response status: ${response.status}`);
return false;
}
return true;
} catch (error) {
console.error(error.message);
return false;
}
}
/**
* Update tags on a submission via PATCH.
*
* @param {object} host
* @param {string} submissionId
* @param {string[]} finalTags
* @returns {Promise<boolean>}
*/
export async function apiUpdateSubmissionTags(host, submissionId, finalTags) {
if (!submissionId || !host.activeFormId) {
sendNotification({
summary: host._i18n.t('errors.error-title'),
body: host._i18n.t('errors.no-submission-id-provided'),
type: 'danger',
timeout: 0,
});
return false;
}
const formData = new FormData();
formData.append('form', '/formalize/forms/' + host.activeFormId);
formData.append('tags', JSON.stringify(finalTags));
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${host.auth.token}`,
},
body: formData,
};
const url = host.entryPointUrl + '/formalize/submissions/' + submissionId;
try {
const response = await fetch(url, options);
if (!response.ok) {
console.warn(`Failed to update submission tags. Response status: ${response.status}`);
const errorData = await response.json().catch(() => ({}));
console.warn('Error details:', errorData);
return false;
}
return true;
} catch (error) {
console.error(error.message);
return false;
}
}
/**
* Get available tags for a form.
*
* @param {object} host
* @param {string} identifier - form identifier
*/
export async function apiGetTags(host, identifier) {
if (host.auth.token === '') {
host.availableTags = [];
return;
}
let response;
let data = [];
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + host.auth.token,
},
};
try {
response = await fetch(host.entryPointUrl + '/formalize/forms/' + identifier, options);
if (!response.ok) {
host.availableTags = [];
}
data = await response.json();
} catch (e) {
host.sendErrorAnalyticsEvent('checkPermissionsToForm', 'WrongResponse', e);
console.error(e);
host.availableTags = [];
}
if (data.error) {
console.error('checkPermissionsToForm data.error', data.error);
host.availableTags = [];
}
if (data['@type'] === 'hydra:Error') {
console.error('checkPermissionsToForm hydra:Error', data.detail);
host.availableTags = [];
}
host.availableTags = data.availableTags || [];
}
// ---------------------------------------------------------------------------
// Helpers (re-exported for use by other modules)
// ---------------------------------------------------------------------------
/**
* Create a new form via POST /formalize/forms.
*
* @param {object} host - The ManageForms element (needs host.auth, host.entryPointUrl, host._i18n).
* @param {object} formData - The form payload.
* @param {string} formData.name - Default name of the form.
* @param {Array<{languageTag: string, name: string}>} formData.localizedNames - Localized names.
* @param {string} formData.frontendKey - Frontend key for filtering (e.g. 'job-offer').
* @param {object} [formData.additionalData] - Free-form metadata stored as the form's additionalData field.
* @param {string} [formData.dataFeedSchema] - JSON Schema for validating submissions.
* @param {boolean} [formData.grantBasedSubmissionAuthorization] - Whether submissions use resource grants.
* @param {string[]} [formData.allowedActionsWhenSubmitted] - Actions granted to the submitter after submission.
* @param {object} [options] - Notification options.
* @param {string} [options.errorNotificationTargetId] - Notification component for errors.
* @returns {Promise<object|null>} The created form object from the API, or null on failure.
*/
export async function apiCreateForm(host, formData, options = {}) {
const i18n = host._i18n;
const body = {
name: formData.name,
localizedNames: formData.localizedNames,
frontendKey: formData.frontendKey,
};
// Store additional metadata as a nested additionalData field on the form
if (formData.additionalData) {
body.additionalData = formData.additionalData;
}
// Store the JSON Schema for validating submission data
if (formData.dataFeedSchema) {
body.dataFeedSchema = formData.dataFeedSchema;
}
if (typeof formData.grantBasedSubmissionAuthorization === 'boolean') {
body.grantBasedSubmissionAuthorization = formData.grantBasedSubmissionAuthorization;
}
if (Array.isArray(formData.allowedActionsWhenSubmitted)) {
body.allowedActionsWhenSubmitted = formData.allowedActionsWhenSubmitted;
}
try {
const response = await fetch(host.entryPointUrl + '/formalize/forms', {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + host.auth.token,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Failed to create form:', response.status, errorData);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('create-form.error-create-failed', {status: response.status}),
type: 'danger',
timeout: 0,
targetNotificationId: options.errorNotificationTargetId,
});
return null;
}
const createdForm = await response.json();
sendNotification({
summary: i18n.t('success.success-title'),
body: i18n.t('create-form.success-created'),
type: 'success',
timeout: 5,
});
return createdForm;
} catch (error) {
console.error('Error creating form:', error);
sendNotification({
summary: i18n.t('errors.error-title'),
body: error.message,
type: 'danger',
timeout: 0,
targetNotificationId: options.errorNotificationTargetId,
});
return null;
}
}
/**
* Update an existing form via PATCH /formalize/forms/{identifier}.
*
* @param {object} host - The ManageForms element (needs host.auth, host.entryPointUrl, host._i18n).
* @param {string} formIdentifier - The identifier of the form to update.
* @param {object} formData - The form payload (same shape as apiCreateForm).
* @param {string} formData.name - Default name of the form.
* @param {Array<{languageTag: string, name: string}>} formData.localizedNames - Localized names.
* @param {object} [formData.additionalData] - Free-form metadata stored as the form's additionalData field.
* @param {string} [formData.dataFeedSchema] - JSON Schema for validating submissions.
* @param {boolean} [formData.grantBasedSubmissionAuthorization] - Whether submissions use resource grants.
* @param {string[]} [formData.allowedActionsWhenSubmitted] - Actions granted to the submitter after submission.
* @param {object} [options] - Notification options.
* @param {string} [options.errorNotificationTargetId] - Notification component for errors.
* @returns {Promise<object|null>} The updated form object from the API, or null on failure.
*/
export async function apiUpdateForm(host, formIdentifier, formData, options = {}) {
const i18n = host._i18n;
const body = {
name: formData.name,
localizedNames: formData.localizedNames,
};
if (formData.additionalData) {
body.additionalData = formData.additionalData;
}
if (formData.dataFeedSchema) {
body.dataFeedSchema = formData.dataFeedSchema;
}
if (typeof formData.grantBasedSubmissionAuthorization === 'boolean') {
body.grantBasedSubmissionAuthorization = formData.grantBasedSubmissionAuthorization;
}
if (Array.isArray(formData.allowedActionsWhenSubmitted)) {
body.allowedActionsWhenSubmitted = formData.allowedActionsWhenSubmitted;
}
try {
const response = await fetch(host.entryPointUrl + '/formalize/forms/' + formIdentifier, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
Authorization: 'Bearer ' + host.auth.token,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Failed to update form:', response.status, errorData);
sendNotification({
summary: i18n.t('errors.error-title'),
body: i18n.t('edit-form.error-update-failed', {status: response.status}),
type: 'danger',
timeout: 0,
targetNotificationId: options.errorNotificationTargetId,
});
return null;
}
const updatedForm = await response.json();
sendNotification({
summary: i18n.t('success.success-title'),
body: i18n.t('edit-form.success-updated'),
type: 'success',
timeout: 5,
});
return updatedForm;
} catch (error) {
console.error('Error updating form:', error);
sendNotification({
summary: i18n.t('errors.error-title'),
body: error.message,
type: 'danger',
timeout: 0,
targetNotificationId: options.errorNotificationTargetId,
});
return null;
}
}
/**
* Delete a single form via DELETE /formalize/forms/{identifier}.
*
* @param {object} host - The ManageForms element (needs host.auth, host.entryPointUrl, host._i18n).
* @param {string} formIdentifier - The identifier of the form to delete.
* @returns {Promise<boolean>} true on success, false on failure.
*/
export async function apiDeleteForm(host, formIdentifier) {
if (!formIdentifier) {
sendNotification({
summary: host._i18n.t('errors.error-title'),
body: host._i18n.t('errors.no-form-id-provided'),
type: 'danger',
timeout: 0,
});
return false;
}
try {
const response = await fetch(host.entryPointUrl + '/formalize/forms/' + formIdentifier, {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + host.auth.token,
},
});
if (!response.ok) {
console.warn(`Failed to delete form. Response status: ${response.status}`);
return false;
}
return true;
} catch (error) {
console.error(error.message);
return false;
}
}
/**
* Convert date string to a Unix timestamp (seconds).
* @param {string} dateInput
* @returns {number}
*/
export function dateToTimestamp(dateInput) {
const d = new Date(dateInput);
if (isNaN(d)) throw new Error('Invalid date');
return Math.floor(d.getTime() / 1000);
}
/**
* Send success/failure notifications for a batch of operations.
* @param {object} host
* @param {boolean[]} responseStatus
* @param {object} [messageKeys] Optional overrides for the notification body
* translation keys so callers can tailor the wording (e.g. forms vs.
* submissions). Defaults to the submission wording.
* @param {string} [messageKeys.successKey]
* @param {string} [messageKeys.failureKey]
*/
export function successFailureNotification(host, responseStatus, messageKeys = {}) {
const successKey = messageKeys.successKey || 'success.submissions-processed';
const failureKey = messageKeys.failureKey || 'errors.submissions-processing-failed';
const successCount = responseStatus.filter((status) => status === true).length;
if (successCount > 0) {
sendNotification({
summary: host._i18n.t('success.success-title'),
body: host._i18n.t(successKey, {count: successCount}),
type: 'success',
timeout: 5,
});
}
const errorCount = responseStatus.filter((status) => status === false).length;
if (errorCount > 0) {
sendNotification({
summary: host._i18n.t('errors.error-title'),
body: host._i18n.t(failureKey, {count: errorCount}),
type: 'danger',
timeout: 0,
});
}
}