assets/js/controllers/guarded-action-modal-controller.js

import { Controller } from "@hotwired/stimulus"

/**
 * Opens server-rendered guarded action forms in one reusable Bootstrap modal.
 */
class GuardedActionModalController extends Controller {
    static targets = ["modal", "content", "title"]

    initialize() {
        this.lastTrigger = null
        this.submitting = false
        this.clearTimer = null
        this.boundHandleShown = this.handleShown.bind(this)
        this.boundHandleHidden = this.handleHidden.bind(this)
        this.boundHandleSubmit = this.submit.bind(this)
        this.boundHandleInput = this.clearConfirmationError.bind(this)
        this.boundHandleChange = this.updateExpectedConfirmation.bind(this)
    }

    connect() {
        this.modalInstance = new window.bootstrap.Modal(this.modalTarget)
        this.modalTarget.addEventListener("shown.bs.modal", this.boundHandleShown)
        this.modalTarget.addEventListener("hidden.bs.modal", this.boundHandleHidden)
        this.contentTarget.addEventListener("submit", this.boundHandleSubmit)
        this.contentTarget.addEventListener("input", this.boundHandleInput)
        this.contentTarget.addEventListener("change", this.boundHandleChange)
    }

    disconnect() {
        this.modalTarget.removeEventListener("shown.bs.modal", this.boundHandleShown)
        this.modalTarget.removeEventListener("hidden.bs.modal", this.boundHandleHidden)
        this.contentTarget.removeEventListener("submit", this.boundHandleSubmit)
        this.contentTarget.removeEventListener("input", this.boundHandleInput)
        this.contentTarget.removeEventListener("change", this.boundHandleChange)
        if (this.clearTimer !== null) {
            window.clearTimeout(this.clearTimer)
            this.clearTimer = null
        }
        this.modalInstance?.dispose()
    }

    open(event) {
        event.preventDefault()
        const trigger = event.currentTarget
        const template = document.getElementById(trigger.dataset.guardedTemplateId || "")
        if (!(template instanceof HTMLTemplateElement) || !this.element.contains(template)) {
            window.KMP_accessibility.announce("The approval form could not be opened.", { assertive: true })
            return
        }

        this.clearContent()
        this.lastTrigger = trigger
        this.submitting = false
        this.titleTarget.textContent = trigger.dataset.guardedModalTitle || "Approve backup action"
        this.contentTarget.append(template.content.cloneNode(true))
        this.modalInstance.show()
    }

    submit(event) {
        const form = event.target
        if (!(form instanceof HTMLFormElement)) {
            return
        }

        const confirmation = form.elements.namedItem("confirmation")
        const expected = form.dataset.expectedConfirmation || ""
        if (!(confirmation instanceof HTMLInputElement) || confirmation.value !== expected) {
            event.preventDefault()
            if (confirmation instanceof HTMLInputElement) {
                confirmation.setCustomValidity(`Type "${expected}" exactly to continue.`)
                confirmation.reportValidity()
                confirmation.focus()
            }
            window.KMP_accessibility.announce(`Type "${expected}" exactly to continue.`, { assertive: true })
            return
        }

        confirmation.setCustomValidity("")
        this.submitting = true
        this.modalInstance.hide()
    }

    clearConfirmationError(event) {
        const input = event.target
        if (input instanceof HTMLInputElement && input.name === "confirmation") {
            input.setCustomValidity("")
        }
    }

    /**
     * Recompute the expected typed confirmation when a select carrying a
     * data-confirmation-template (e.g. a restore-target picker) changes.
     */
    updateExpectedConfirmation(event) {
        const source = event.target
        if (!(source instanceof HTMLSelectElement) || !source.dataset.confirmationTemplate) {
            return
        }
        const form = source.closest("form")
        if (!(form instanceof HTMLFormElement)) {
            return
        }
        const expected = source.dataset.confirmationTemplate.replaceAll("{value}", source.value)
        form.dataset.expectedConfirmation = expected
        const confirmation = form.elements.namedItem("confirmation")
        if (confirmation instanceof HTMLInputElement) {
            confirmation.setCustomValidity("")
            const label = form.querySelector(`label[for="${confirmation.id}"]`)
            if (label instanceof HTMLElement) {
                label.textContent = `Type "${expected}" to confirm`
            }
        }
    }

    handleShown() {
        const initialFocus = this.contentTarget.querySelector("[data-guarded-action-initial-focus]")
        if (initialFocus instanceof HTMLElement) {
            initialFocus.focus()
        }
    }

    handleHidden() {
        if (this.submitting) {
            this.clearTimer = window.setTimeout(() => {
                this.clearContent()
                this.clearTimer = null
            }, 0)
        } else {
            this.clearContent()
        }
        this.submitting = false

        if (this.lastTrigger instanceof HTMLElement && this.lastTrigger.isConnected) {
            this.lastTrigger.focus({ preventScroll: true })
        }
    }

    clearContent() {
        this.contentTarget.replaceChildren()
    }
}

if (!window.Controllers) {
    window.Controllers = {}
}
window.Controllers["guarded-action-modal"] = GuardedActionModalController