import { Controller } from "@hotwired/stimulus"
/**
* Grid View Controller - Simplified Architecture
*
* This controller follows a clean MVC pattern where:
* - Server generates complete state
* - Templates display state
* - Controller captures user actions and navigates
*
* NO state management in JavaScript - server is source of truth.
*
* Supports optional bulk selection when enableBulkSelection is configured.
*/
class GridViewController extends Controller {
static targets = ["gridState", "searchInput", "searchStatusIndicator", "rowCheckbox", "selectAllCheckbox", "bulkActionBtn", "selectionCount"]
static values = {
stickyQuery: String,
stickyDefault: Object
}
/**
* Initialize controller
*/
connect() {
console.log("GridViewController (simplified) connected")
// State will be loaded when frame loads
this.state = null
// Track active filter tab (for UX persistence)
this.activeFilterKey = null
// Initialize bulk selection tracking
this.selectedIds = []
this.searchDebounceTimer = null
this.searchDebounceMs = 900
// Initialize sticky query parameter support
this.stickyParams = {}
if (this.hasStickyDefaultValue && this.stickyDefaultValue) {
this.stickyParams = { ...this.stickyDefaultValue }
}
this.captureStickyParamsFromUrl(window.location.href)
// Bind handler once for use in addEventListener/removeEventListener
this.boundHandleFrameLoad = this.handleFrameLoad.bind(this)
// Listen for Turbo Frame updates
document.addEventListener('turbo:frame-load', this.boundHandleFrameLoad)
// Check if state is already present (inline rendered content)
this.loadInlineState()
this.setSearchBusy(false)
}
/**
* Load state from inline rendered content (when table frame doesn't have src)
* This handles the case where grid content is pre-rendered on initial page load.
*/
loadInlineState() {
// Find the table frame within this controller
const tableFrame = this.element.querySelector('turbo-frame[id$="-table"]')
if (!tableFrame) return
// If the frame has a src attribute and no content yet, it will load via turbo:frame-load
// Only load inline state if there's no src (content is pre-rendered)
if (tableFrame.hasAttribute('src')) return
// Look for the state script tag
const stateScript = tableFrame.querySelector('script[type="application/json"]')
if (!stateScript) return
try {
const stateJson = stateScript.textContent
this.state = JSON.parse(stateJson)
console.log('Grid state loaded from inline content:', this.state)
// Update toolbar UI based on state
this.updateToolbar()
this.setSearchBusy(false)
// Capture sticky parameters for inline-rendered frame content
this.captureStickyParamsFromFrame(tableFrame)
} catch (e) {
console.error('Failed to parse inline grid state:', e)
}
}
/**
* Cleanup when controller disconnects
*/
disconnect() {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer)
this.searchDebounceTimer = null
}
document.removeEventListener('turbo:frame-load', this.boundHandleFrameLoad)
}
/**
* Handle Turbo Frame load - update state from table frame
*/
handleFrameLoad(event) {
// Only handle frames that belong to THIS grid controller
// Check if the event target is inside this controller's element
if (!this.element.contains(event.target)) {
return
}
// Check if this is a table frame load (ends with -table)
if (event.target.id && event.target.id.endsWith('-table')) {
// Direct table frame load - get state from its script tag
const tableFrame = event.target
const stateScript = tableFrame.querySelector('script[type="application/json"]')
if (!stateScript) {
console.warn('No state script found in table frame')
return
}
try {
const stateJson = stateScript.textContent
this.state = JSON.parse(stateJson)
console.log('Grid state updated from table frame:', this.state)
// Update toolbar UI based on new state
this.updateToolbar()
this.setSearchBusy(false)
// Clear bulk selection when table data changes (pagination, filter, sort)
this.clearBulkSelection()
// Capture sticky parameters based on the loaded frame
this.captureStickyParamsFromFrame(tableFrame)
} catch (e) {
console.error('Failed to parse grid state from table frame:', e)
this.setSearchBusy(false)
}
} else {
// Outer grid frame loaded - check if it contains an inline table frame with state
// This happens when dv_grid_content renders with inline data (no nested src)
this.loadInlineState()
}
}
/**
* Update toolbar UI based on current state
*/
updateToolbar() {
// Update all UI elements from state
this.updateViewTabs()
this.updateFilterPills()
this.updateSearchDisplay()
this.updateFilterCount()
this.updateFilterDropdownCheckboxes()
this.updateFilterNavigation()
this.updateFilterPanels()
this.updateClearFiltersFooter()
this.updateColumnPicker()
}
/**
* Toggle search busy indicator in the filter search input.
*/
setSearchBusy(isBusy) {
if (this.hasSearchStatusIndicatorTarget) {
this.searchStatusIndicatorTarget.classList.toggle('d-none', !isBusy)
}
if (this.hasSearchInputTarget) {
this.searchInputTarget.setAttribute('aria-busy', isBusy ? 'true' : 'false')
}
}
/**
* Update filter pill display
*/
updateFilterPills() {
const container = this.element.querySelector('[data-filter-pills-container]')
if (!container) return
// Clear existing pills
container.innerHTML = ''
// If no filters, hide container
if (!this.state.filters.active || Object.keys(this.state.filters.active).length === 0) {
//container.classList.add('d-none')
return
}
container.classList.remove('d-none')
// Get OR grouping information from state
const orGroups = this.state.filters.grouping?.orGroups || []
// Get locked filters from config
const lockedFilters = this.state.config?.lockedFilters || []
// Build map of field -> group index for quick lookup
const fieldToGroup = new Map()
orGroups.forEach((group, groupIndex) => {
group.forEach(field => {
fieldToGroup.set(field, groupIndex)
})
})
// Organize filters by OR groups and multi-value filters
const groupedFilters = new Map() // groupIndex -> array of {column, value} objects
const ungroupedFilters = [] // Filters not in any OR group
let nextAutoGroupIndex = orGroups.length // Start auto-group indices after explicit OR groups
Object.entries(this.state.filters.active).forEach(([column, values]) => {
const valueArray = Array.isArray(values) ? values : [values]
// Check direct match or date range base field match (e.g., expires_on_end matches expires_on)
let groupIndex = null
if (fieldToGroup.has(column)) {
groupIndex = fieldToGroup.get(column)
} else {
// Try matching date range suffixes (_start, _end) to base field
const baseField = column.replace(/_(start|end)$/, '')
if (baseField !== column && fieldToGroup.has(baseField)) {
groupIndex = fieldToGroup.get(baseField)
}
}
if (groupIndex !== null) {
// This filter is part of an explicit expression-based OR group
if (!groupedFilters.has(groupIndex)) {
groupedFilters.set(groupIndex, [])
}
valueArray.forEach(value => {
groupedFilters.get(groupIndex).push({ column, value })
})
} else if (valueArray.length > 1) {
// Multiple values for same field (IN clause) - create implicit OR group
const autoGroupIndex = `auto-${nextAutoGroupIndex++}`
groupedFilters.set(autoGroupIndex, [])
valueArray.forEach(value => {
groupedFilters.get(autoGroupIndex).push({ column, value })
})
} else {
// Single value, not part of OR group
ungroupedFilters.push({ column, value: valueArray[0] })
}
})
// Render ungrouped filters first
ungroupedFilters.forEach(({ column, value }) => {
const isLocked = this.isFilterLocked(column, lockedFilters)
const pill = this.createFilterPill(column, value, isLocked)
container.appendChild(pill)
})
// Render OR groups with visual indicators
groupedFilters.forEach((filters, groupIndex) => {
if (filters.length === 0) return
// Create a wrapper for the OR group
const groupWrapper = document.createElement('div')
groupWrapper.className = 'd-inline-flex align-items-center gap-1'
groupWrapper.style.cssText = 'padding: 2px 6px; border-radius: 6px; background-color: rgba(13, 110, 253, 0.08); border: 1px dashed rgba(13, 110, 253, 0.3);'
groupWrapper.setAttribute('data-or-group', groupIndex)
filters.forEach((filterData, index) => {
const isLocked = this.isFilterLocked(filterData.column, lockedFilters)
const pill = this.createFilterPill(filterData.column, filterData.value, isLocked)
groupWrapper.appendChild(pill)
// Add OR indicator between pills (but not after the last one)
if (index < filters.length - 1) {
const orIndicator = document.createElement('span')
orIndicator.className = 'text-primary fw-bold px-1'
orIndicator.style.cssText = 'font-size: 0.65rem; letter-spacing: 0.5px;'
orIndicator.textContent = 'OR'
orIndicator.setAttribute('title', 'These filters are combined with OR logic - any one can match')
groupWrapper.appendChild(orIndicator)
}
})
container.appendChild(groupWrapper)
})
}
/**
* Create a filter pill element
*
* @param {string} column - The filter column key
* @param {string} value - The filter value
* @param {boolean} isLocked - Whether this filter is locked (cannot be removed)
*/
createFilterPill(column, value, isLocked = false) {
// Match the exact styling from grid_view_toolbar.php
const badge = document.createElement('span')
badge.className = 'badge d-inline-flex align-items-center gap-1 pe-1'
badge.style.cssText = 'background-color: #f6f6f7; color: #202223; border: 1px solid #c9cccf; font-weight: 500; font-size: 0.75rem; padding: 0.25rem 0.4rem 0.25rem 0.5rem; border-radius: 0.4rem;'
badge.setAttribute('data-filter-badge', '')
if (isLocked) {
badge.setAttribute('data-filter-locked', 'true')
}
// Get the label for this value from filters metadata
const valueLabel = this.getFilterValueLabel(column, value)
const columnLabel = this.getFilterColumnLabel(column)
const textSpan = document.createElement('span')
textSpan.innerHTML = `${columnLabel}: <strong>${this.escapeHtml(valueLabel)}</strong>`
badge.appendChild(textSpan)
// Only add remove button if filter is not locked
if (!isLocked) {
const removeBtn = document.createElement('button')
removeBtn.type = 'button'
removeBtn.className = 'btn btn-link p-0 m-0 text-decoration-none d-flex align-items-center justify-content-center'
removeBtn.style.cssText = 'width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,0.1); color: #202223; font-size: 0.7rem; line-height: 1;'
removeBtn.setAttribute('aria-label', 'Remove filter')
removeBtn.setAttribute('data-action', 'click->grid-view#removeFilter')
removeBtn.setAttribute('data-filter-column', column)
removeBtn.setAttribute('data-filter-value', value)
const icon = document.createElement('i')
icon.className = 'bi bi-x'
icon.style.cssText = 'font-size: 0.9rem; font-weight: bold;'
removeBtn.appendChild(icon)
badge.appendChild(removeBtn)
} else {
// For locked filters, add a lock icon instead
const lockIcon = document.createElement('i')
lockIcon.className = 'bi bi-lock-fill ms-1'
lockIcon.style.cssText = 'font-size: 0.65rem; opacity: 0.5;'
lockIcon.setAttribute('title', 'This filter cannot be removed')
badge.appendChild(lockIcon)
}
return badge
}
/**
* Get filter column label from metadata
*/
getFilterColumnLabel(column) {
if (this.state.filters.available && this.state.filters.available[column]) {
return this.state.filters.available[column].label
}
return this.formatColumnName(column)
}
/**
* Get filter value label from metadata
*/
getFilterValueLabel(column, value) {
if (this.state.filters.available && this.state.filters.available[column]) {
const filterMeta = this.state.filters.available[column]
// For date range filters, just return the value (it's already a formatted date)
if (filterMeta.type === 'date-range-start' || filterMeta.type === 'date-range-end') {
return value
}
// For dropdown filters, look up the option label
const options = filterMeta.options
if (options) {
const option = options.find(opt => opt.value === value)
if (option) {
return option.label
}
}
}
return value
}
/**
* Escape HTML for safe insertion
*/
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
/**
* Format column name for display
*/
formatColumnName(column) {
// Simple title case formatting
return column.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
/**
* Update search display
*/
updateSearchDisplay() {
const container = this.element.querySelector('[data-filter-pills-container]')
if (!container) return
// Update search input value
if (this.hasSearchInputTarget) {
this.searchInputTarget.value = this.state.search || ''
}
// Remove existing search badge if present
const existingSearchBadge = container.querySelector('[data-search-badge]')
if (existingSearchBadge) {
existingSearchBadge.remove()
}
// If search is active, create and insert search badge as first element
if (this.state.search) {
const searchBadge = this.createSearchBadge(this.state.search)
container.insertBefore(searchBadge, container.firstChild)
}
}
/**
* Create a search badge element
*/
createSearchBadge(searchTerm) {
// Match the exact styling from grid_view_toolbar.php
const badge = document.createElement('span')
badge.className = 'badge d-inline-flex align-items-center gap-1 pe-1'
badge.style.cssText = 'background-color: #f6f6f7; color: #202223; border: 1px solid #c9cccf; font-weight: 500; font-size: 0.75rem; padding: 0.25rem 0.4rem 0.25rem 0.5rem; border-radius: 0.4rem;'
badge.setAttribute('data-search-badge', '')
const textSpan = document.createElement('span')
textSpan.innerHTML = `Search: <strong data-search-text>${this.escapeHtml(searchTerm)}</strong>`
badge.appendChild(textSpan)
const removeBtn = document.createElement('button')
removeBtn.type = 'button'
removeBtn.className = 'btn btn-link p-0 m-0 text-decoration-none d-flex align-items-center justify-content-center'
removeBtn.style.cssText = 'width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,0.1); color: #202223; font-size: 0.7rem; line-height: 1;'
removeBtn.setAttribute('aria-label', 'Remove search')
removeBtn.setAttribute('data-action', 'click->grid-view#clearSearch')
const icon = document.createElement('i')
icon.className = 'bi bi-x'
icon.style.cssText = 'font-size: 0.9rem; font-weight: bold;'
removeBtn.appendChild(icon)
badge.appendChild(removeBtn)
return badge
}
/**
* Update filter count badge
*/
updateFilterCount() {
// Find the filter button (it has a Filter icon and text)
const filterButton = Array.from(this.element.querySelectorAll('button'))
.find(btn => btn.textContent.includes('Filter') && btn.querySelector('.bi-funnel'))
if (!filterButton) return
// Calculate active filter count (matching PHP logic)
let activeCount = 0
// Count search as 1 filter if present
if (this.state.search) {
activeCount++
}
// Count each active filter value
if (this.state.filters && this.state.filters.active) {
Object.values(this.state.filters.active).forEach(values => {
activeCount += Array.isArray(values) ? values.length : 1
})
}
// Find or create badge
let badge = filterButton.querySelector('.badge')
if (activeCount > 0) {
if (!badge) {
// Create badge if it doesn't exist
badge = document.createElement('span')
badge.className = 'badge bg-primary rounded-circle'
filterButton.appendChild(badge)
}
badge.textContent = activeCount
} else {
// Remove badge if count is 0
if (badge) {
badge.remove()
}
}
}
/**
* Update filter dropdown checkboxes to match current state
* (This is now mostly redundant since we rebuild the panels,
* but keeping it for any dynamic checkbox updates)
*/
updateFilterDropdownCheckboxes() {
if (!this.state.filters || !this.state.filters.active) return
// Get all filter checkboxes and sync their state
const checkboxes = this.element.querySelectorAll('[data-filter-column][type="checkbox"]')
checkboxes.forEach(checkbox => {
const column = checkbox.dataset.filterColumn
const value = checkbox.value
// Check if this filter value is active in the current state
const activeValues = this.state.filters.active[column]
let isActive = false
if (activeValues !== undefined) {
if (Array.isArray(activeValues)) {
isActive = activeValues.includes(value)
} else {
isActive = activeValues === value
}
}
checkbox.checked = isActive
})
}
/**
* Update view tabs from state
*/
updateViewTabs() {
const container = this.element.querySelector('[data-view-tabs-container]')
if (!container) return
// Check if we should show "All" tab (marker present means DON'T show it)
const showAllTab = !this.element.querySelector('[data-no-all-tab]')
// Find the "Create View" button to preserve it
const createViewBtn = container.querySelector('[data-action*="saveView"]')?.closest('li')
// Find the marker element to preserve it
const markerElement = container.querySelector('[data-no-all-tab]')?.closest('li')
// Clear existing tabs (except create button and marker)
container.querySelectorAll('li').forEach(li => {
if (li !== createViewBtn && li !== markerElement) {
li.remove()
}
})
// Add "All" tab if enabled
if (showAllTab) {
const allTab = this.createViewTab('All', null, !this.state.view.currentId, false, false)
container.insertBefore(allTab, createViewBtn)
}
// Add user views
if (this.state.view.available && Array.isArray(this.state.view.available)) {
this.state.view.available.forEach(view => {
const isActive = (this.state.view.currentId == view.id)
const isPreferred = view.isPreferred || view.isUserDefault || false
const canManage = view.canManage !== false
const count = view.count || null
const viewTab = this.createViewTab(view.name, view.id, isActive, isPreferred, canManage, count)
container.insertBefore(viewTab, createViewBtn)
})
}
}
/**
* Create a view tab element
* @param {string} name Tab name
* @param {string|null} viewId View ID (null for 'All' tab)
* @param {boolean} isActive Is this the active tab
* @param {boolean} isPreferred Is this the preferred (default) view
* @param {boolean} canManage Can this view be managed
* @param {number|null} count Optional count for badge display
*/
createViewTab(name, viewId, isActive, isPreferred, canManage, count = null) {
const li = document.createElement('li')
li.className = 'nav-item'
li.setAttribute('role', 'presentation')
if (isActive && canManage) {
// Active tab with dropdown
const btnGroup = document.createElement('div')
btnGroup.className = 'btn-group'
btnGroup.setAttribute('role', 'group')
btnGroup.style.marginBottom = '-1px'
const mainBtn = document.createElement('button')
mainBtn.type = 'button'
mainBtn.className = 'nav-link active split-tab-left'
mainBtn.setAttribute('role', 'tab')
mainBtn.setAttribute('aria-selected', 'true')
if (viewId) {
mainBtn.setAttribute('data-action', 'click->grid-view#switchView')
mainBtn.setAttribute('data-view-id', viewId)
} else {
mainBtn.setAttribute('data-action', 'click->grid-view#showAll')
}
mainBtn.textContent = name
if (count !== null) {
mainBtn.appendChild(document.createTextNode(' '))
const badge = document.createElement('span')
badge.className = 'badge bg-secondary ms-1'
badge.textContent = count
mainBtn.appendChild(badge)
}
if (isPreferred) {
const star = document.createElement('i')
star.className = 'bi bi-star-fill text-warning'
star.style.fontSize = '0.75rem'
mainBtn.appendChild(document.createTextNode(' '))
mainBtn.appendChild(star)
}
const dropdownBtn = document.createElement('button')
dropdownBtn.type = 'button'
dropdownBtn.className = 'nav-link active dropdown-toggle dropdown-toggle-split split-tab-right'
dropdownBtn.setAttribute('data-bs-toggle', 'dropdown')
dropdownBtn.setAttribute('aria-expanded', 'false')
dropdownBtn.style.cssText = 'padding-left: 5px; padding-right: 5px;'
dropdownBtn.innerHTML = '<span class="visually-hidden">Toggle Dropdown</span>'
const dropdownMenu = document.createElement('ul')
dropdownMenu.className = 'dropdown-menu'
if (viewId && canManage) {
// Update View
const updateItem = document.createElement('li')
updateItem.innerHTML = `
<button type="button" class="dropdown-item" data-action="click->grid-view#updateView">
<i class="bi bi-arrow-repeat me-2"></i> Update View
</button>
`
dropdownMenu.appendChild(updateItem)
}
// Set/Clear Default
const defaultItem = document.createElement('li')
if (isPreferred) {
defaultItem.innerHTML = `
<button type="button" class="dropdown-item" data-action="click->grid-view#clearDefault">
<i class="bi bi-star-fill me-2"></i> Remove as Default
</button>
`
} else {
defaultItem.innerHTML = `
<button type="button" class="dropdown-item" data-action="click->grid-view#setDefault">
<i class="bi bi-star me-2"></i> Set as Default
</button>
`
}
dropdownMenu.appendChild(defaultItem)
if (viewId && canManage) {
// Divider
const divider = document.createElement('li')
divider.innerHTML = '<hr class="dropdown-divider">'
dropdownMenu.appendChild(divider)
// Delete View
const deleteItem = document.createElement('li')
deleteItem.innerHTML = `
<button type="button" class="dropdown-item text-danger" data-action="click->grid-view#deleteView">
<i class="bi bi-trash me-2"></i> Delete View
</button>
`
dropdownMenu.appendChild(deleteItem)
}
btnGroup.appendChild(mainBtn)
btnGroup.appendChild(dropdownBtn)
btnGroup.appendChild(dropdownMenu)
li.appendChild(btnGroup)
} else if (isActive && !canManage) {
// Active system view with limited dropdown
const btnGroup = document.createElement('div')
btnGroup.className = 'btn-group'
btnGroup.setAttribute('role', 'group')
btnGroup.style.marginBottom = '-1px'
const mainBtn = document.createElement('button')
mainBtn.type = 'button'
mainBtn.className = 'nav-link active split-tab-left'
mainBtn.setAttribute('role', 'tab')
mainBtn.setAttribute('aria-selected', 'true')
mainBtn.textContent = name
if (count !== null) {
mainBtn.appendChild(document.createTextNode(' '))
const badge = document.createElement('span')
badge.className = 'badge bg-secondary ms-1'
badge.textContent = count
mainBtn.appendChild(badge)
}
if (isPreferred) {
const star = document.createElement('i')
star.className = 'bi bi-star-fill text-warning'
star.style.fontSize = '0.75rem'
mainBtn.appendChild(document.createTextNode(' '))
mainBtn.appendChild(star)
}
const dropdownBtn = document.createElement('button')
dropdownBtn.type = 'button'
dropdownBtn.className = 'nav-link active dropdown-toggle dropdown-toggle-split split-tab-right'
dropdownBtn.setAttribute('data-bs-toggle', 'dropdown')
dropdownBtn.setAttribute('aria-expanded', 'false')
dropdownBtn.style.cssText = 'padding-left: 5px; padding-right: 5px;'
dropdownBtn.innerHTML = '<span class="visually-hidden">Toggle Dropdown</span>'
const dropdownMenu = document.createElement('ul')
dropdownMenu.className = 'dropdown-menu'
const defaultItem = document.createElement('li')
if (isPreferred) {
defaultItem.innerHTML = `
<button type="button" class="dropdown-item" data-action="click->grid-view#clearDefault">
<i class="bi bi-star-fill me-2"></i> Remove as Default
</button>
`
} else {
defaultItem.innerHTML = `
<button type="button" class="dropdown-item" data-action="click->grid-view#setDefault">
<i class="bi bi-star me-2"></i> Set as Default
</button>
`
}
dropdownMenu.appendChild(defaultItem)
btnGroup.appendChild(mainBtn)
btnGroup.appendChild(dropdownBtn)
btnGroup.appendChild(dropdownMenu)
li.appendChild(btnGroup)
} else {
// Inactive tab
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'nav-link'
btn.setAttribute('role', 'tab')
btn.setAttribute('aria-selected', 'false')
if (viewId) {
btn.setAttribute('data-action', 'click->grid-view#switchView')
btn.setAttribute('data-view-id', viewId)
} else {
btn.setAttribute('data-action', 'click->grid-view#showAll')
}
btn.textContent = name
if (count !== null) {
btn.appendChild(document.createTextNode(' '))
const badge = document.createElement('span')
badge.className = 'badge bg-secondary ms-1'
badge.textContent = count
btn.appendChild(badge)
}
if (isPreferred) {
const star = document.createElement('i')
star.className = 'bi bi-star-fill text-warning'
star.style.fontSize = '0.75rem'
btn.appendChild(document.createTextNode(' '))
btn.appendChild(star)
}
li.appendChild(btn)
}
return li
}
/**
* Update filter navigation (left side filter tabs)
*/
updateFilterNavigation() {
const container = this.element.querySelector('[data-filter-nav-container]')
if (!container) return
container.innerHTML = ''
if (!this.state.filters.available) return
// Group date range filters by base field
const filterGroups = new Map()
const standaloneFilters = []
Object.entries(this.state.filters.available).forEach(([key, meta]) => {
if (meta.type === 'date-range-start' || meta.type === 'date-range-end') {
const baseField = meta.baseField || key.replace(/_start$|_end$/, '')
if (!filterGroups.has(baseField)) {
filterGroups.set(baseField, {
baseField,
label: meta.label.replace(' (after)', '').replace(' (before)', ''),
filters: []
})
}
filterGroups.get(baseField).filters.push({ key, meta })
} else {
standaloneFilters.push({ key, meta })
}
})
// Build array of all filters (standalone + grouped date ranges)
const allFilterItems = [
...standaloneFilters.map(({ key, meta }) => ({ key, label: meta.label, type: 'dropdown', meta })),
...Array.from(filterGroups.values()).map(group => ({
key: group.baseField,
label: group.label,
type: 'date-range',
group
}))
]
if (allFilterItems.length === 0) return
// Determine which filter should be active (preserve user's selection or default to first)
const firstFilterKey = allFilterItems[0].key
const activeKey = this.activeFilterKey && allFilterItems.some(item => item.key === this.activeFilterKey)
? this.activeFilterKey
: firstFilterKey
// Update activeFilterKey to the determined value
this.activeFilterKey = activeKey
allFilterItems.forEach((item) => {
let activeCount = 0
if (item.type === 'date-range') {
// Count active date range filters
item.group.filters.forEach(({ key }) => {
if (this.state.filters.active[key]) activeCount++
})
} else {
// Count active dropdown filters
const activeValues = this.state.filters.active?.[item.key] || []
const activeArray = Array.isArray(activeValues) ? activeValues : [activeValues]
activeCount = activeArray.filter(v => v !== null && v !== undefined && v !== '').length
}
const button = document.createElement('button')
button.type = 'button'
button.className = `list-group-item list-group-item-action d-flex justify-content-between align-items-center${item.key === activeKey ? ' active' : ''}`
button.setAttribute('data-filter-key', item.key)
button.setAttribute('data-filter-type', item.type)
button.setAttribute('data-filter-nav-item', '')
button.setAttribute('data-action', 'click->grid-view#selectFilter')
const label = document.createElement('span')
label.textContent = item.label
button.appendChild(label)
if (activeCount > 0) {
const badge = document.createElement('span')
badge.className = 'badge bg-primary rounded-pill'
badge.textContent = activeCount
button.appendChild(badge)
}
container.appendChild(button)
})
}
/**
* Update filter panels (right side filter options)
*/
updateFilterPanels() {
const container = this.element.querySelector('[data-filter-panels-container]')
if (!container) return
container.innerHTML = ''
if (!this.state.filters.available) return
// Get locked filters from config
const lockedFilters = this.state.config?.lockedFilters || []
// Group date range filters by base field (same logic as navigation)
const filterGroups = new Map()
const standaloneFilters = []
Object.entries(this.state.filters.available).forEach(([key, meta]) => {
if (meta.type === 'date-range-start' || meta.type === 'date-range-end') {
const baseField = meta.baseField || key.replace(/_start$|_end$/, '')
if (!filterGroups.has(baseField)) {
filterGroups.set(baseField, {
baseField,
label: meta.label.replace(' (after)', '').replace(' (before)', ''),
filters: []
})
}
filterGroups.get(baseField).filters.push({ key, meta })
} else {
standaloneFilters.push({ key, meta })
}
})
// Build array of all filters (standalone + grouped date ranges)
const allFilterItems = [
...standaloneFilters.map(({ key, meta }) => ({ key, label: meta.label, type: 'dropdown', meta })),
...Array.from(filterGroups.values()).map(group => ({
key: group.baseField,
label: group.label,
type: 'date-range',
group
}))
]
if (allFilterItems.length === 0) return
// Use the same active key logic as navigation
const firstFilterKey = allFilterItems[0].key
const activeKey = this.activeFilterKey && allFilterItems.some(item => item.key === this.activeFilterKey)
? this.activeFilterKey
: firstFilterKey
allFilterItems.forEach((item) => {
const panel = document.createElement('div')
panel.className = item.key === activeKey ? '' : 'd-none'
panel.setAttribute('data-filter-key', item.key)
panel.setAttribute('data-filter-panel', '')
const innerDiv = document.createElement('div')
innerDiv.className = 'px-3 py-3 border-bottom'
if (item.type === 'date-range') {
// Render date range panel with From/To inputs
const startFilter = item.group.filters.find(f => f.meta.type === 'date-range-start')
const endFilter = item.group.filters.find(f => f.meta.type === 'date-range-end')
const startValue = startFilter ? (this.state.filters.active[startFilter.key] || '') : ''
const endValue = endFilter ? (this.state.filters.active[endFilter.key] || '') : ''
const activeCount = (startValue ? 1 : 0) + (endValue ? 1 : 0)
// Check if this date range filter is locked
const isLocked = this.isFilterLocked(item.key, lockedFilters)
const headerDiv = document.createElement('div')
headerDiv.className = 'd-flex justify-content-between align-items-center mb-1'
const title = document.createElement('strong')
title.textContent = item.label
if (isLocked) {
const lockIcon = document.createElement('i')
lockIcon.className = 'bi bi-lock-fill ms-2'
lockIcon.style.cssText = 'font-size: 0.75rem; opacity: 0.5;'
lockIcon.setAttribute('title', 'This filter is locked and cannot be changed')
title.appendChild(lockIcon)
}
headerDiv.appendChild(title)
if (activeCount > 0) {
const countText = document.createElement('small')
countText.className = 'text-muted'
countText.textContent = `${activeCount} selected`
headerDiv.appendChild(countText)
}
innerDiv.appendChild(headerDiv)
const helpText = document.createElement('div')
helpText.className = 'text-muted small mb-3'
helpText.textContent = isLocked ? 'This filter is locked' : 'Select date range'
innerDiv.appendChild(helpText)
// Create row for From/To inputs
const row = document.createElement('div')
row.className = 'row g-2'
// From date
if (startFilter) {
const fromCol = document.createElement('div')
fromCol.className = 'col-12'
const fromLabel = document.createElement('label')
fromLabel.className = 'form-label small text-muted'
fromLabel.textContent = 'From'
fromCol.appendChild(fromLabel)
const fromInput = document.createElement('input')
fromInput.type = 'date'
fromInput.className = 'form-control'
fromInput.value = startValue
fromInput.setAttribute('data-filter-column', startFilter.key)
if (isLocked) {
fromInput.disabled = true
fromInput.setAttribute('title', 'This filter is locked and cannot be changed')
} else {
fromInput.setAttribute('data-action', 'change->grid-view#updateDateRangeFilter')
}
fromCol.appendChild(fromInput)
row.appendChild(fromCol)
}
// To date
if (endFilter) {
const toCol = document.createElement('div')
toCol.className = 'col-12'
const toLabel = document.createElement('label')
toLabel.className = 'form-label small text-muted'
toLabel.textContent = 'To'
toCol.appendChild(toLabel)
const toInput = document.createElement('input')
toInput.type = 'date'
toInput.className = 'form-control'
toInput.value = endValue
toInput.setAttribute('data-filter-column', endFilter.key)
if (isLocked) {
toInput.disabled = true
toInput.setAttribute('title', 'This filter is locked and cannot be changed')
} else {
toInput.setAttribute('data-action', 'change->grid-view#updateDateRangeFilter')
}
toCol.appendChild(toInput)
row.appendChild(toCol)
}
innerDiv.appendChild(row)
} else {
// Render dropdown panel with checkboxes
const activeValues = this.state.filters.active?.[item.key] || []
const activeArray = Array.isArray(activeValues) ? activeValues : [activeValues]
const activeFiltered = activeArray.filter(v => v !== null && v !== undefined && v !== '')
const activeCount = activeFiltered.length
// Check if this filter is locked
const isLocked = this.isFilterLocked(item.key, lockedFilters)
const headerDiv = document.createElement('div')
headerDiv.className = 'd-flex justify-content-between align-items-center mb-1'
const title = document.createElement('strong')
title.textContent = item.label
if (isLocked) {
const lockIcon = document.createElement('i')
lockIcon.className = 'bi bi-lock-fill ms-2'
lockIcon.style.cssText = 'font-size: 0.75rem; opacity: 0.5;'
lockIcon.setAttribute('title', 'This filter is locked and cannot be changed')
title.appendChild(lockIcon)
}
headerDiv.appendChild(title)
if (activeCount > 0) {
const countText = document.createElement('small')
countText.className = 'text-muted'
countText.textContent = `${activeCount} selected`
headerDiv.appendChild(countText)
}
innerDiv.appendChild(headerDiv)
const helpText = document.createElement('div')
helpText.className = 'text-muted small mb-2'
helpText.textContent = isLocked ? 'This filter is locked' : 'Choose one or more options'
innerDiv.appendChild(helpText)
// Add checkboxes for each option
item.meta.options.forEach(option => {
const isChecked = activeFiltered.includes(option.value)
const formCheck = document.createElement('div')
formCheck.className = 'form-check mb-1'
const checkbox = document.createElement('input')
checkbox.className = 'form-check-input'
checkbox.type = 'checkbox'
checkbox.id = `filter_${item.key}_${option.value}`
checkbox.value = option.value
checkbox.checked = isChecked
checkbox.setAttribute('data-filter-column', item.key)
if (isLocked) {
checkbox.disabled = true
checkbox.setAttribute('title', 'This filter is locked and cannot be changed')
} else {
checkbox.setAttribute('data-action', 'change->grid-view#toggleFilter')
}
const label = document.createElement('label')
label.className = 'form-check-label'
if (isLocked) {
label.classList.add('text-muted')
}
label.htmlFor = checkbox.id
label.textContent = option.label
formCheck.appendChild(checkbox)
formCheck.appendChild(label)
innerDiv.appendChild(formCheck)
})
}
panel.appendChild(innerDiv)
container.appendChild(panel)
})
}
/**
* Update clear filters footer visibility
*/
updateClearFiltersFooter() {
const container = this.element.querySelector('[data-clear-filters-container]')
if (!container) return
const hasSearch = this.state.search && this.state.search.trim() !== ''
const hasFilters = this.state.filters.active && Object.keys(this.state.filters.active).length > 0
if (hasSearch || hasFilters) {
container.style.display = ''
} else {
container.style.display = 'none'
}
}
/**
* Update column picker modal
*/
updateColumnPicker() {
const container = this.element.querySelector('[data-column-list-container]')
if (!container) return
container.innerHTML = ''
if (!this.state.columns || !this.state.columns.all) return
// Normalize columns.visible to array (handle both array and object formats from PHP)
const visibleColumns = Array.isArray(this.state.columns.visible)
? this.state.columns.visible
: Object.values(this.state.columns.visible)
// Build ordered list: visible first, then remaining
const orderedColumns = []
const orderedKeys = []
// Add visible columns first
visibleColumns.forEach(key => {
if (this.state.columns.all[key]) {
orderedColumns.push({ key, ...this.state.columns.all[key] })
orderedKeys.push(key)
}
})
// Add remaining columns
Object.entries(this.state.columns.all).forEach(([key, column]) => {
if (!orderedKeys.includes(key)) {
orderedColumns.push({ key, ...column })
}
})
// Create list items
orderedColumns.forEach(column => {
// Skip export-only columns - they shouldn't appear in the column picker
if (column.exportOnly) return
const isVisible = visibleColumns.includes(column.key)
const isRequired = column.required || false
const label = document.createElement('label')
label.className = `list-group-item d-flex align-items-center${!isVisible ? ' list-group-item-secondary' : ''}`
label.setAttribute('data-sortable-list-target', 'item')
label.setAttribute('data-column-key', column.key)
if (isRequired) {
label.setAttribute('data-column-required', 'true')
}
label.draggable = true
label.setAttribute('data-action', `dragstart->sortable-list#dragStart
dragover->sortable-list#dragOver
dragenter->sortable-list#dragEnter
dragleave->sortable-list#dragLeave
drop->sortable-list#drop
dragend->sortable-list#dragEnd`)
// Drag handle
const dragHandle = document.createElement('span')
dragHandle.className = 'drag-handle me-2'
dragHandle.style.cursor = 'move'
dragHandle.title = 'Drag to reorder'
dragHandle.innerHTML = '<i class="bi bi-grip-vertical"></i>'
label.appendChild(dragHandle)
// Checkbox
const checkbox = document.createElement('input')
checkbox.className = 'form-check-input me-2'
checkbox.type = 'checkbox'
checkbox.value = column.key
checkbox.checked = isVisible
if (isRequired) {
checkbox.disabled = true
}
checkbox.setAttribute('data-action', 'change->grid-view#toggleColumn')
checkbox.setAttribute('data-column-key', column.key)
label.appendChild(checkbox)
// Label content
const contentDiv = document.createElement('div')
contentDiv.className = 'flex-grow-1'
const columnLabel = column.label && column.label.trim() !== '' ? column.label : column.key
const strong = document.createElement('strong')
strong.textContent = columnLabel
contentDiv.appendChild(strong)
if (isRequired) {
const requiredText = document.createElement('small')
requiredText.className = 'text-muted ms-1'
requiredText.textContent = '(Required)'
contentDiv.appendChild(requiredText)
}
if (column.description) {
contentDiv.appendChild(document.createElement('br'))
const desc = document.createElement('small')
desc.className = 'text-muted'
desc.textContent = column.description
contentDiv.appendChild(desc)
}
label.appendChild(contentDiv)
container.appendChild(label)
})
}
// ============================================================================
// View Actions
// ============================================================================
/**
* Show all records (clear view selection)
*/
showAll() {
// When showing all, clear everything except ignore_default
const url = new URL(window.location.href)
url.search = '' // Clear all query parameters
url.searchParams.set('ignore_default', '1')
this.navigate(url.pathname + url.search) // Table frame nav
}
/**
* Switch to a specific view
*/
switchView(event) {
const viewId = event.currentTarget.dataset.viewId
if (!viewId) return
// When switching to a view, clear everything except the view_id
const url = new URL(window.location.href)
url.search = '' // Clear all query parameters
url.searchParams.set('view_id', viewId)
this.navigate(url.pathname + url.search) // Table frame nav
}
/**
* Save current state as new view
*/
async saveView() {
const name = prompt("Enter a name for this view:")
if (!name || name.trim() === "") return
const config = this.getCurrentConfig()
try {
const response = await fetch(`/grid-views/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": this.getCsrfToken(),
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
body: JSON.stringify({
gridKey: this.state.config.gridKey,
name: name.trim(),
config: config
})
})
const data = await response.json()
if (response.ok && data.success) {
alert("View saved successfully")
// Navigate to the new view
const url = this.buildUrl({ view_id: data.data.view.id })
window.location.assign(url)
} else {
throw new Error(data.error || "Failed to save view")
}
} catch (error) {
console.error("Error saving view:", error)
alert("Failed to save view: " + error.message)
}
}
/**
* Update existing view with current state
*/
async updateView() {
if (!this.state.view.currentId) {
alert("No view selected to update")
return
}
if (!confirm("Update this view with current settings?")) return
const config = this.getCurrentConfig()
try {
const response = await fetch(`/grid-views/edit/${this.state.view.currentId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": this.getCsrfToken(),
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
body: JSON.stringify({ config: config })
})
const data = await response.json()
if (response.ok && data.success) {
alert("View updated successfully")
window.location.reload()
} else {
throw new Error(data.error || "Failed to update view")
}
} catch (error) {
console.error("Error updating view:", error)
alert("Failed to update view: " + error.message)
}
}
/**
* Delete current view
*/
async deleteView() {
if (!this.state.view.currentId) {
alert("No view selected to delete")
return
}
if (!confirm("Are you sure you want to delete this view?")) return
try {
const response = await fetch(`/grid-views/delete/${this.state.view.currentId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": this.getCsrfToken(),
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
body: JSON.stringify({ gridKey: this.state.config.gridKey })
})
const data = await response.json()
if (response.ok && data.success) {
alert("View deleted successfully")
const url = this.buildUrl({ view_id: null })
window.location.assign(url)
} else {
throw new Error(data.error || "Failed to delete view")
}
} catch (error) {
console.error("Error deleting view:", error)
alert("Failed to delete view: " + error.message)
}
}
/**
* Set current view as user default
*/
async setDefault() {
if (!this.state.view.currentId) {
alert("No view selected to set as default")
return
}
try {
const response = await fetch(`/grid-views/set-default/${this.state.view.currentId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": this.getCsrfToken(),
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
body: JSON.stringify({ gridKey: this.state.config.gridKey })
})
const data = await response.json()
if (response.ok && data.success) {
alert("Default view set successfully")
window.location.reload()
} else {
throw new Error(data.error || "Failed to set default")
}
} catch (error) {
console.error("Error setting default:", error)
alert("Failed to set default: " + error.message)
}
}
/**
* Clear user default view
*/
async clearDefault() {
try {
const response = await fetch(`/grid-views/clear-default`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": this.getCsrfToken(),
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
body: JSON.stringify({ gridKey: this.state.config.gridKey })
})
const data = await response.json()
if (response.ok && data.success) {
alert("Default view cleared successfully")
window.location.reload()
} else {
throw new Error(data.error || "Failed to clear default")
}
} catch (error) {
console.error("Error clearing default:", error)
alert("Failed to clear default: " + error.message)
}
}
// ============================================================================
// Search Actions
// ============================================================================
/**
* Handle search input keyup with debouncing
*/
handleSearchKeyup(event) {
if (event.key === 'Enter') {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer)
this.searchDebounceTimer = null
}
return // Handled separately
}
// Ignore modifier/navigation keys that don't change input value
if (
event.key === 'Shift' ||
event.key === 'Control' ||
event.key === 'Alt' ||
event.key === 'Meta' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'Tab' ||
event.key === 'Escape'
) {
return
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer)
}
this.searchDebounceTimer = setTimeout(() => {
this.performSearch()
}, this.searchDebounceMs)
}
/**
* Perform search
*/
performSearch() {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer)
this.searchDebounceTimer = null
}
const searchTerm = this.searchInputTarget.value.trim()
if ((this.state.search || '') === searchTerm) {
this.setSearchBusy(false)
return
}
const updates = { search: searchTerm || null }
// If we're on a view and search differs from view default, mark as dirty
if (this.state.view.currentId && searchTerm !== (this.state.view.search || '')) {
updates['dirty[search]'] = '1'
}
const url = this.buildUrl(updates)
this.setSearchBusy(true)
this.navigate(url)
}
/**
* Clear search
*/
clearSearch() {
const updates = { search: '' } // Use empty string instead of null to explicitly clear
// If we're on a view, mark search as dirty to override the view's potential saved search
if (this.state.view.currentId) {
updates['dirty[search]'] = '1'
}
const url = this.buildUrl(updates)
this.setSearchBusy(true)
this.navigate(url)
}
/**
* Apply date range filter
*/
applyDateRangeFilter(event) {
const input = event.currentTarget
const fieldName = input.dataset.dateRangeField
const value = input.value
// Build updates object with the date range parameter
const updates = {}
updates[fieldName] = value || null
// If value is empty, explicitly set to null to clear the filter
if (!value) {
updates[fieldName] = null
}
// Mark filters as dirty to ensure they're applied
updates['dirty[filters]'] = '1'
const url = this.buildUrl(updates)
this.navigate(url)
}
// ============================================================================
// Filter Actions
// ============================================================================
/**
* Toggle a filter value
*/
toggleFilter(event) {
const checkbox = event.currentTarget
const column = checkbox.dataset.filterColumn
const value = checkbox.value
// Check if this filter is locked
const lockedFilters = this.state.config?.lockedFilters || []
if (this.isFilterLocked(column, lockedFilters)) {
console.warn(`Filter '${column}' is locked and cannot be toggled`)
// Restore checkbox to its previous state
checkbox.checked = !checkbox.checked
return
}
// Get current filter values for this column
let currentValues = this.state.filters.active[column] || []
if (!Array.isArray(currentValues)) {
currentValues = currentValues ? [currentValues] : []
}
// Toggle the value
const newValues = checkbox.checked
? [...currentValues, value]
: currentValues.filter(v => v !== value)
// Build URL with updated filter
const filterParams = { ...this.state.filters.active }
if (newValues.length > 0) {
filterParams[column] = newValues
} else {
delete filterParams[column]
}
let url = this.buildUrlWithFilters(filterParams)
// If we're on a view, mark filters as dirty
if (this.state.view.currentId) {
const urlObj = new URL(url, window.location.origin)
urlObj.searchParams.set('dirty[filters]', '1')
url = urlObj.pathname + urlObj.search
}
this.navigate(url)
}
/**
* Remove a specific filter value
*/
removeFilter(event) {
const column = event.currentTarget.dataset.filterColumn
const value = event.currentTarget.dataset.filterValue
// Check if this filter is locked
const lockedFilters = this.state.config?.lockedFilters || []
if (this.isFilterLocked(column, lockedFilters)) {
console.warn(`Filter '${column}' is locked and cannot be removed`)
return
}
// Get current filter values for this column (ensure it's an array)
let currentValues = this.state.filters.active[column] || []
if (!Array.isArray(currentValues)) {
currentValues = [currentValues]
}
const newValues = currentValues.filter(v => v !== value)
// Build URL with updated filter
const filterParams = { ...this.state.filters.active }
if (newValues.length > 0) {
filterParams[column] = newValues
} else {
delete filterParams[column]
}
// Build URL and mark filters as dirty (keep view_id)
let url = this.buildUrlWithFilters(filterParams)
const urlObj = new URL(url, window.location.origin)
// If we're on a view, mark filters as dirty
if (this.state.view.currentId) {
urlObj.searchParams.set('dirty[filters]', '1')
}
url = urlObj.pathname + urlObj.search
this.navigate(url) // Frame nav - toolbar will update via handleFrameLoad
}
/**
* Clear all filters and search (preserves locked filters)
*/
clearAllFilters() {
const lockedFilters = this.state.config?.lockedFilters || []
// Build filter params preserving only locked filters
const preservedFilters = {}
if (this.state.filters.active && lockedFilters.length > 0) {
for (const [column, values] of Object.entries(this.state.filters.active)) {
if (this.isFilterLocked(column, lockedFilters)) {
preservedFilters[column] = values
}
}
}
// Build URL with only locked filters preserved
let url
if (Object.keys(preservedFilters).length > 0) {
// Has locked filters - use buildUrlWithFilters to preserve them
url = this.buildUrlWithFilters(preservedFilters)
const urlObj = new URL(url, window.location.origin)
// Clear search
urlObj.searchParams.delete('search')
// If we're on a view, mark filters as dirty
if (this.state.view.currentId) {
urlObj.searchParams.set('dirty[filters]', '1')
}
url = urlObj.pathname + urlObj.search
} else {
// No locked filters - simple clear
const updates = { search: null }
// If we're on a view, mark filters as dirty instead of removing view
if (this.state.view.currentId) {
updates['dirty[filters]'] = '1'
}
url = this.buildUrl(updates)
}
this.navigate(url)
}
/**
* Check if a filter column is locked
*
* @param {string} column - The filter column key
* @param {string[]} lockedFilters - Array of locked filter keys
* @returns {boolean} True if the filter is locked
*/
isFilterLocked(column, lockedFilters) {
// Check exact match
if (lockedFilters.includes(column)) {
return true
}
// Check date range variants (e.g., 'expires_on' locks 'expires_on_start' and 'expires_on_end')
const baseField = column.replace(/_(start|end)$/, '')
if (baseField !== column && lockedFilters.includes(baseField)) {
return true
}
return false
}
/**
* Show specific filter panel in dropdown
*/
selectFilter(event) {
const key = event.currentTarget.dataset.filterKey
// Remember which filter tab is active
this.activeFilterKey = key
// Hide all panels
this.element.querySelectorAll('[data-filter-panel]').forEach(panel => {
panel.classList.add('d-none')
})
// Show selected panel
const targetPanel = this.element.querySelector(`[data-filter-panel][data-filter-key="${key}"]`)
if (targetPanel) {
targetPanel.classList.remove('d-none')
}
// Update nav item active states
this.element.querySelectorAll('[data-filter-nav-item]').forEach(item => {
item.classList.remove('active')
})
event.currentTarget.classList.add('active')
}
/**
* Update date range filter value
*/
updateDateRangeFilter(event) {
const columnKey = event.target.dataset.filterColumn
const value = event.target.value
// Check if this filter is locked
const lockedFilters = this.state.config?.lockedFilters || []
if (this.isFilterLocked(columnKey, lockedFilters)) {
console.warn(`Filter '${columnKey}' is locked and cannot be changed`)
// Restore the input to its previous value
const activeValue = this.state.filters.active[columnKey] || ''
event.target.value = activeValue
return
}
// Build URL with updated filter
const filterParams = { ...this.state.filters.active }
if (value) {
filterParams[columnKey] = value
} else {
delete filterParams[columnKey]
}
// Build URL and mark filters as dirty (keep view_id)
let url = this.buildUrlWithFilters(filterParams)
const urlObj = new URL(url, window.location.origin)
// If we're on a view, mark filters as dirty
if (this.state.view.currentId) {
urlObj.searchParams.set('dirty[filters]', '1')
}
url = urlObj.pathname + urlObj.search
this.navigate(url) // Frame nav - toolbar will update via handleFrameLoad
}
// ============================================================================
// Sort Actions
// ============================================================================
/**
* Apply sort to a column
*/
applySort(event) {
const field = event.currentTarget.dataset.columnKey
if (!field) return
const currentSort = this.state.sort
// Cycle through: none -> asc -> desc -> none
let direction = null
if (!currentSort || currentSort.field !== field) {
direction = 'asc'
} else if (currentSort.direction === 'asc') {
direction = 'desc'
}
// else direction stays null (clear sort)
const updates = {
sort: direction ? field : null,
direction: direction
}
// If we're on a view, mark sort as dirty
if (this.state.view.currentId) {
updates['dirty[sort]'] = '1'
}
const url = this.buildUrl(updates)
this.navigate(url)
}
// ============================================================================
// Column Actions
// ============================================================================
/**
* Toggle column visibility (checkbox in modal)
*/
toggleColumn(event) {
const columnKey = event.currentTarget.dataset.columnKey
const listItem = event.currentTarget.closest('[data-column-key]')
// Prevent toggling required columns
if (listItem && listItem.dataset.columnRequired === 'true') {
event.preventDefault()
return
}
const isVisible = event.currentTarget.checked
// Update visual state
if (listItem) {
console.log(`Toggling column ${columnKey}: ${isVisible ? 'visible' : 'hidden'}`)
if (isVisible) {
listItem.classList.remove('list-group-item-secondary')
console.log(`Removed 'list-group-item-secondary' class from ${columnKey}`)
} else {
listItem.classList.add('list-group-item-secondary')
console.log(`Added 'list-group-item-secondary' class to ${columnKey}`)
}
// Force a visual refresh
listItem.offsetHeight
}
}
/**
* Handle column reorder from sortable list
*/
handleColumnReorder(event) {
console.log("Column reorder detected:", event.detail.order)
}
/**
* Apply column changes (from modal)
*/
applyColumnChanges() {
// Get visible columns from checkboxes in modal
const modal = this.element.querySelector(`#columnPickerModal-${this.state.config.gridKey.replace(/\./g, '\\.')}`)
if (!modal) return
const visibleColumns = []
// Include checked columns
modal.querySelectorAll('input[type="checkbox"]:checked').forEach(checkbox => {
const key = checkbox.dataset.columnKey || checkbox.value
if (key) visibleColumns.push(key)
})
// Always include required columns even if disabled
modal.querySelectorAll('[data-column-required="true"]').forEach(item => {
const key = item.dataset.columnKey
if (key && !visibleColumns.includes(key)) {
visibleColumns.push(key)
}
})
const url = this.buildUrl({ columns: visibleColumns.join(',') })
this.navigate(url)
}
// ============================================================================
// Helper Methods
// ============================================================================
getStickyKeys() {
if (!this.hasStickyQueryValue || !this.stickyQueryValue) {
return []
}
return this.stickyQueryValue.split(',').map(key => key.trim()).filter(Boolean)
}
captureStickyParamsFromUrl(url) {
const stickyKeys = this.getStickyKeys()
if (!stickyKeys.length || !url) {
return
}
let parsedUrl
try {
parsedUrl = new URL(url, window.location.origin)
} catch (error) {
console.warn('Unable to parse URL for sticky parameters:', url, error)
return
}
stickyKeys.forEach(key => {
if (parsedUrl.searchParams.has(key)) {
const value = parsedUrl.searchParams.get(key)
if (value !== undefined && value !== null) {
this.stickyParams[key] = value
}
}
})
}
captureStickyParamsFromFrame(frame) {
if (!frame) {
return
}
const src = frame.getAttribute('src') || frame.dataset.gridSrc
if (src) {
this.captureStickyParamsFromUrl(src)
}
this.updateBrowserUrlWithStickyParams()
}
updateBrowserUrlWithStickyParams() {
const stickyKeys = this.getStickyKeys()
if (!stickyKeys.length) {
return
}
const params = new URLSearchParams(window.location.search)
let changed = false
stickyKeys.forEach(key => {
const value = this.stickyParams[key]
if (value !== undefined && value !== null && value !== '') {
const stringValue = String(value)
if (params.get(key) !== stringValue) {
params.set(key, stringValue)
changed = true
}
}
})
if (!changed) {
return
}
const queryString = params.toString()
const newUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname
window.history.replaceState({}, '', newUrl)
}
applyStickyParamsToParams(params, updates = null) {
const stickyKeys = this.getStickyKeys()
if (!stickyKeys.length) {
return
}
stickyKeys.forEach(key => {
if (updates && Object.prototype.hasOwnProperty.call(updates, key)) {
const updateValue = updates[key]
if (updateValue === null || updateValue === undefined || updateValue === '') {
params.delete(key)
delete this.stickyParams[key]
} else {
const stringValue = String(updateValue)
params.set(key, stringValue)
this.stickyParams[key] = stringValue
}
} else {
const storedValue = this.stickyParams[key]
if (storedValue !== undefined && storedValue !== null && storedValue !== '') {
params.set(key, String(storedValue))
}
}
})
}
/**
* Build URL with updated parameters
*/
buildUrl(updates) {
const params = new URLSearchParams(window.location.search)
// Apply updates
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === undefined) {
// Only delete if value is null/undefined
params.delete(key)
} else if (value === '') {
// Empty string means explicitly set to empty (e.g., clearing search on a view)
params.set(key, '')
} else {
params.set(key, value)
}
}
// Ensure sticky parameters persist across navigations
this.applyStickyParamsToParams(params, updates)
// Remove filter parameters if not explicitly included
if (!('filter' in updates)) {
// Keep existing filters unless we're explicitly clearing them
// (This is handled by buildUrlWithFilters)
}
const queryString = params.toString()
return queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname
}
/**
* Build URL with filter parameters
*/
buildUrlWithFilters(filterParams) {
const url = new URL(window.location)
const params = url.searchParams
// If we're on a saved view and search hasn't been explicitly dirtied,
// preserve the current search value in the URL
const hasSearchDirty = params.has('dirty[search]')
if (this.state.view.currentId && !hasSearchDirty && this.state.search) {
params.set('search', this.state.search)
}
// Remove all existing filter parameters (both filter[] and date range)
const keysToDelete = []
for (const key of params.keys()) {
if (key.startsWith('filter[')) {
keysToDelete.push(key)
}
// Also remove date range parameters (_start and _end suffixes)
if (key.endsWith('_start') || key.endsWith('_end')) {
keysToDelete.push(key)
}
}
keysToDelete.forEach(key => params.delete(key))
// Add new filter parameters
for (const [column, values] of Object.entries(filterParams)) {
// Check if this is a date range filter (has _start or _end suffix)
const isDateRangeFilter = column.endsWith('_start') || column.endsWith('_end')
if (isDateRangeFilter) {
// Date range filters use direct query parameters (no filter[] prefix)
const valueArray = Array.isArray(values) ? values : [values]
if (valueArray.length > 0) {
params.set(column, valueArray[0])
}
} else {
// Regular filters use filter[] prefix
// Always use array syntax (filter[column][]) for consistency, even with single values
const valueArray = Array.isArray(values) ? values : [values]
valueArray.forEach(v => params.append(`filter[${column}][]`, v))
}
}
// Reset to page 1
params.delete('page')
// Ensure sticky parameters persist
this.applyStickyParamsToParams(params)
const queryString = params.toString()
return queryString ? `${url.pathname}?${queryString}` : url.pathname
}
/**
* Navigate to URL via Turbo (frame or full page)
*/
navigate(url, fullPage = false) {
console.log('Navigating to:', url, 'fullPage:', fullPage)
if (fullPage) {
// Full page navigation
if (window.Turbo) {
window.Turbo.visit(url)
} else {
window.location.assign(url)
}
} else {
// Frame navigation - find the table frame and update its src
const tableFrame = this.element.querySelector('turbo-frame[id$="-table"]')
if (tableFrame) {
// Get the base grid-data URL from the frame's current src
// This handles embedded grids with custom endpoints like /members/roles-grid-data/1
const currentSrc = tableFrame.getAttribute('src') || tableFrame.dataset.gridSrc || tableFrame.src
if (!currentSrc) {
console.warn('Table frame has no src attribute')
return
}
// Parse current src to extract base URL and context parameters
const currentSrcUrl = new URL(currentSrc, window.location.origin)
const baseGridDataUrl = currentSrcUrl.pathname
// Context parameters that must be preserved (e.g., member_id, branch_id)
// These identify which entity's data we're viewing
const contextParams = ['member_id', 'branch_id', 'gathering_id']
// Parse the navigation URL to get new query params
const urlObj = new URL(url, window.location.origin)
// Build final URL starting with base path
const finalUrl = new URL(baseGridDataUrl, window.location.origin)
// Copy all params from the incoming URL
// Use append() instead of set() to preserve multiple values for the same key (e.g., filter[status][])
urlObj.searchParams.forEach((value, key) => {
finalUrl.searchParams.append(key, value)
})
// Ensure sticky parameters are carried over for frame requests
this.applyStickyParamsToParams(finalUrl.searchParams)
// Preserve context params from original src if not in new URL
contextParams.forEach(param => {
if (currentSrcUrl.searchParams.has(param) && !finalUrl.searchParams.has(param)) {
finalUrl.searchParams.set(param, currentSrcUrl.searchParams.get(param))
}
})
const gridDataUrl = finalUrl.pathname + finalUrl.search
// Update browser history with the original URL (for page reload)
window.history.pushState({}, '', url)
// Notify other controllers that the URL has changed
window.dispatchEvent(new CustomEvent('grid-view:navigated'))
// Persist sticky parameters based on the navigation URL
this.captureStickyParamsFromUrl(finalUrl.toString())
// Navigate the frame by setting src to gridData URL
tableFrame.src = gridDataUrl
} else {
console.warn('Table frame not found, falling back to full page navigation')
if (window.Turbo) {
window.Turbo.visit(url)
} else {
window.location.assign(url)
}
}
}
}
/**
* Get current configuration for saving
*/
getCurrentConfig() {
// Build filters array from active filters
const filters = []
// Add search if present
if (this.state.search) {
filters.push({
field: '_search',
operator: 'contains',
value: this.state.search
})
}
// Add regular filters
// First, collect date range pairs
const dateRanges = new Map()
const regularFilters = []
for (const [field, values] of Object.entries(this.state.filters.active)) {
const valueArray = Array.isArray(values) ? values : [values]
if (valueArray.length > 0) {
// Check if this is a date range filter (field ends with _start or _end)
if (field.endsWith('_start')) {
const baseField = field.slice(0, -6) // Remove '_start' suffix
if (!dateRanges.has(baseField)) {
dateRanges.set(baseField, [null, null])
}
dateRanges.get(baseField)[0] = valueArray[0]
} else if (field.endsWith('_end')) {
const baseField = field.slice(0, -4) // Remove '_end' suffix
if (!dateRanges.has(baseField)) {
dateRanges.set(baseField, [null, null])
}
dateRanges.get(baseField)[1] = valueArray[0]
} else {
regularFilters.push({
field: field,
operator: 'in',
value: valueArray
})
}
}
}
// Add date range filters with [start, end] array
for (const [field, range] of dateRanges) {
filters.push({
field: field,
operator: 'dateRange',
value: range
})
}
// Add regular filters
filters.push(...regularFilters)
// Build sort array
const sort = []
if (this.state.sort && this.state.sort.field) {
sort.push({
field: this.state.sort.field,
direction: this.state.sort.direction
})
}
// Build columns array - normalize visible columns (handle both array and object formats)
const visibleColumns = Array.isArray(this.state.columns.visible)
? this.state.columns.visible
: Object.values(this.state.columns.visible)
const columns = visibleColumns.map((key, index) => ({
key: key,
visible: true,
order: index
}))
return {
filters: filters,
sort: sort,
columns: columns,
pageSize: this.state.config.pageSize,
search: this.state.search
}
}
/**
* Toggle sub-row expansion for additional details
*
* @param {Event} event - Click event from cell with toggleSubRow action
*/
toggleSubRow(event) {
event.preventDefault()
const link = event.currentTarget
const rowId = link.dataset.rowId
const subRowType = link.dataset.subrowType
if (!rowId || !subRowType) {
console.error('Missing rowId or subRowType for toggleSubRow')
return
}
// Find the parent table row
const mainRow = link.closest('tr')
if (!mainRow) {
console.error('Could not find parent row for toggleSubRow')
return
}
// Look for existing sub-row immediately after the main row
const existingSubRow = mainRow.nextElementSibling
const subRowId = `subrow-${rowId}-${subRowType}`
if (existingSubRow && existingSubRow.id === subRowId) {
// Sub-row exists - collapse it
existingSubRow.remove()
mainRow.classList.remove('row-expanded')
// Update icon if present
const icon = link.querySelector('.toggle-icon')
if (icon) {
icon.classList.remove('bi-chevron-down')
icon.classList.add('bi-chevron-right')
}
} else {
// Sub-row doesn't exist - expand it
const colspan = mainRow.querySelectorAll('td').length
// Fetch sub-row content from server
const url = `/members/sub-row/${rowId}/${subRowType}`
fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`)
}
return response.text()
})
.then(html => {
// Create sub-row element
const subRow = document.createElement('tr')
subRow.id = subRowId
subRow.className = 'sub-row'
subRow.innerHTML = `<td colspan="${colspan}" class="sub-row-content">${html}</td>`
// Insert after main row
mainRow.insertAdjacentElement('afterend', subRow)
mainRow.classList.add('row-expanded')
// Update icon if present
const icon = link.querySelector('.toggle-icon')
if (icon) {
icon.classList.remove('bi-chevron-right')
icon.classList.add('bi-chevron-down')
}
})
.catch(error => {
console.error('Error loading sub-row:', error)
// Show error in a sub-row
const subRow = document.createElement('tr')
subRow.id = subRowId
subRow.className = 'sub-row sub-row-error'
subRow.innerHTML = `<td colspan="${colspan}" class="sub-row-content text-danger">
<small>Error loading details. Please try again.</small>
</td>`
mainRow.insertAdjacentElement('afterend', subRow)
})
}
}
// ============================
// BULK SELECTION METHODS
// ============================
/**
* Toggle individual row selection checkbox
*/
toggleRowSelection(event) {
const checkbox = event.target
const id = checkbox.value
if (checkbox.checked) {
if (!this.selectedIds.includes(id)) {
this.selectedIds.push(id)
}
} else {
this.selectedIds = this.selectedIds.filter(i => i !== id)
}
this.updateBulkSelectionUI()
}
/**
* Toggle all row checkboxes on current page
*/
toggleAllSelection(event) {
const selectAll = event.target.checked
if (this.hasRowCheckboxTarget) {
this.rowCheckboxTargets.forEach(checkbox => {
checkbox.checked = selectAll
const id = checkbox.value
if (selectAll) {
if (!this.selectedIds.includes(id)) {
this.selectedIds.push(id)
}
} else {
this.selectedIds = this.selectedIds.filter(i => i !== id)
}
})
}
this.updateBulkSelectionUI()
}
/**
* Clear all bulk selections
*/
clearBulkSelection() {
this.selectedIds = []
// Uncheck all row checkboxes
if (this.hasRowCheckboxTarget) {
this.rowCheckboxTargets.forEach(checkbox => {
checkbox.checked = false
})
}
// Uncheck select all checkbox
if (this.hasSelectAllCheckboxTarget) {
this.selectAllCheckboxTarget.checked = false
this.selectAllCheckboxTarget.indeterminate = false
}
this.updateBulkSelectionUI()
}
/**
* Update bulk action button state and selection count display
*/
updateBulkSelectionUI() {
const hasSelection = this.selectedIds.length > 0
// Enable/disable bulk action buttons
if (this.hasBulkActionBtnTarget) {
this.bulkActionBtnTargets.forEach(btn => {
btn.disabled = !hasSelection
})
}
// Update selection count badges
if (this.hasSelectionCountTarget) {
this.selectionCountTargets.forEach(badge => {
if (hasSelection) {
badge.textContent = this.selectedIds.length
badge.style.display = 'inline'
} else {
badge.style.display = 'none'
}
})
}
// Update select all checkbox indeterminate state
if (this.hasSelectAllCheckboxTarget && this.hasRowCheckboxTarget) {
const totalRows = this.rowCheckboxTargets.length
const selectedCount = this.selectedIds.length
if (selectedCount === 0) {
this.selectAllCheckboxTarget.checked = false
this.selectAllCheckboxTarget.indeterminate = false
} else if (selectedCount === totalRows) {
this.selectAllCheckboxTarget.checked = true
this.selectAllCheckboxTarget.indeterminate = false
} else {
this.selectAllCheckboxTarget.checked = false
this.selectAllCheckboxTarget.indeterminate = true
}
}
}
/**
* Trigger bulk action - dispatches event with selected IDs for modal listeners
*/
triggerBulkAction(event) {
if (this.selectedIds.length === 0) {
return
}
// Dispatch custom event with selected IDs for listeners (e.g., bulk edit modal)
const detail = { ids: [...this.selectedIds] }
// Fire event on the button (outlet-btn pattern expects this)
event.currentTarget.dispatchEvent(new CustomEvent('outlet-btn:notice', {
bubbles: true,
detail: detail
}))
// Also dispatch a more generic event on the controller element
this.element.dispatchEvent(new CustomEvent('grid-view:bulk-action', {
bubbles: true,
detail: detail
}))
}
/**
* Export current grid data to CSV
*
* Triggers a CSV export with current filters, search, sort, and column selection.
* Uses the table frame's src URL as base (handles embedded grids with custom endpoints).
*/
exportCsv() {
// Find the table frame to get the correct data endpoint
const tableFrame = this.element.querySelector('turbo-frame[id$="-table"]')
if (!tableFrame) {
console.warn('Table frame not found, cannot export')
return
}
// Get the base grid-data URL from the frame's src
const currentSrc = tableFrame.getAttribute('src') || tableFrame.dataset.gridSrc || tableFrame.src
if (!currentSrc) {
console.warn('Table frame has no src attribute')
return
}
// Parse current src to build export URL
const srcUrl = new URL(currentSrc, window.location.origin)
// Add export parameter
srcUrl.searchParams.set('export', 'csv')
// Navigate to CSV export URL (will trigger download)
window.location.href = srcUrl.toString()
}
/**
* Get CSRF token from meta tag
*/
getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.content : ""
}
}
// Register controller globally
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["grid-view"] = GridViewController
export default GridViewController