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:

661 lines (591 loc) • 25.2 kB
// @ts-nocheck import {css, html} from 'lit'; import {ScopedElementsMixin} from '@dbp-toolkit/common'; import {Icon, Button, MiniSpinner} from '@dbp-toolkit/common'; import {Modal} from '@dbp-toolkit/common/src/modal.js'; import {Notification} from '@dbp-toolkit/notification'; import * as commonStyles from '@dbp-toolkit/common/styles'; import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element'; import {setOverridesByGlobalCache} from '@dbp-toolkit/common/i18next.js'; import {createInstance} from './i18n.js'; import {getSelectorFixCSS} from './styles.js'; /** * Dialog for creating or editing a form via POST/PATCH /formalize/forms. * * Renders a modal with a form-type selector (only modules that implement * getEditFormComponent()). When a form type is selected, the edit form * component from getEditFormComponent() is rendered below the selector. * That embedded component handles form fields, validation, API call, and * error handling. On success the component dispatches `dbp-edit-form-saved` * which this dialog relays upward and then closes. * * The edit-form component is mounted imperatively into a container div to * avoid issues with dynamic tag names in Lit templates with ScopedElementsMixin. */ export class EditFormDialog extends ScopedElementsMixin(DBPLitElement) { static get scopedElements() { return { 'dbp-icon': Icon, 'dbp-button': Button, 'dbp-modal': Modal, 'dbp-mini-spinner': MiniSpinner, 'dbp-notification': Notification, }; } constructor() { super(); this._i18n = createInstance(); this.lang = this._i18n.language; this.langDir = ''; /** @type {Array<{formId: string, formSlug: string, formName: string, moduleInstance: object}>} */ this.creatableModules = []; /** @type {string} Currently selected module slug */ this._selectedModuleSlug = ''; /** @type {boolean} Whether the save request is in progress */ this._isSubmitting = false; /** @type {string|null} Custom element tag name for the selected edit-form component */ this._formComponentTag = null; /** @type {HTMLElement|null} Currently mounted form component instance */ this._formComponentInstance = null; /** @type {object} Auth object with token */ this.auth = {}; /** @type {string} API entry point URL */ this.entryPointUrl = ''; /** * When set, the dialog operates in edit mode. * Shape: { formId, formSlug, formName, moduleInstance, additionalData, localizedNames } * @type {object|null} */ this.existingForm = null; } static get properties() { return { ...super.properties, lang: {type: String}, langDir: {type: String, attribute: 'lang-dir'}, auth: {type: Object}, entryPointUrl: {type: String, attribute: 'entry-point-url'}, creatableModules: {type: Array, attribute: false}, existingForm: {type: Object, attribute: false}, _selectedModuleSlug: {state: true}, _isSubmitting: {state: true}, _formComponentTag: {state: true}, }; } connectedCallback() { super.connectedCallback(); if (this.langDir) { void setOverridesByGlobalCache(this._i18n, this); } } update(changedProperties) { changedProperties.forEach((oldValue, propName) => { if (propName === 'lang') { void this._i18n.changeLanguage(this.lang); } if ((propName === 'lang' || propName === 'langDir') && this.langDir) { void setOverridesByGlobalCache(this._i18n, this); } }); super.update(changedProperties); } updated(changedProperties) { super.updated(changedProperties); if ( changedProperties.has('creatableModules') && !this._isEditMode && this.creatableModules.length === 1 && this._selectedModuleSlug === '' ) { this._selectedModuleSlug = this.creatableModules[0].formSlug; this._registerAndSetFormComponent(this.creatableModules[0]); } // Sync the imperatively-managed form component whenever the tag or passed props change if ( changedProperties.has('_formComponentTag') || changedProperties.has('lang') || changedProperties.has('auth') || changedProperties.has('entryPointUrl') || changedProperties.has('existingForm') ) { this._syncFormComponent(); } } /** * Mounts or unmounts the edit-form component in the container div based on _formComponentTag. * Properties are kept in sync on every relevant update. */ _syncFormComponent() { const container = this.renderRoot?.querySelector('#form-component-container'); if (!container) { return; } if (!this._formComponentTag) { // Remove any existing component container.innerHTML = ''; this._formComponentInstance = null; return; } // Create a new element if the tag changed or none exists yet if ( !this._formComponentInstance || this._formComponentInstance.tagName.toLowerCase() !== this._formComponentTag ) { container.innerHTML = ''; // Use createScopedElement so the element is created in the correct scoped registry this._formComponentInstance = this.createScopedElement(this._formComponentTag); container.appendChild(this._formComponentInstance); } // Keep properties in sync this._formComponentInstance.lang = this.lang; this._formComponentInstance.langDir = this.langDir; this._formComponentInstance.auth = this.auth; this._formComponentInstance.entryPointUrl = this.entryPointUrl; // Pass the existing form data when in edit mode so the component can pre-populate fields this._formComponentInstance.existingForm = this.existingForm || null; } /** * Returns the currently selected module entry, or null. * @returns {object|null} */ get _selectedModule() { return this.creatableModules.find((m) => m.formSlug === this._selectedModuleSlug) || null; } /** * Returns true when in edit mode. * @returns {boolean} */ get _isEditMode() { return this.existingForm !== null && this.existingForm !== undefined; } /** * Returns true when a form type is selected and the component is registered. * @returns {boolean} */ get _isFormValid() { return this._selectedModuleSlug !== '' && this._formComponentTag !== null; } /** Resets all form fields to empty defaults. */ _resetForm() { this._selectedModuleSlug = ''; this._isSubmitting = false; this._formComponentTag = null; this._formComponentInstance = null; const container = this.renderRoot?.querySelector('#form-component-container'); if (container) { container.replaceChildren(); } } /** Opens the dialog and resets form state. */ open() { this._resetForm(); if (this._isEditMode) { // In edit mode, pre-select the module for the existing form this._selectedModuleSlug = this.existingForm.formSlug; this._registerAndSetFormComponent(this.existingForm); } else if (this.creatableModules.length === 1) { // Auto-select when there is exactly one option this._selectedModuleSlug = this.creatableModules[0].formSlug; this._registerAndSetFormComponent(this.creatableModules[0]); } const dialog = this.renderRoot?.querySelector('dbp-modal'); if (dialog) { dialog.open(); } } /** Closes the dialog. */ close() { const dialog = this.renderRoot?.querySelector('dbp-modal'); if (dialog) { dialog.close(); } this._resetForm(); } /** * Registers the edit-form component via ScopedElementsMixin's scoped registry * and sets _formComponentTag so _syncFormComponent() can imperatively mount it. * @param {object} moduleEntry - Entry from creatableModules */ _registerAndSetFormComponent(moduleEntry) { if (!moduleEntry || typeof moduleEntry.moduleInstance.getEditFormComponent !== 'function') { this._formComponentTag = null; return; } const FormComponent = moduleEntry.moduleInstance.getEditFormComponent(); if (!FormComponent) { this._formComponentTag = null; return; } // Derive a stable custom element tag from the form slug const tag = `dbp-edit-form-${moduleEntry.formSlug}`; // Register via the scoped registry so the element is recognised inside this shadow root this.defineScopedElement(tag, FormComponent); this._formComponentTag = tag; } /** * Handles the form type selector change. * @param {Event} e */ _onFormTypeChange(e) { this._selectedModuleSlug = e.target.value; // Mount the edit-form component for the newly selected module const selectedModule = this.creatableModules.find( (m) => m.formSlug === this._selectedModuleSlug, ); this._registerAndSetFormComponent(selectedModule || null); } /** * Handles the create/save action. Delegates to the imperatively-mounted * edit-form component's submit() method. */ async _onSave() { if (!this._isFormValid || this._isSubmitting) { return; } this._isSubmitting = true; const formComponent = this._formComponentInstance; if (formComponent && typeof formComponent.submit === 'function') { const result = await formComponent.submit(); if (result) { if (this._isEditMode) { // Relay the edit success event upward so the parent can refresh this.dispatchEvent( new CustomEvent('dbp-edit-form-saved', { detail: {form: result}, bubbles: true, composed: true, }), ); } else { // Relay the create success event upward so the parent can refresh this.dispatchEvent( new CustomEvent('dbp-create-form-created', { detail: {form: result}, bubbles: true, composed: true, }), ); } this.close(); } } this._isSubmitting = false; } /** * Moves focus from a form skip link to the primary save * button in the pinned modal header. Falls back to the action bar while the * save button is still disabled and therefore not focusable. */ _skipToSaveButton() { const saveButton = this._('#dialog-save-btn'); if (saveButton && !saveButton.disabled) { saveButton.focus(); return; } const actionsBar = this._('#dialog-actions-bar'); if (actionsBar) { actionsBar.focus(); } } /** * Moves focus to the modal's close button, which is the only way to abort the * dialog besides Escape. It lives in the modal's shadow root, so we have to go * through the modal's public API. */ _skipToCloseButton() { const modal = this._('dbp-modal'); if (modal && typeof modal.focusCloseButton === 'function') { modal.focusCloseButton(); } } /** Called by the parent after the API call completes (success or failure). */ submitComplete() { this._isSubmitting = false; } render() { const i18n = this._i18n; const t = (key, opts) => (i18n ? i18n.t(key, opts) : key); const isEdit = this._isEditMode; const saveDisabled = !this._isFormValid || this._isSubmitting; // The save button is labelled differently per mode (and can be overridden by apps), // so reuse its label in the skip link instead of hardcoding "save". const saveLabel = isEdit ? t('edit-form.save') : t('create-form.create'); const showFormTypeSelector = !isEdit && this.creatableModules.length > 1; const generalSectionTitle = t('create-form.section-general'); return html` <dbp-modal modal-id="edit-form-dialog" lang="${this.lang}" sticky-footer class="modal-width"> <!-- Title --> <div slot="title"> <h2 class="dialog-title"> <dbp-icon class="title-icon" name="${isEdit ? 'pencil' : 'add-job'}" aria-hidden="true"></dbp-icon> ${isEdit ? t('edit-form.dialog-title') : t('create-form.dialog-title')} </h2> </div> <!-- Header content stays pinned above the scrollable modal content. --> <div slot="header"> <!-- Action bar: Cancel (left) + Create/Save (right) --> <div id="dialog-actions-bar" class="dialog-actions-bar" tabindex="-1"> <p class="required-fields-hint"> <span class="required-marker"> ${t('render-form.required-files-asterisk')} </span> ${t('render-form.required-files-text')} </p> <button id="dialog-save-btn" class="button is-primary save-btn" type="button" ?disabled="${saveDisabled}" @click="${this._onSave}"> ${ this._isSubmitting ? html` <dbp-mini-spinner></dbp-mini-spinner> ` : html` <dbp-icon class="btn-icon" name="save" aria-hidden="true"></dbp-icon> ` } <span class="button-label">${saveLabel}</span> </button> </div> <!-- In-dialog notifications (appear above the modal, anchored to it) --> <dbp-notification id="edit-form-dialog-notification" inline lang="${this.lang}"></dbp-notification> </div> <!-- Form content --> <div slot="content" class="dialog-content"> ${ generalSectionTitle ? html` <h4 class="form-section-heading">${generalSectionTitle}</h4> ` : '' } <!-- Form type selector: only shown when there are multiple create options --> ${ showFormTypeSelector ? html` <div class="form-field"> <label class="form-label" for="form-type-select"> ${t('create-form.field-form-type')} <span class="required-marker">*</span> </label> <div class="select-wrapper"> <select id="form-type-select" class="form-select" .value="${this._selectedModuleSlug}" @change="${this._onFormTypeChange}"> <option value="" ?selected="${this._selectedModuleSlug === ''}"> ${t('create-form.field-form-type-placeholder')} </option> ${this.creatableModules.map( (m) => html` <option value="${m.formSlug}" ?selected="${ this._selectedModuleSlug === m.formSlug }"> ${m.formName || m.formSlug} </option> `, )} </select> </div> </div> ` : '' } <!-- Container where the edit-form component is mounted imperatively --> <div id="form-component-container" class="form-component-area" @dbp-edit-form-skip-to-save-button="${this._skipToSaveButton}" ?hidden="${!this._formComponentTag}"></div> <!-- Skip links to the dialog actions, which sit in the pinned modal header and therefore come before the form in the tab order. --> ${ this._formComponentTag ? html` <div class="skip-links"> <button type="button" class="skip-link" @click="${this._skipToSaveButton}"> ${t('edit-form-dialog.skip-to-save-button', { label: saveLabel, })} </button> <button type="button" class="skip-link" @click="${this._skipToCloseButton}"> ${t('edit-form-dialog.skip-to-close-button')} </button> </div> ` : '' } </div> </dbp-modal> `; } static get styles() { return css` ${commonStyles.getThemeCSS()} ${commonStyles.getGeneralCSS()} ${commonStyles.getButtonCSS()} ${getSelectorFixCSS()} .modal-width { --dbp-modal-min-width: min(95vw, 700px); --dbp-modal-max-width: min(95vw, 700px); --dbp-modal-max-height: 90vh; --dbp-modal-content-overflow-y: auto; } /* Match the color of the modal's own close button (--dbp-accent) */ .title-icon { color: var(--dbp-accent); top: 5px; } /* Dialog title layout */ .dialog-title { display: inline-flex; align-items: start; gap: 0.4rem; margin: 0; font-size: 1.4rem; } /* Action bar: Cancel on the left, Save on the right */ .dialog-actions-bar { display: flex; justify-content: space-between; align-items: center; padding-bottom: 1em; } .cancel-btn, .save-btn { display: inline-flex; align-items: center; gap: 0.4rem; } /* Icon inside buttons: override the default top offset */ .btn-icon { flex-shrink: 0; top: 0; } /* Section headings */ .form-section-heading { font-size: 1rem; font-weight: 700; margin: 1.25rem 0 0.75rem; } /* Form field wrapper for native elements (select) */ .form-field { margin-bottom: 0.75rem; } .form-label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .required-marker { color: var(--dbp-danger, #e4154b); } /* Wrapper provides relative positioning for the chevron icon */ .select-wrapper { position: relative; } /* Native select styled to match the manage-forms table action header selectors */ .form-select { box-sizing: border-box; width: 100%; height: 2.25rem; padding: 0 2rem 0 0.6rem; border: var(--dbp-border); border-radius: var(--dbp-border-radius, 0); font-size: 1rem; color: var(--dbp-content); background-color: var(--dbp-background); /* background-size is controlled by getSelectorFixCSS() to fix oversized chevron */ cursor: pointer; appearance: auto; } .form-select:focus { outline: none; border-color: var(--dbp-accent, #007bff); } .form-select:disabled { opacity: 0.4; cursor: not-allowed; } /* Edit-form component area */ .form-component-area { margin-top: 1rem; } /* Skip links: hidden until they receive keyboard focus */ .skip-link { position: absolute !important; clip: rect(1px, 1px, 1px, 1px); overflow: hidden; height: 1px; width: 1px; word-wrap: normal; appearance: none; border: none; padding: 0; background: none; font: inherit; color: var(--dbp-accent); text-decoration: underline; cursor: pointer; } .skip-link:focus-visible { position: static !important; clip: auto; overflow: visible; display: inline-block; height: auto; width: auto; } /* Only the focused link becomes visible, so the gap never shows twice */ .skip-links:focus-within { display: flex; margin-top: 1rem; } /* Fallback focus target while the save button is disabled */ #dialog-actions-bar:focus { outline: none; } #dialog-actions-bar:focus-visible { outline: 1px solid var(--dbp-accent); outline-offset: 2px; } @media (max-width: 560px) { .modal-width { --dbp-modal-min-width: unset; --dbp-modal-max-width: unset; --dbp-modal-max-height: 90vh; --dbp-modal-content-overflow-y: auto; } } `; } }