assets_js_controllers_kanban-controller.js

import { Controller } from "@hotwired/stimulus"

const optionSelector = "[role='option']:not([aria-disabled])"
const activeSelector = "[aria-selected='true']"

/**
 * Kanban Stimulus Controller
 * 
 * Implements drag-and-drop functionality for Kanban board interfaces with
 * server synchronization and position tracking. Provides comprehensive
 * card movement, visual feedback, and AJAX-based persistence.
 * 
 * Features:
 * - Drag-and-drop card movement between columns
 * - Visual feedback during drag operations
 * - Position restoration on invalid drops
 * - Server synchronization via AJAX
 * - CSRF protection for security
 * - Validation callbacks before drop
 * - Automatic position calculation
 * 
 * Values:
 * - csrfToken: String - CSRF token for secure requests
 * - url: String - API endpoint for position updates
 * 
 * Targets:
 * - card: Individual card elements that can be dragged
 * - column: Column containers for card organization
 * 
 * Usage:
 * <div data-controller="kanban" 
 *      data-kanban-csrf-token-value="<%= @csrf_token %>"
 *      data-kanban-url-value="/api/cards/move">
 *   <div data-kanban-target="column" class="sortable" data-col="todo">
 *     <div data-kanban-target="card" data-rec-id="1" data-stack-rank="100"
 *          draggable="true" data-action="dragstart->kanban#grabCard">
 *       Card Content
 *     </div>
 *   </div>
 * </div>
 */
class Kanban extends Controller {
    static targets = ["card", "column"]
    static values = { csrfToken: String, url: String }

    /**
     * Initialize controller state
     * Sets up drag tracking variables
     */
    initialize() {
        this.draggedItem = null;
    }

    /**
     * Register callback function to validate drops before processing
     * Allows custom validation logic for card movements
     * 
     * @param {Function} callback - Validation function returning boolean
     */
    registerBeforeDrop(callback) {
        this.beforeDropCallback = callback;
    }

    /**
     * Handle card drag operation
     * Processes drag movement without dropping
     * 
     * @param {DragEvent} event - Drag event from card element
     */
    cardDrag(event) {
        event.preventDefault();
        this.processDrag(event, false);
    }

    /**
     * Connect controller to DOM
     * Sets up global drag and drop event listeners
     */
    connect() {
        // Add event listeners for drag and drop events 
        document.addEventListener('dragover', this.handleDragOver.bind(this));
        document.addEventListener('drop', this.handleDrop.bind(this));
    }

    /**
     * Disconnect controller from DOM
     * Cleans up global event listeners
     */
    disconnect() {
        // Remove event listeners when the controller is disconnected
        document.removeEventListener('dragover', this.handleDragOver.bind(this));
        document.removeEventListener('drop', this.handleDrop.bind(this));
    }

    /**
     * Handle global drag over events
     * Prevents default behavior to enable drop
     * 
     * @param {DragEvent} event - Global dragover event
     */
    handleDragOver(event) {
        event.preventDefault();
    }

    /**
     * Handle global drop events
     * Restores original position if dropped outside valid area
     * 
     * @param {DragEvent} event - Global drop event
     */
    handleDrop(event) {
        event.preventDefault();
        if (!this.element.contains(event.target)) {
            console.log('Dropped outside of the table');
            // Handle the drop outside of the table
            this.restoreOriginalPosition();
        }
    }

    /**
     * Restore card to its original position
     * Used when drop is invalid or cancelled
     */
    restoreOriginalPosition() {
        if (this.draggedItem && this.originalParent) {
            // Insert the dragged item back to its original position
            if (this.originalIndex >= this.originalParent.children.length) {
                this.originalParent.appendChild(this.draggedItem);
            } else {
                this.originalParent.insertBefore(this.draggedItem, this.originalParent.children[this.originalIndex]);
            }
            this.draggedItem.classList.remove("opacity-25");
            this.draggedItem = null;
        }
    }


    /**
     * Handle card drop operation
     * Processes final drop with server synchronization
     * 
     * @param {DragEvent} event - Drop event from target element
     */
    dropCard(event) {
        event.preventDefault();
        this.processDrag(event, true);
        this.draggedItem.classList.remove("opacity-25");
        this.draggedItem = null;
    }

    /**
     * Handle card grab (drag start)
     * Sets up drag operation and records original position
     * 
     * @param {DragEvent} event - Dragstart event from card element
     */
    grabCard(event) {
        var target = event.target;
        target.classList.add("opacity-25");
        this.draggedItem = target;
        //record where the object is in the dom before it is moved
        this.originalParent = this.draggedItem.parentElement;
        this.originalIndex = Array.prototype.indexOf.call(this.originalParent.children, this.draggedItem);
    }

    /**
     * Process drag operation with position calculation and server sync
     * Handles both preview and final drop operations
     * 
     * @param {DragEvent} event - Drag or drop event
     * @param {Boolean} isDrop - Whether this is final drop operation
     */
    processDrag(event, isDrop) {
        //console.log(event);
        var targetCol = event.target;
        var entityId = this.draggedItem.dataset['recId'];
        var targetStackRank = null;
        while (!targetCol.classList.contains('sortable')) {
            if (targetCol.tagName == 'BODY') {
                return;
            }
            targetCol = targetCol.parentElement;
        }
        var targetBefore = event.target;
        var foundBefore = true;
        while (!targetBefore.classList.contains('card')) {
            if (targetBefore.tagName == 'TD') {
                foundBefore = false;
                break;
            }
            targetBefore = targetBefore.parentElement;
        }
        if (foundBefore) {
            targetStackRank = targetBefore.dataset['stackRank'];
        }
        if (targetCol.classList.contains('sortable')) {
            const data = event.dataTransfer.getData('Text');
            if (foundBefore) {
                targetCol.insertBefore(this.draggedItem, targetBefore);
            } else {
                targetCol.appendChild(this.draggedItem);
            }
            if (isDrop) {
                //in the targetCol get the card before the draggedItem
                var palaceAfter = -1;
                var palaceBefore = -1;
                var previousSibling = this.draggedItem.previousElementSibling;
                if (previousSibling) {
                    palaceAfter = previousSibling.dataset['recId'];
                } else {
                    palaceAfter = -1;
                }
                var nextSibling = this.draggedItem.nextElementSibling;
                if (nextSibling) {
                    palaceBefore = nextSibling.dataset['recId'];
                } else {
                    palaceBefore = -1;
                }
                var toCol = targetCol.dataset['col'];
                if (this.beforeDropCallback && !this.beforeDropCallback(entityId, toCol)) {
                    this.restoreOriginalPosition()
                    return;
                }
                fetch(this.urlValue + "/" + entityId, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        "X-CSRF-Token": this.csrfTokenValue
                    },
                    body: JSON.stringify({
                        newCol: targetCol.dataset['col'],
                        placeAfter: palaceAfter,
                        placeBefore: palaceBefore
                    })
                });
            }
        }
    }
}

if (!window.Controllers) {
    window.Controllers = {}
}
window.Controllers["kanban"] = Kanban;