assets_js_controllers_permission-manage-policies-controller.js
import { Controller } from "@hotwired/stimulus"
/**
* **INTERNAL CODE DOCUMENTATION COMPLETE**
*
* Permission Manage Policies Controller
*
* A sophisticated Stimulus controller that provides an interactive permission matrix for managing
* CakePHP authorization policies. Features hierarchical checkbox management with indeterminate
* states, batch processing for performance, and asynchronous AJAX updates with queue management.
*
* Key Features:
* - Hierarchical checkbox management (class-level controls method-level checkboxes)
* - Indeterminate state indicators for partial selections
* - Batch processing with loading overlay for performance optimization
* - Asynchronous AJAX queue management for reliable server updates
* - Dynamic event listener management with cleanup
*
* @class PermissionManagePolicies
* @extends Controller
*
* HTML Structure Example:
* ```html
* <div data-controller="permission-manage-policies"
* data-permission-manage-policies-url-value="/permissions/manage-policies">
* <!-- Class-level checkbox (controls all methods in the class) -->
* <input type="checkbox"
* data-permission-manage-policies-target="policyClass"
* data-class-name="App\\Controller\\MembersController"
* data-permission-id="1">
*
* <!-- Method-level checkboxes (controlled by class checkbox) -->
* <input type="checkbox"
* data-permission-manage-policies-target="policyMethod"
* data-class-name="App\\Controller\\MembersController"
* data-permission-id="1"
* data-method-name="index">
*
* <input type="checkbox"
* data-permission-manage-policies-target="policyMethod"
* data-class-name="App\\Controller\\MembersController"
* data-permission-id="1"
* data-method-name="view">
* </div>
* ```
*/
class PermissionManagePolicies extends Controller {
static targets = ["policyClass", "policyMethod"]
static values = {
url: String,
}
/** @type {Array} Queue for managing sequential AJAX requests to prevent race conditions */
changeQueue = []
/**
* Event handler for policy class target connection
* Sets up click event listeners for class-level checkboxes
*
* @param {HTMLElement} element - The connected policy class checkbox element
*/
policyClassTargetConnected(element) {
//add event listener to the element
element.clickEvent = (event) => {
this.classClicked(event)
}
element.addEventListener("click", element.clickEvent)
}
/**
* Event handler for policy method target connection
* Sets up click event listeners for method-level checkboxes
*
* @param {HTMLElement} element - The connected policy method checkbox element
*/
policyMethodTargetConnected(element) {
//add event listener to the element
element.clickEvent = (event) => {
this.methodClicked(event)
}
element.addEventListener("click", element.clickEvent)
}
/**
* Controller initialization and setup
* Implements batch processing for performance optimization when dealing with large
* permission matrices. Shows loading overlay during processing.
*/
connect() {
// Show loading overlay
this.showLoadingOverlay();
// Batch process checkboxes for performance
const classes = Array.from(document.querySelectorAll(`input[type='checkbox'][data-class-name][data-permission-id]:not([data-method-name])`));
const batchSize = 100; // Number of checkboxes to process per batch
let index = 0;
const processBatch = () => {
const end = Math.min(index + batchSize, classes.length);
for (let i = index; i < end; i++) {
const element = classes[i];
const className = element.dataset.className;
const permissionId = element.dataset.permissionId;
this.checkClass(className, permissionId);
}
index = end;
if (index < classes.length) {
setTimeout(processBatch, 0);
} else {
this.hideLoadingOverlay();
}
};
processBatch();
}
/**
* Display loading overlay during batch processing
* Creates a Bootstrap spinner overlay for performance indication
*/
showLoadingOverlay() {
// Find the permissions-matrix container
const container = this.element.closest('.permissions-matrix') || this.element;
if (!container.querySelector('.loading-overlay')) {
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
overlay.style.position = 'absolute';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.background = 'rgba(255,255,255,0.7)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = 1000;
container.style.position = 'relative';
container.appendChild(overlay);
}
}
/**
* Remove loading overlay after processing completion
* Cleans up spinner overlay from permissions matrix
*/
hideLoadingOverlay() {
const container = this.element.closest('.permissions-matrix') || this.element;
const overlay = container.querySelector('.loading-overlay');
if (overlay) {
overlay.remove();
}
}
/**
* Update class-level checkbox state based on method selections
* Implements three-state logic: checked, unchecked, and indeterminate
*
* @param {String} className - The class name to check state for
* @param {String} permissionId - The permission ID to check state for
*/
checkClass(className, permissionId) {
const methods = document.querySelectorAll(`input[type='checkbox'][data-class-name='${className}'][data-permission-id='${permissionId}'][data-method-name]`)
let checkCount = 0
methods.forEach((method) => {
if (method.checked) {
checkCount++
}
})
const allChecked = checkCount === methods.length
const someChecked = checkCount > 0 && checkCount < methods.length
const classCheckbox = document.querySelectorAll(`input[type='checkbox'][data-class-name='${className}'][data-permission-id='${permissionId}']:not([data-method-name])`)[0]
classCheckbox.checked = allChecked || someChecked
if (someChecked) {
// add the secondary class to the checkbox
classCheckbox.classList.add("indeterminate-switch")
} else {
// remove the secondary class from the checkbox
classCheckbox.classList.remove("indeterminate-switch")
}
}
/**
* Handle class-level checkbox clicks
* Updates all method-level checkboxes and manages indeterminate state
*
* @param {Event} event - The click event from class checkbox
*/
classClicked(event) {
const checkbox = event.target
const isChecked = checkbox.checked
const className = checkbox.dataset.className
const permissionId = checkbox.dataset.permissionId
const methods = document.querySelectorAll(`input[type='checkbox'][data-class-name='${className}'][data-permission-id='${permissionId}'][data-method-name]`)
methods.forEach((method) => {
method.checked = isChecked
this.changeMethod(method, isChecked)
})
checkbox.classList.remove("indeterminate-switch");
}
/**
* Handle method-level checkbox clicks
* Updates parent class checkbox state and queues server update
*
* @param {Event} event - The click event from method checkbox
*/
methodClicked(event) {
// check if the element is checked or not
const checkbox = event.target
const isChecked = checkbox.checked
const className = checkbox.dataset.className
const permissionId = checkbox.dataset.permissionId
this.checkClass(className, permissionId);
this.changeMethod(checkbox, isChecked)
}
/**
* Queue permission change for server update
* Adds change to queue and processes if not already processing
*
* @param {HTMLElement} method - The method checkbox element
* @param {Boolean} isChecked - Whether the checkbox is checked
*/
changeMethod(method, isChecked) {
let className = method.dataset.className
className = className.replace(/-/g, "\\");
console.log(className);
const methodName = method.dataset.methodName
const permissionId = method.dataset.permissionId
this.changeQueue.push({
permissionId: permissionId,
method: methodName,
className: className,
action: isChecked ? "add" : "delete",
})
// if the queue is empty then start the queue
if (this.changeQueue.length === 1) {
this.processQueue()
}
}
/**
* Process queued permission changes sequentially
* Handles AJAX communication with server for policy updates
* Maintains queue integrity and prevents race conditions
*/
processQueue() {
if (this.changeQueue.length === 0) {
return
}
const change = this.changeQueue[0]
// make a fetch call to the controller url with the change
fetch(this.urlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
},
body: JSON.stringify(change),
})
.then((response) => response.json())
.then((data) => {
// remove the change from the queue
this.changeQueue.shift()
// process the next change in the queue
this.processQueue()
})
}
/**
* Clean up event listeners on controller disconnect
* Removes all dynamically added event listeners to prevent memory leaks
*/
disconnect() {
// remove event listeners from all elements
this.policyClassTargets.forEach((element) => {
element.removeEventListener("click", element.clickEvent);
});
this.policyMethodTargets.forEach((element) => {
element.removeEventListener("click", element.clickEvent)
});
}
}
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["permission-manage-policies"] = PermissionManagePolicies;