assets/js/controllers/approval-response-controller.js

import { Controller } from "@hotwired/stimulus"

/**
 * Approval Response Controller
 *
 * Handles the workflow approval response modal form logic.
 * Shows/hides the "Pick Next Approver" section based on decision
 * and serialPickNext configuration.
 */
class ApprovalResponseController extends Controller {
    static targets = ["decision", "decisionSection", "decisionLegend", "decisionOptions", "nextApproverSection", "nextApproverInput", "submitBtn", "comment", "commentRequiredHint", "commentWarning", "commentWarningText", "infoText", "approvalIds", "bulkSummary", "bestowalGatheringSection", "bestowalGathering", "bestowalGatheringError"]
    static values = {
        serialPickNext: Boolean,
        requiredCount: Number,
        approvedCount: Number,
        approvalId: Number,
        eligibleUrl: String,
    }

    connect() {
        this._updateVisibility()
    }

    onDecisionChange(event) {
        this._updateVisibility()
    }

    onBestowalGatheringChange() {
        if (this.hasBestowalGatheringTarget && this._getBestowalGatheringValue()) {
            this._setBestowalGatheringInvalid(false)
        }
    }

    /**
     * Called when the modal is shown with approval data.
     */
    configure(approvalData) {
        this.approvalIdValue = approvalData.id || 0
        this.serialPickNextValue = approvalData.serialPickNext || false
        this.requiredCountValue = approvalData.requiredCount || 1
        this.approvedCountValue = approvalData.approvedCount || 0
        this.eligibleUrlValue = approvalData.eligibleUrl || ''
        this._commentWarning = approvalData.commentWarning || ''
        this._requiresComment = approvalData.requiresComment || false
        this._feedbackResponse = approvalData.feedbackResponse || false
        this._decisionOptions = Array.isArray(approvalData.decisionOptions) ? approvalData.decisionOptions : []
        this._decisionPromptLabel = approvalData.decisionPromptLabel || 'Decision'
        this._approveLabel = approvalData.approveLabel || 'Approve'
        this._hideReject = approvalData.hideReject || false
        this._bulkIds = Array.isArray(approvalData.bulkIds) ? approvalData.bulkIds : []
        this._bulkError = approvalData.bulkError || ''
        this._requiresBestowalGathering = approvalData.requiresBestowalGathering === true
        this._bestowalGatheringUrl = approvalData.bestowalGatheringUrl || approvalData.bestowal_gathering_url || ''
        this._renderDecisionOptions()

        // Reset form
        this.decisionTargets.forEach(el => el.checked = false)
        if (approvalData.defaultDecision) {
            const defaultDecision = this.decisionTargets.find(el => el.value === approvalData.defaultDecision)
            if (defaultDecision) defaultDecision.checked = true
        }
        if (this.hasCommentTarget) this.commentTarget.value = ''
        if (this.hasBestowalGatheringTarget) {
            this._setBestowalGatheringValue('')
            this._setBestowalGatheringInvalid(false)
        }
        if (this.hasApprovalIdsTarget) {
            this.approvalIdsTarget.value = this._bulkIds.join(',')
        }
        if (this.hasNextApproverInputTarget) {
            // Clear the autocomplete widget
            const acEl = this.nextApproverInputTarget.closest('[data-controller="ac"]')
            if (acEl && acEl.value !== undefined) {
                acEl.value = ''
            }
        }

        // Show/hide comment warning from workflow config
        if (this.hasCommentWarningTarget) {
            if (this._commentWarning) {
                this.commentWarningTextTarget.textContent = this._commentWarning
                this.commentWarningTarget.hidden = false
            } else {
                this.commentWarningTarget.hidden = true
            }
        }

        // Update info text
        if (this.hasInfoTextTarget) {
            const remaining = this.requiredCountValue - this.approvedCountValue - 1
            if (remaining > 0 && this.serialPickNextValue) {
                this.infoTextTarget.textContent = `This approval requires ${remaining} more approver(s). Select who should review next.`
                this.infoTextTarget.hidden = false
            } else {
                this.infoTextTarget.hidden = true
            }
        }

        // Update the autocomplete URL if we have one
        if (this.eligibleUrlValue && this.hasNextApproverInputTarget) {
            const acEl = this.nextApproverInputTarget.closest('[data-controller="ac"]')
            if (acEl) {
                acEl.setAttribute('data-ac-url-value', this.eligibleUrlValue)
            }
        }

        if (this._bestowalGatheringUrl && this.hasBestowalGatheringTarget) {
            const acEl = this.bestowalGatheringTarget.closest('[data-controller~="ac"]')
                || (this.bestowalGatheringTarget.matches?.('[data-controller~="ac"]') ? this.bestowalGatheringTarget : null)
            if (acEl) {
                acEl.setAttribute('data-ac-url-value', this._bestowalGatheringUrl)
            }
        }

        if (this.hasBulkSummaryTarget) {
            this.bulkSummaryTarget.hidden = this._bulkIds.length < 2 && !this._bulkError
            this.bulkSummaryTarget.classList.toggle('alert-info', !this._bulkError)
            this.bulkSummaryTarget.classList.toggle('alert-danger', !!this._bulkError)
            this.bulkSummaryTarget.textContent = this._bulkError
                || `This response will be applied to ${this._bulkIds.length} selected approvals.`
        }

        this._updateVisibility()
    }

    validateSubmit(event) {
        if (this._requiresBestowalGathering && this._getSelectedDecision() === 'approve') {
            this._setBestowalGatheringInvalid(false)
        }
    }

    _updateVisibility() {
        if (!this.hasNextApproverSectionTarget) return

        const decision = this._getSelectedDecision()
        const isApprove = decision === 'approve'
        const isReject = decision === 'reject'
        const needsMore = (this.approvedCountValue + 1) < this.requiredCountValue
        const showPicker = !this._feedbackResponse && isApprove && this.serialPickNextValue && needsMore
        const showBestowalGathering = !this._feedbackResponse && isApprove && this._requiresBestowalGathering

        if (this.hasDecisionSectionTarget) {
            this.decisionSectionTarget.hidden = this._feedbackResponse && this._decisionOptions.length === 0
        }
        if (this.hasDecisionLegendTarget) {
            this.decisionLegendTarget.textContent = this._decisionPromptLabel || 'Decision'
        }

        this.nextApproverSectionTarget.hidden = !showPicker
        if (this.hasBestowalGatheringSectionTarget) {
            this.bestowalGatheringSectionTarget.hidden = !showBestowalGathering
        }
        if (this.hasBestowalGatheringTarget) {
            if (showBestowalGathering) {
                this.bestowalGatheringTarget.removeAttribute('required')
                this.bestowalGatheringTarget.removeAttribute('aria-required')
                this._getBestowalGatheringInput()?.removeAttribute('required')
                this._getBestowalGatheringInput()?.removeAttribute('aria-required')
            } else {
                this.bestowalGatheringTarget.removeAttribute('required')
                this.bestowalGatheringTarget.removeAttribute('aria-required')
                this._getBestowalGatheringInput()?.removeAttribute('required')
                this._getBestowalGatheringInput()?.removeAttribute('aria-required')
                this._setBestowalGatheringValue('')
                this._setBestowalGatheringInvalid(false)
            }
        }

        // Comment is required for rejections and feedback responses.
        if (this.hasCommentTarget) {
            if (isReject || this._requiresComment) {
                this.commentTarget.setAttribute('required', 'required')
                this.commentTarget.placeholder = isReject
                    ? 'A reason is required when rejecting...'
                    : 'Feedback is required...'
            } else {
                this.commentTarget.removeAttribute('required')
                this.commentTarget.placeholder = 'Optional comment...'
            }
        }

        // Show/hide rejection reason label
        if (this.hasCommentRequiredHintTarget) {
            this.commentRequiredHintTarget.hidden = !(isReject || this._requiresComment)
        }

        // Update required state on hidden input
        if (this.hasNextApproverInputTarget) {
            const acEl = this.nextApproverInputTarget.closest('[data-controller="ac"]')
            if (acEl) {
                const hiddenInput = acEl.querySelector('[data-ac-target="hidden"]')
                if (hiddenInput) {
                    if (showPicker) {
                        hiddenInput.setAttribute('required', 'required')
                    } else {
                        hiddenInput.removeAttribute('required')
                    }
                }
            }
        }

        // Enable/disable submit
        if (this.hasSubmitBtnTarget) {
            this.submitBtnTarget.disabled = !decision || !!this._bulkError
        }
    }

    _getSelectedDecision() {
        for (const el of this.decisionTargets) {
            if (el.checked) return el.value
        }
        return null
    }

    _renderDecisionOptions() {
        if (!this.hasDecisionOptionsTarget) return

        if (this._decisionOptions.length === 0) {
            const rejectHidden = this._hideReject ? ' hidden' : ''
            this.decisionOptionsTarget.innerHTML = `
                <div class="form-check">
                    <input class="form-check-input" type="radio" name="decision" id="decisionApprove" value="approve"
                        data-approval-response-target="decision"
                        data-action="change->approval-response#onDecisionChange">
                    <label class="form-check-label text-success fw-semibold" for="decisionApprove">
                        <i class="bi bi-check-circle me-1"></i>${this._escHtml(this._approveLabel)}
                    </label>
                </div>
                <div class="form-check"${rejectHidden}>
                    <input class="form-check-input" type="radio" name="decision" id="decisionReject" value="reject"
                        data-approval-response-target="decision"
                        data-action="change->approval-response#onDecisionChange">
                    <label class="form-check-label text-danger fw-semibold" for="decisionReject">
                        <i class="bi bi-x-circle me-1"></i>Reject
                    </label>
                </div>`
            return
        }

        this.decisionOptionsTarget.innerHTML = this._decisionOptions.map((option, index) => {
            const id = `decisionCustom${index}`
            const value = this._escAttr(option.value)
            const label = this._escHtml(option.label)

            return `<div class="form-check">
                <input class="form-check-input" type="radio" name="decision" id="${id}" value="${value}"
                    data-approval-response-target="decision"
                    data-action="change->approval-response#onDecisionChange">
                <label class="form-check-label fw-semibold" for="${id}">${label}</label>
            </div>`
        }).join('')
    }

    _setBestowalGatheringInvalid(isInvalid) {
        if (!this.hasBestowalGatheringTarget) return

        const input = this._getBestowalGatheringInput()
        const target = input || this.bestowalGatheringTarget
        target.setAttribute('aria-invalid', isInvalid ? 'true' : 'false')
        if (this.hasBestowalGatheringErrorTarget) {
            this.bestowalGatheringErrorTarget.hidden = !isInvalid
        }
    }

    _getBestowalGatheringValue() {
        if (!this.hasBestowalGatheringTarget) return ''

        const hidden = this.bestowalGatheringTarget.querySelector('[data-ac-target="hidden"]')
        if (hidden) return hidden.value

        return this.bestowalGatheringTarget.value || ''
    }

    _setBestowalGatheringValue(value) {
        if (!this.hasBestowalGatheringTarget) return

        const hidden = this.bestowalGatheringTarget.querySelector('[data-ac-target="hidden"]')
        if (hidden) hidden.value = value
        const hiddenText = this.bestowalGatheringTarget.querySelector('[data-ac-target="hiddenText"]')
        if (hiddenText) hiddenText.value = ''
        const input = this._getBestowalGatheringInput()
        if (input) input.value = ''
        if (!hidden && !input) this.bestowalGatheringTarget.value = value
    }

    _getBestowalGatheringInput() {
        if (!this.hasBestowalGatheringTarget) return null

        return this.bestowalGatheringTarget.querySelector('[data-ac-target="input"]')
            || (this.bestowalGatheringTarget.matches?.('input,select,textarea')
                ? this.bestowalGatheringTarget
                : null)
    }

    _escHtml(value) {
        const div = document.createElement('div')
        div.textContent = String(value ?? '')
        return div.innerHTML
    }

    _escAttr(value) {
        return this._escHtml(value).replace(/"/g, '&quot;')
    }
}

if (!window.Controllers) {
    window.Controllers = {}
}
window.Controllers["approval-response"] = ApprovalResponseController