plugins/Awards/Assets/js/controllers/bestowal-bulk-todo-controller.js

import { Controller } from "@hotwired/stimulus";

/**
 * Awards Bestowal Bulk To-Do Controller
 *
 * Populates the grid "Complete Check" bulk-action modal with the bestowals
 * selected in the grid when the modal opens, so a single named check can be
 * completed across all of them. Server-side eligibility decides which selected
 * bestowals are actually flipped.
 */
class AwardsBestowalBulkTodo extends Controller {
    static targets = [
        "ids",
        "summary",
        "submit",
        "checkSelect",
        "gatheringSection",
        "gatheringControl",
        "gatheringHelp",
        "includePast",
    ];
    static values = {
        lookupUrl: String,
    };

    connect() {
        this.boundHandleShow = this.handleShow.bind(this);
        this.boundSubmit = this.confirmTerminal.bind(this);
        this.element.addEventListener("submit", this.boundSubmit);
        this.boundCheckChange = this.handleCheckChange.bind(this);
        this.boundGatheringChange = this.updateSubmitState.bind(this);
        this.element.addEventListener("show.bs.modal", this.boundHandleShow);
        if (this.hasCheckSelectTarget) {
            this.checkSelectTarget.addEventListener("change", this.boundCheckChange);
        }
        if (this.hasGatheringControlTarget) {
            this.gatheringControlTarget.addEventListener("autocomplete.change", this.boundGatheringChange);
            this.gatheringControlTarget.addEventListener("change", this.boundGatheringChange);
        }
    }

    async confirmTerminal(event) {
        if (this.confirmedSubmission) {
            this.confirmedSubmission = false;
            return;
        }
        const key = this.hasCheckSelectTarget ? this.checkSelectTarget.value : "";
        const messages = (this.currentRows || []).flatMap(row => row.options
            .filter(option => option.key === key && option.isTerminal)
            .map(option => `Bestowal #${row.id}: ${option.confirmation}`));
        if (!messages.length) return;
        event.preventDefault();
        if (await window.KMP_accessibility.confirm(messages.join("\n\n"))) {
            this.confirmedSubmission = true;
            event.target.requestSubmit(event.submitter);
        }
    }

    disconnect() {
        this.element.removeEventListener("show.bs.modal", this.boundHandleShow);
        this.element.removeEventListener("submit", this.boundSubmit);
        if (this.hasCheckSelectTarget) {
            this.checkSelectTarget.removeEventListener("change", this.boundCheckChange);
        }
        if (this.hasGatheringControlTarget) {
            this.gatheringControlTarget.removeEventListener("autocomplete.change", this.boundGatheringChange);
            this.gatheringControlTarget.removeEventListener("change", this.boundGatheringChange);
        }
    }

    /** Read the current grid selection when the modal opens. */
    handleShow(event) {
        const selection = this.readSelectionDetails(event.relatedTarget);
        this.applySelection(selection.ids, selection.rows);
    }

    /**
     * Resolve the selected bestowal ids from the live grid checkboxes, falling
     * back to the serialized selection stashed on the bulk-action button.
     *
     * @param {HTMLElement|null} button
     * @return {string[]}
     */
    readSelection(button) {
        return this.readSelectionDetails(button).ids;
    }

    /**
     * Resolve the selected bestowal ids and row metadata from the live grid
     * checkboxes, falling back to the serialized selection payload.
     *
     * @param {HTMLElement|null} button
     * @return {{ids: string[], rows: Array<{id: string, options: Array<object>}>}}
     */
    readSelectionDetails(button) {
        if (!button) {
            return { ids: [], rows: [] };
        }

        const grid = button.closest("turbo-frame")
            || button.closest("[data-controller~=\"grid-view\"]")
            || document;
        const checked = Array.from(
            grid.querySelectorAll("[data-grid-view-target~=\"rowCheckbox\"]:checked:not(:disabled)"),
        );
        if (checked.length > 0) {
            return this.selectionFromCheckboxes(checked);
        }

        if (button.dataset.bulkActionSelection) {
            try {
                const parsed = JSON.parse(button.dataset.bulkActionSelection);
                if (Array.isArray(parsed.ids)) {
                    const rows = Array.isArray(parsed.checkboxes)
                        ? parsed.checkboxes.map((row) => this.selectionRowFromDataset(row))
                        : [];
                    return {
                        ids: parsed.ids.filter(Boolean).map(String),
                        rows,
                    };
                }
            } catch (error) {
                // Ignore malformed selection payloads.
            }
        }

        return { ids: [], rows: [] };
    }

    /** @param {HTMLInputElement[]} checkboxes */
    selectionFromCheckboxes(checkboxes) {
        const rows = checkboxes.map((checkbox) => this.selectionRowFromDataset({
            id: checkbox.value,
            bulkTodoOptions: checkbox.dataset.bulkTodoOptions || "[]",
        }));

        return {
            ids: rows.map((row) => row.id).filter(Boolean),
            rows,
        };
    }

    /**
     * Normalize a selection row read from a checkbox or serialized dataset.
     *
     * @param {object} row Selection row.
     * @param {string} [row.id] Selected bestowal identifier.
     * @param {string} [row.bulkTodoOptions] JSON-encoded to-do options.
     * @returns {{id: string, options: Array<object>}} Normalized selection row.
     */
    selectionRowFromDataset(row) {
        return {
            id: String(row.id || ""),
            options: this.parseOptions(row.bulkTodoOptions || "[]"),
        };
    }

    /** @param {string} raw */
    parseOptions(raw) {
        try {
            const parsed = JSON.parse(raw);
            if (Array.isArray(parsed)) {
                return parsed.filter((option) => option && typeof option.key === "string");
            }
        } catch (error) {
            // Ignore malformed row option payloads.
        }

        return [];
    }

    /**
     * @param {string[]} ids
     * @param {Array<{id: string, options: Array<object>}>} rows
     */
    applySelection(ids, rows = []) {
        const unique = [...new Set(ids.map(String))];
        this.currentRows = rows;
        this.currentOptions = this.buildOptions(rows);
        if (this.hasIncludePastTarget) {
            this.includePastTarget.checked = false;
        }

        if (this.hasIdsTarget) {
            this.idsTarget.value = unique.join(",");
        }
        if (this.hasSummaryTarget) {
            this.summaryTarget.textContent = this.summaryText(unique.length, this.currentOptions.length);
        }
        this.populateCheckOptions();
        this.resetGathering();
        this.toggleGatheringSection(false);
        this.updateSubmitState();
    }

    summaryText(selectionCount, optionCount) {
        if (selectionCount === 0) {
            return "Select bestowals in the grid to complete a check across them.";
        }
        if (optionCount === 0) {
            return selectionCount === 1
                ? "No open checks assigned to you are available on the selected bestowal."
                : "No open checks assigned to you are available on the selected bestowals.";
        }

        return selectionCount === 1
            ? "Choose an open check assigned to you for 1 selected bestowal."
            : `Choose an open check assigned to you for ${selectionCount} selected bestowals.`;
    }

    /** @param {Array<{id: string, options: Array<object>}>} rows */
    buildOptions(rows) {
        const options = new Map();
        rows.forEach((row) => {
            row.options.forEach((option) => {
                if (!options.has(option.key)) {
                    options.set(option.key, {
                        key: option.key,
                        label: option.label || option.key,
                        requiresGathering: option.requiresGathering === true,
                        gatheringHelp: option.gatheringHelp || "",
                        applicableIds: new Set(),
                    });
                }
                const existing = options.get(option.key);
                existing.applicableIds.add(row.id);
                existing.requiresGathering = existing.requiresGathering || option.requiresGathering === true;
                if (!existing.gatheringHelp && option.gatheringHelp) {
                    existing.gatheringHelp = option.gatheringHelp;
                }
            });
        });

        return Array.from(options.values())
            .map((option) => ({
                ...option,
                applicableIds: Array.from(option.applicableIds),
            }))
            .sort((a, b) => a.label.localeCompare(b.label));
    }

    populateCheckOptions() {
        if (!this.hasCheckSelectTarget) {
            return;
        }

        this.checkSelectTarget.replaceChildren();
        const placeholder = document.createElement("option");
        placeholder.value = "";
        placeholder.textContent = this.currentOptions.length > 0
            ? "— Select a check —"
            : "No assigned open checks";
        this.checkSelectTarget.appendChild(placeholder);

        this.currentOptions.forEach((option) => {
            const optionElement = document.createElement("option");
            optionElement.value = option.key;
            optionElement.textContent = option.applicableIds.length > 1
                ? `${option.label} (${option.applicableIds.length})`
                : option.label;
            optionElement.dataset.requiresGathering = option.requiresGathering ? "1" : "0";
            this.checkSelectTarget.appendChild(optionElement);
        });

        this.checkSelectTarget.disabled = this.currentOptions.length === 0;
    }

    handleCheckChange() {
        this.resetGathering();
        const option = this.selectedOption();
        this.toggleGatheringSection(option?.requiresGathering === true);
        if (option?.requiresGathering === true) {
            this.updateLookupUrl(option.applicableIds);
            if (this.hasGatheringHelpTarget && option.gatheringHelp) {
                this.gatheringHelpTarget.textContent = option.gatheringHelp;
            }
        }
        this.updateSubmitState();
    }

    handleIncludePastChange() {
        const option = this.selectedOption();
        if (option?.requiresGathering === true) {
            this.resetGathering();
            this.updateLookupUrl(option.applicableIds);
        }
    }

    selectedOption() {
        if (!this.hasCheckSelectTarget || !this.checkSelectTarget.value) {
            return null;
        }

        return this.currentOptions.find((option) => option.key === this.checkSelectTarget.value) || null;
    }

    toggleGatheringSection(show) {
        if (this.hasGatheringSectionTarget) {
            this.gatheringSectionTarget.hidden = !show;
        }
        if (!this.hasGatheringControlTarget) {
            return;
        }

        const input = this.gatheringControlTarget.querySelector("[data-ac-target=\"input\"]");
        const clearBtn = this.gatheringControlTarget.querySelector("[data-ac-target=\"clearBtn\"]");
        if (input) {
            input.disabled = !show;
            input.required = show;
        }
        if (clearBtn) {
            clearBtn.disabled = true;
        }
    }

    /** @param {string[]} ids */
    updateLookupUrl(ids) {
        if (!this.hasGatheringControlTarget || !this.hasLookupUrlValue) {
            return;
        }

        const url = new URL(this.lookupUrlValue, window.location.href);
        url.searchParams.set("bestowal_ids", [...new Set(ids.map(String))].join(","));
        if (this.hasIncludePastTarget && this.includePastTarget.checked) {
            url.searchParams.set("include_past", "1");
        } else {
            url.searchParams.delete("include_past");
        }
        this.gatheringControlTarget.dataset.acUrlValue = url.toString();
    }

    resetGathering() {
        if (!this.hasGatheringControlTarget) {
            return;
        }

        const input = this.gatheringControlTarget.querySelector("[data-ac-target=\"input\"]");
        const hidden = this.gatheringControlTarget.querySelector("[data-ac-target=\"hidden\"]");
        const hiddenText = this.gatheringControlTarget.querySelector("[data-ac-target=\"hiddenText\"]");
        const clearBtn = this.gatheringControlTarget.querySelector("[data-ac-target=\"clearBtn\"]");
        if (input) {
            input.value = "";
            input.removeAttribute("aria-invalid");
        }
        if (hidden) {
            hidden.value = "";
        }
        if (hiddenText) {
            hiddenText.value = "";
        }
        if (clearBtn) {
            clearBtn.disabled = true;
        }
    }

    updateSubmitState() {
        if (!this.hasSubmitTarget) {
            return;
        }

        const hasIds = this.hasIdsTarget && this.idsTarget.value !== "";
        const selectedOption = this.selectedOption();
        const gatheringId = this.hasGatheringControlTarget
            ? this.gatheringControlTarget.querySelector("[data-ac-target=\"hidden\"]")?.value || ""
            : "";
        this.submitTarget.disabled = !hasIds
            || selectedOption === null
            || (selectedOption.requiresGathering === true && gatheringId === "");
    }
}

if (!window.Controllers) {
    window.Controllers = {};
}
window.Controllers["awards-bestowal-bulk-todo"] = AwardsBestowalBulkTodo;

export default AwardsBestowalBulkTodo;