assets_js_controllers_gathering-schedule-controller.js
import { Controller } from "@hotwired/stimulus";
/**
* Gathering Schedule Controller
*
* Manages the gathering schedule interface including:
* - Adding new scheduled activities via modal
* - Editing existing scheduled activities via modal
* - Deleting scheduled activities
* - Dynamic form field updates based on activity selection
*/
class GatheringScheduleController extends Controller {
static targets = [
"scheduleList",
"addModal",
"editModal",
"activitySelect",
"isOtherCheckbox",
"addForm",
"editForm",
"editActivitySelect",
"editIsOtherCheckbox",
"startDatetime",
"endDatetime",
"editStartDatetime",
"editEndDatetime",
"hasEndTimeCheckbox",
"editHasEndTimeCheckbox",
"endTimeContainer",
"editEndTimeContainer"
]
static values = {
gatheringId: Number,
gatheringStart: String, // YYYY-MM-DD format
gatheringEnd: String, // YYYY-MM-DD format
addUrl: String,
editUrl: String,
deleteUrl: String
}
/**
* Initialize controller
*/
connect() {
console.log('Gathering schedule controller connected');
// Note: setupDateTimeLimits() is called when the modal opens (resetAddForm)
// because Stimulus values may not be initialized yet during connect()
}
/**
* Setup min/max limits on datetime inputs based on gathering dates
* This is called when modals open to ensure values are set
*/
setupDateTimeLimits() {
// Validate that gathering dates are present and in correct format (YYYY-MM-DDTHH:MM)
const datetimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
if (!this.gatheringStartValue || !this.gatheringEndValue ||
!datetimePattern.test(this.gatheringStartValue) ||
!datetimePattern.test(this.gatheringEndValue)) {
console.warn('Invalid gathering dates - skipping datetime limits setup');
console.log('Start:', this.gatheringStartValue, 'End:', this.gatheringEndValue);
return;
}
// Use the gathering start/end times directly (already in gathering timezone)
const minDatetime = this.gatheringStartValue;
const maxDatetime = this.gatheringEndValue;
// Set limits on add form inputs
if (this.hasStartDatetimeTarget) {
this.startDatetimeTarget.min = minDatetime;
this.startDatetimeTarget.max = maxDatetime;
// Set default to start of gathering if empty
if (!this.startDatetimeTarget.value) {
this.startDatetimeTarget.value = this.gatheringStartValue;
}
}
if (this.hasEndDatetimeTarget) {
this.endDatetimeTarget.min = minDatetime;
this.endDatetimeTarget.max = maxDatetime;
// Don't set a default value - end time is optional
}
// Set limits on edit form inputs
if (this.hasEditStartDatetimeTarget) {
this.editStartDatetimeTarget.min = minDatetime;
this.editStartDatetimeTarget.max = maxDatetime;
}
if (this.hasEditEndDatetimeTarget) {
this.editEndDatetimeTarget.min = minDatetime;
this.editEndDatetimeTarget.max = maxDatetime;
}
}
/**
* Reset add form when modal is opened
*/
resetAddForm(event) {
// Setup datetime limits when modal opens (values are guaranteed to be available now)
this.setupDateTimeLimits();
// Validate that gathering dates are present and in correct format (YYYY-MM-DDTHH:MM)
const datetimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
if (!this.gatheringStartValue || !this.gatheringEndValue ||
!datetimePattern.test(this.gatheringStartValue) ||
!datetimePattern.test(this.gatheringEndValue)) {
console.warn('Invalid gathering dates - skipping form reset defaults');
return;
}
// Reset to defaults - use gathering start time
if (this.hasStartDatetimeTarget) {
this.startDatetimeTarget.value = this.gatheringStartValue;
}
if (this.hasEndDatetimeTarget) {
// Don't set a default value - end time is optional
this.endDatetimeTarget.value = '';
}
}
/**
* Handle activity select change - disable/enable based on "other" checkbox
*/
handleActivityChange(event) {
const isOther = this.isOtherCheckboxTarget.checked;
this.activitySelectTarget.disabled = isOther;
if (isOther) {
this.activitySelectTarget.value = '';
}
}
/**
* Handle "other" checkbox change for add form
*/
handleOtherChange(event) {
const isOther = event.target.checked;
this.activitySelectTarget.disabled = isOther;
this.activitySelectTarget.required = !isOther;
if (isOther) {
this.activitySelectTarget.value = '';
}
}
/**
* Handle "other" checkbox change for edit form
*/
handleEditOtherChange(event) {
const isOther = event.target.checked;
this.editActivitySelectTarget.disabled = isOther;
this.editActivitySelectTarget.required = !isOther;
if (isOther) {
this.editActivitySelectTarget.value = '';
}
}
/**
* Toggle end time field visibility for add form
*/
toggleEndTime(event) {
const hasEndTime = event.target.checked;
if (this.hasEndTimeContainerTarget) {
this.endTimeContainerTarget.style.display = hasEndTime ? 'block' : 'none';
}
// Clear end time if unchecking
if (!hasEndTime && this.hasEndDatetimeTarget) {
this.endDatetimeTarget.value = '';
} else {
// If checking, set default end time to one hour after start time
if (this.hasStartDatetimeTarget && this.startDatetimeTarget.value) {
const startDate = new Date(this.startDatetimeTarget.value);
startDate.setHours(startDate.getHours() + 1);
const year = startDate.getFullYear();
const month = String(startDate.getMonth() + 1).padStart(2, '0');
const day = String(startDate.getDate()).padStart(2, '0');
const hours = String(startDate.getHours()).padStart(2, '0');
const minutes = String(startDate.getMinutes()).padStart(2, '0');
this.endDatetimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`;
}
}
}
/**
* Toggle end time field visibility for edit form
*/
toggleEditEndTime(event) {
const hasEndTime = event.target.checked;
if (this.hasEditEndTimeContainerTarget) {
this.editEndTimeContainerTarget.style.display = hasEndTime ? 'block' : 'none';
}
// Clear end time if unchecking
if (!hasEndTime && this.hasEditEndDatetimeTarget) {
this.editEndDatetimeTarget.value = '';
} else {
// If checking, set default end time to one hour after start time
if (this.hasEditStartDatetimeTarget && this.editStartDatetimeTarget.value) {
const startDate = new Date(this.editStartDatetimeTarget.value);
startDate.setHours(startDate.getHours() + 1);
const year = startDate.getFullYear();
const month = String(startDate.getMonth() + 1).padStart(2, '0');
const day = String(startDate.getDate()).padStart(2, '0');
const hours = String(startDate.getHours()).padStart(2, '0');
const minutes = String(startDate.getMinutes()).padStart(2, '0');
this.editEndDatetimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`;
}
}
}
/**
* Open edit modal and populate with activity data
*/
openEditModal(event) {
event.preventDefault();
// Setup datetime limits when modal opens (values are guaranteed to be available now)
this.setupDateTimeLimits();
const button = event.currentTarget;
// Get data attributes from the button
const activityId = button.dataset.activityId;
const activityName = button.dataset.activityName;
const gatheringActivityId = button.dataset.gatheringActivityId;
const startDatetime = button.dataset.startDatetime;
const endDatetime = button.dataset.endDatetime;
const displayTitle = button.dataset.displayTitle;
const description = button.dataset.description;
const preRegister = button.dataset.preRegister === 'true';
const isOther = button.dataset.isOther === 'true';
const hasEndTime = button.dataset.hasEndTime === 'true';
// Populate form fields
const form = this.editFormTarget;
form.action = this.editUrlValue.replace('__ID__', activityId);
form.querySelector('[name="gathering_activity_id"]').value = gatheringActivityId || '';
form.querySelector('[name="start_datetime"]').value = startDatetime;
form.querySelector('[name="end_datetime"]').value = endDatetime || '';
form.querySelector('[name="display_title"]').value = displayTitle;
form.querySelector('[name="description"]').value = description || '';
// Use getElementById for checkboxes to avoid hidden input conflicts
document.getElementById('edit-pre-register').checked = preRegister;
document.getElementById('edit-is-other').checked = isOther;
document.getElementById('edit-has-end-time').checked = hasEndTime;
// Handle activity select state based on is_other
const activitySelect = this.editActivitySelectTarget;
activitySelect.disabled = isOther;
activitySelect.required = !isOther;
// Handle end time container visibility
if (this.hasEditEndTimeContainerTarget) {
this.editEndTimeContainerTarget.style.display = hasEndTime ? 'block' : 'none';
}
// Show the modal
const modal = new bootstrap.Modal(this.editModalTarget);
modal.show();
}
/**
* Validate datetime is within gathering range
*/
validateDatetimeRange(event) {
const input = event.target;
const value = input.value;
if (!value) return;
const minDatetime = `${this.gatheringStartValue}T00:00`;
const maxDatetime = `${this.gatheringEndValue}T23:59`;
const selectedDate = new Date(value);
const minDate = new Date(minDatetime);
const maxDate = new Date(maxDatetime);
if (selectedDate < minDate || selectedDate > maxDate) {
input.setCustomValidity(`Date must be between ${this.formatDate(minDate)} and ${this.formatDate(maxDate)}`);
input.reportValidity();
} else {
input.setCustomValidity('');
}
}
/**
* Format date for display
*/
formatDate(date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
/**
* Normalize error response into a readable string
* Handles errors that may be an array, object, string, or missing
*/
normalizeErrors(result) {
if (!result.errors) {
return result.message || 'An error occurred';
}
// If errors is already an array, join it
if (Array.isArray(result.errors)) {
return result.errors.join(', ');
}
// If errors is a string, use it directly
if (typeof result.errors === 'string') {
return result.errors;
}
// If errors is an object, try to extract values
if (typeof result.errors === 'object') {
try {
// Try to flatten nested arrays and join
const errorValues = Object.values(result.errors).flat();
if (errorValues.length > 0) {
return errorValues.join(', ');
}
// Fall back to JSON stringify for complex objects
return JSON.stringify(result.errors);
} catch (e) {
console.error('Error parsing errors object:', e);
return result.message || 'An error occurred';
}
}
// Final fallback
return result.message || 'An error occurred';
}
/**
* Submit add form via AJAX
*/
async submitAddForm(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
try {
const response = await fetch(this.addUrlValue, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const result = await response.json();
if (result.success) {
// Close modal
const modal = bootstrap.Modal.getInstance(this.addModalTarget);
modal.hide();
// Reset form
form.reset();
// Show success message and reload page
this.showFlashMessage('success', result.message);
window.location.reload();
} else {
// Show error message
const errorMsg = this.normalizeErrors(result);
this.showFlashMessage('error', errorMsg);
}
} catch (error) {
console.error('Error submitting form:', error);
this.showFlashMessage('error', 'An error occurred while adding the scheduled activity.');
}
}
/**
* Submit edit form via AJAX
*/
async submitEditForm(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const result = await response.json();
if (result.success) {
// Close modal
const modal = bootstrap.Modal.getInstance(this.editModalTarget);
modal.hide();
// Show success message and reload page
this.showFlashMessage('success', result.message);
window.location.reload();
} else {
// Show error message
const errorMsg = this.normalizeErrors(result);
this.showFlashMessage('error', errorMsg);
}
} catch (error) {
console.error('Error submitting form:', error);
this.showFlashMessage('error', 'An error occurred while updating the scheduled activity.');
}
}
/**
* Show flash message
*/
showFlashMessage(type, message) {
// Create flash message element
const flashContainer = document.querySelector('.flash-messages') || this.createFlashContainer();
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
const flashDiv = document.createElement('div');
flashDiv.className = `alert ${alertClass} alert-dismissible fade show`;
flashDiv.role = 'alert';
// Safely add message text using textContent (prevents XSS)
const messageText = document.createTextNode(message);
flashDiv.appendChild(messageText);
// Create close button separately with proper attributes
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'alert');
closeButton.setAttribute('aria-label', 'Close');
flashDiv.appendChild(closeButton);
flashContainer.appendChild(flashDiv);
// Auto-dismiss after 5 seconds
setTimeout(() => {
flashDiv.remove();
}, 5000);
}
/**
* Create flash message container if it doesn't exist
*/
createFlashContainer() {
const container = document.createElement('div');
container.className = 'flash-messages container mt-3';
const main = document.querySelector('main') || document.body;
main.prepend(container);
return container;
}
}
// Add to global controllers registry
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["gathering-schedule"] = GatheringScheduleController;