diff --git a/assets/controllers/common/dirty_form_controller.js b/assets/controllers/common/dirty_form_controller.js new file mode 100644 index 00000000..aad2e6b0 --- /dev/null +++ b/assets/controllers/common/dirty_form_controller.js @@ -0,0 +1,274 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {visit} from "@hotwired/turbo"; +import * as bootbox from "bootbox"; +import "../../css/components/bootbox_extensions.css"; +import "../../css/components/dirty_form.css"; + +/** + * Attach to a
element (or a wrapper containing a ) to prevent accidental navigation + * away when the form has unsaved changes. + * + * Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger + * a check of whether any element's current value differs from the DOM default recorded in the HTML + * (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both + * native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input` + * when they sync their underlying textarea). + * + * Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty: + * the submitted data was never saved, so navigating away would lose it. This removes the need for + * any snapshot mechanism — the `.is-invalid` classes in the re-rendered HTML are the signal. + * + * Intercepts three navigation paths: + * 1. Any link click (capture phase) + * 2. window beforeunload + * 3. turbo:before-visit + * + * Values: + * - confirmTitle (String) – dialog title + * - confirmMessage (String) – dialog body text + */ +export default class extends Controller { + static values = { + confirmTitle: {type: String, default: 'Unsaved Changes'}, + confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'}, + }; + + connect() { + this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form'); + this._isDirty = false; + this._submitting = false; + this._navigating = false; + + this._changeHandler = this._handleChange.bind(this); + this._linkClickHandler = this._handleLinkClick.bind(this); + this._beforeUnloadHandler = this._handleBeforeUnload.bind(this); + this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this); + this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this); + + if (this._form) { + this._form.addEventListener('change', this._changeHandler); + // CKEditor (and other rich-text widgets) dispatch `input` rather than `change` + // when their underlying textarea value is updated. + this._form.addEventListener('input', this._changeHandler); + } + document.addEventListener('click', this._linkClickHandler, true); + window.addEventListener('beforeunload', this._beforeUnloadHandler); + document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler); + document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler); + + const modal = this.element.closest('.modal'); + if (modal) { + this._modal = modal; + this._modalHideHandler = this._handleModalHide.bind(this); + modal.addEventListener('hide.bs.modal', this._modalHideHandler); + } + } + + disconnect() { + if (this._form) { + this._form.removeEventListener('change', this._changeHandler); + this._form.removeEventListener('input', this._changeHandler); + } + document.removeEventListener('click', this._linkClickHandler, true); + window.removeEventListener('beforeunload', this._beforeUnloadHandler); + document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler); + document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler); + + if (this._modal && this._modalHideHandler) { + this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler); + } + } + + /** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */ + submit() { + this._submitting = true; + } + + /** + * data-action="reset->common--dirty-form#resetDirtyState" — marks the form as clean after + * a programmatic reset. Native change events are not fired by form.reset(), so we set the + * flag directly. Turbo also calls form.reset() internally before the post-submit redirect; + * the _submitting guard prevents that from incorrectly clearing the flag. + */ + resetDirtyState() { + if (this._submitting) return; + + // Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag. + requestAnimationFrame(() => { + this._isDirty = false; + this._clearDirtyMarkers(); + }); + } + + _handleChange(event) { + const target = event?.target; + if (target?.name) { + this._updateDirtyMarker(target); + } else { + this._refreshDirtyMarkers(); + } + this._isDirty = this._form?.querySelector('[data-dirty]') !== null; + } + + /** + * Walk every named form element and update its `data-dirty` attribute. + * Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are + * skipped — they are not submitted and are not the source of truth for form data. + */ + _refreshDirtyMarkers() { + if (!this._form) return; + for (const el of this._form.elements) { + if (!el.name) continue; + this._updateDirtyMarker(el); + } + } + + /** + * Set or clear `data-dirty` on a single named form element. + * Hidden inputs are not visually rendered, so special handling applies: + * - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox — + * mark that instead. + * - Other hidden inputs (e.g. CSRF tokens): ignored. + * TomSelect hides the so the dirty-check + * controller can detect changes, and restores that value when the form is reset. + */ +export default function form_reset_handler() { + const self = this; + const input = this.input; + + // Multiple selects not yet supported + if (input.multiple) { + return; + } + + // Always capture the initial value, even empty string. + // Empty string is falsy, so the old `|| null` guard would silently skip it, + // leaving data-default-value unset and breaking the dirty check for blank defaults. + input.dataset.defaultValue = input.value; + + if (input.form) { + input.form.addEventListener('reset', () => { + input.value = input.dataset.defaultValue ?? ''; + self.sync(); + }); + } +} diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 5fed1571..c4a52bc7 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; +use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use App\Settings\AppSettings; use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface; @@ -50,7 +51,9 @@ class SettingsController extends AbstractController $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); //Create a form builder for the settings object - $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings); + $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings, formOptions: [ + 'warn_on_unsaved_changes' => true, + ]); //Add a submit button to the form $builder->add('submit', SubmitType::class, ['label' => 'save']); diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index bf005882..54cb0406 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType $resolver->setRequired('attachment_class'); $resolver->setRequired('parameter_class'); $resolver->setAllowedTypes('parameter_class', ['string', 'null']); + + $resolver->setDefaults([ + 'warn_on_unsaved_changes' => true, + ]); } public function buildForm(FormBuilderInterface $builder, array $options): void diff --git a/src/Form/Extension/UnsavedChangesExtension.php b/src/Form/Extension/UnsavedChangesExtension.php new file mode 100644 index 00000000..1187eb19 --- /dev/null +++ b/src/Form/Extension/UnsavedChangesExtension.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Adds a `warn_on_unsaved_changes` option to any root form. When set to true, the Stimulus + * `common--dirty-form` controller attributes are merged into the form element's HTML + * attributes, enabling unsaved-change detection without any template boilerplate. + * + * Usage in a form type: + * + * public function configureOptions(OptionsResolver $resolver): void + * { + * $resolver->setDefaults(['warn_on_unsaved_changes' => true]); + * } + * + * Or per-instance from a controller: + * + * $form = $this->createForm(MyFormType::class, $data, ['warn_on_unsaved_changes' => true]); + */ +class UnsavedChangesExtension extends AbstractTypeExtension +{ + public function __construct(private readonly TranslatorInterface $translator) + { + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('warn_on_unsaved_changes', false); + $resolver->setAllowedTypes('warn_on_unsaved_changes', 'bool'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + if (!$options['warn_on_unsaved_changes'] || $view->parent !== null) { + return; + } + + $extraAttr = [ + 'data-controller' => 'common--dirty-form', + 'data-common--dirty-form-confirm-title-value' => $this->translator->trans('form.dirty_form.unsaved_changes.title'), + 'data-common--dirty-form-confirm-message-value' => $this->translator->trans('form.dirty_form.unsaved_changes.message'), + ]; + + // Merge data-action so existing actions on the form element are preserved. + $existingAction = $view->vars['attr']['data-action'] ?? ''; + $dirtyActions = 'submit->common--dirty-form#submit reset->common--dirty-form#resetDirtyState'; + $extraAttr['data-action'] = $existingAction !== '' ? $existingAction . ' ' . $dirtyActions : $dirtyActions; + + $view->vars['attr'] = array_merge($view->vars['attr'], $extraAttr); + } +} diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 6b929486..a31f2469 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -353,6 +353,7 @@ class PartBaseType extends AbstractType $resolver->setDefaults([ 'data_class' => Part::class, 'info_provider_dto' => null, + 'warn_on_unsaved_changes' => true, ]); $resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']); diff --git a/src/Form/UserAdminForm.php b/src/Form/UserAdminForm.php index 457a6e0b..6331dfb7 100644 --- a/src/Form/UserAdminForm.php +++ b/src/Form/UserAdminForm.php @@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType $resolver->setDefault('parameter_class', false); $resolver->setDefault('validation_groups', ['Default', 'permissions:edit']); + + $resolver->setDefaults([ + 'warn_on_unsaved_changes' => true, + ]); } public function buildForm(FormBuilderInterface $builder, array $options): void diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b5fec280..a005338b 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1035,6 +1035,18 @@ Sub elements will be moved upwards. Edit [part] + + + form.dirty_form.unsaved_changes.title + Unsaved Changes + + + + + form.dirty_form.unsaved_changes.message + You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue? + + part.edit.tab.common