1import { Controller } from "@hotwired/stimulus" 2import { serializeFilterRow } from "../utils/filter_serializer" 3import { loadRelationOptions, fillRelationSelect } from "../utils/relation_options" 4 5export default class extends Controller { 6 static targets = ["container", "input", "panel", "badge", "toggleIcon", 7 "startYear", "startPeriod", "endYear", "endPeriod"] 8 static values = { 9 url: String, // URL to update dashboard config 10 config: Object, // Current config 11 availableFilters: Array // New value for available filters (includes definitions & spine) 12 } 13 14 connect() { 15 // 1. Build Definitions Cache in memory 16 this.cacheDefinitions() 17 18 // 2. Restore filters from config 19 const filters = this.configValue.filters || [] 20 filters.forEach(f => this.addFilterInternal(f)) 21 22 // 3. Restore Period 23 this.restorePeriod() 24 25 // 3.5 Initial check for blocking logic 26 this.handleStartPeriodChange() 27 28 // 4. Update badge count 29 this.updateBadgeCount() 30 } 31 32 restorePeriod() { 33 const range = this.configValue.date_range 34 if (!range) return 35 36 // 1. Restore Year (Simple Heuristic for basic cases) 37 if (range.start) { 38 const d = new Date(range.start) 39 this.startYearTarget.value = d.getFullYear() 40 } 41 42 // 2. Restore Meta (Precise) 43 // Check for meta presence and valid keys to ensure UI matches saved logic 44 if (range.meta) { 45 // Restore Start Period 46 if (range.meta.start_period) { 47 this.startPeriodTarget.value = range.meta.start_period 48 } 49 // Restore Start Year (Override inferred if needed) 50 if (range.meta.start_year) { 51 this.startYearTarget.value = range.meta.start_year 52 } 53 54 // Restore End Period 55 if (range.meta.end_period !== undefined) { 56 // Check if target exists before setting (in case disabled/hidden logic varies) 57 if (this.hasEndPeriodTarget) { 58 this.endPeriodTarget.value = range.meta.end_period || "" 59 } 60 } 61 62 // Restore End Year 63 if (range.meta.end_year !== undefined) { 64 if (this.hasEndYearTarget) { 65 this.endYearTarget.value = range.meta.end_year || "" 66 } 67 } 68 } 69 } 70 71 handleStartPeriodChange() { 72 if (!this.hasStartPeriodTarget) return 73 74 const val = this.startPeriodTarget.value 75 const isQuarter = val.startsWith('Q') 76 77 // Block End Period/Year if Quarter is selected 78 if (this.hasEndPeriodTarget) { 79 this.endPeriodTarget.disabled = isQuarter 80 if (isQuarter) this.endPeriodTarget.value = "" // Visual clear 81 } 82 83 if (this.hasEndYearTarget) { 84 this.endYearTarget.disabled = isQuarter 85 if (isQuarter) this.endYearTarget.value = "" // Visual clear 86 } 87 } 88 89 save() { 90 // ... (Filters logic same) ... 91 const filters = [] 92 this.containerTarget.querySelectorAll('.filter-row').forEach(row => { 93 const modelSelect = row.querySelector('.filter-model') 94 const datasetKey = modelSelect ? modelSelect.value : null 95 if (!datasetKey) return 96 const defs = this.defsByDataset[datasetKey].definitions 97 const result = serializeFilterRow(row, { datasetKey: datasetKey, defsByField: defs }) 98 if (result) filters.push(result) 99 }) 100 101 const newConfig = { ...this.configValue, filters } 102 103 // Calculate Global Date Range 104 const startP = this.startPeriodTarget.value 105 const startY = parseInt(this.startYearTarget.value, 10) 106 107 const isQuarter = startP.startsWith('Q') 108 109 // If blocked (Quarter), we force single period logic (End is derived from Start Period) 110 // Otherwise use user inputs. 111 const endP = isQuarter ? startP : this.endPeriodTarget.value 112 let endY = isQuarter ? startY : parseInt(this.endYearTarget.value, 10) 113 114 // UX Improvement: Default End Year to Start Year if usage implies same year (only if NOT blocked, since blocked handled above) 115 if (!isQuarter && endP && isNaN(endY) && !isNaN(startY)) { 116 endY = startY 117 } 118 119 if (startP && startY) { 120 const startDate = this.calcDate(startY, startP, 'start') 121 let endDate = null 122 123 // For Quarters (blocked), endP/endY are effectively startP/startY, so we calculate End of that period. 124 // For others, if endP/endY exist, we use them.
125 if (endP && !isNaN(endY)) { 126 endDate = this.calcDate(endY, endP, 'end') 127 } 128 129 newConfig.date_range = { 130 start: startDate, 131 end: endDate, // If null, open ended 132 meta: { 133 start_period: startP, 134 start_year: startY, 135 // If blocked, we usually don't store end_period in meta to reflect UI state (empty) 136 // BUT for restoration, if we want to show Q1, we stored start_period. 137 // If we store end_period=Q1 while UI is empty, restoration might be tricky? 138 // restorePeriod sets values. If UI disables them, it's fine. 139 // Let's store what we used for calculation if it makes semantic sense, 140 // OR stick to what user sees? 141 // User sees Empty End. 142 // Let's store end_period as null if blocked? 143 // But then `endDate` is set. 144 // If `endDate` is present but `end_period` is missing in meta, restoration might lose context? 145 // Let's store null in meta if user didn't pick it, to keep UI consistent on reload. 146 end_period: isQuarter ? null : endP, 147 end_year: isQuarter ? null : endY 148 } 149 } 150 } else { 151 delete newConfig.date_range 152 } 153 154 // ... (fetch) ... 155 fetch(this.urlValue, { 156 method: "PATCH", 157 headers: { 158 "Content-Type": "application/json", 159 "Accept": "application/json", 160 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content 161 }, 162 body: JSON.stringify({ dashboard: { config: newConfig } }) 163 }) 164 .then(response => { 165 if (response.ok) { 166 window.location.reload() 167 } else { 168 alert("Failed to save filters") 169 } 170 }) 171 } 172 173 calcDate(year, period, type) { 174 // periods: 01-12 (Months), Q1-Q4 (Quarters) 175 let monthStart = 0 176 let monthEnd = 11 177 178 if (period.startsWith("Q")) { 179 const q = parseInt(period[1], 10) 180 monthStart = (q - 1) * 3 181 monthEnd = monthStart + 2 182 } else { 183 const m = parseInt(period, 10) 184 monthStart = m - 1 185 monthEnd = m - 1 186 } 187 188 const y = year 189 190 if (type === 'start') { 191 // First day of monthStart 192 // Use UTC to ensure date string is stable "YYYY-MM-DD" 193 const d = new Date(Date.UTC(y, monthStart, 1)) 194 return d.toISOString().split('T')[0] 195 } else { 196 // Last day of monthEnd 197 // Date.UTC(y, monthEnd + 1, 0) gives last day of prev month (which is monthEnd) 198 const d = new Date(Date.UTC(y, monthEnd + 1, 0)) 199 return d.toISOString().split('T')[0] 200 } 201 } 202 203 cacheDefinitions() { 204 this.defsByDataset = {} 205 this.availableFiltersValue.forEach(ds => { 206 this.defsByDataset[ds.key] = { 207 label: ds.label, 208 definitions: ds.filter_definitions || {}, // Use filter_definitions from Registry 209 spine: ds.spine 210 } 211 }) 212 } 213 214 togglePanel() { 215 if (this.hasPanelTarget) { 216 this.panelTarget.classList.toggle("d-none") 217 // Toggle icon if present 218 if (this.hasToggleIconTarget) { 219 if (this.panelTarget.classList.contains("d-none")) { 220 this.toggleIconTarget.classList.remove("fa-chevron-up") 221 this.toggleIconTarget.classList.add("fa-chevron-down") 222 } else { 223 this.toggleIconTarget.classList.remove("fa-chevron-down") 224 this.toggleIconTarget.classList.add("fa-chevron-up") 225 } 226 } 227 } 228 } 229 230 updateBadgeCount() { 231 if (this.hasBadgeTarget) { 232 const count = (this.configValue.filters || []).length 233 this.badgeTarget.textContent = count 234 if (count > 0) { 235 this.badgeTarget.classList.remove("d-none") 236 } else { 237 this.badgeTarget.classList.add("d-none") 238 } 239 } 240 } 241 242 addFilter() { 243 this.addFilterInternal() 244 } 245 246 addFilterInternal(data = null) {
247 const row = document.createElement("div") 248 row.className = "input-group input-group-sm mb-2 filter-row" 249 250 // 1. Build Model Select 251 let modelOptions = '<option value="">Select Model...</option>' 252 253 Object.keys(this.defsByDataset).forEach(key => { 254 const ds = this.defsByDataset[key] 255 256 // Show only datasets that have filter definitions 257 if (Object.keys(ds.definitions).length > 0) { 258 // Restoration Logic: 259 // If data has 'dataset', match key. 260 // If data has NO 'dataset' (Legacy), try to find if this dataset has the field. 261 262 let selected = '' 263 if (data) { 264 if (data.dataset === key) { 265 selected = 'selected' 266 } else if (!data.dataset && ds.definitions[data.field]) { 267 // Legacy fallback match 268 selected = 'selected' 269 // Patch data object for subsequent calls 270 data.dataset = key 271 } 272 } 273 274 modelOptions += `<option value="${key}" ${selected}>${ds.label}</option>` 275 } 276 }) 277 278 row.innerHTML = ` 279 <select class="form-select filter-model" style="max-width: 150px;"> 280 ${modelOptions} 281 </select> 282 <select class="form-select filter-field" style="max-width: 200px;"> 283 <option value="">Select Field...</option> 284 </select> 285 <div class="filter-operator-container" style="width: 120px;"></div> 286 <div class="filter-value-container flex-grow-1"></div> 287 <button class="btn btn-outline-danger" type="button" data-action="click->dashboard-filter#removeFilter"><i class="fa-solid fa-times"></i></button> 288 ` 289 290 this.containerTarget.appendChild(row) 291 292 const modelSelect = row.querySelector('.filter-model') 293 const fieldSelect = row.querySelector('.filter-field') 294 const opContainer = row.querySelector('.filter-operator-container') 295 296 // Event Listeners 297 modelSelect.addEventListener('change', () => this.handleModelChange(row, modelSelect.value)) 298 fieldSelect.addEventListener('change', () => this.handleFieldChange(row)) 299 opContainer.addEventListener('change', () => this.handleOperatorChange(row)) 300 301 // Initialization 302 if (data && data.dataset) { 303 // Trigger model change to populate fields, handling data restoration 304 this.handleModelChange(row, data.dataset, data) 305 } 306 } 307 308 handleModelChange(row, datasetKey, data = null) { 309 const fieldSelect = row.querySelector('.filter-field') 310 311 // Clear children 312 fieldSelect.innerHTML = '<option value="">Select Field...</option>' 313 row.querySelector('.filter-operator-container').innerHTML = '' 314 row.querySelector('.filter-value-container').innerHTML = '' 315 316 if (!datasetKey) return 317 318 const ds = this.defsByDataset[datasetKey] 319 if (!ds) return 320 321 let fieldOptions = '<option value="">Select Field...</option>' 322 Object.entries(ds.definitions).forEach(([field, def]) => { 323 const label = def.label || field.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase()) 324 325 // Fix: Check both 'field' (legacy/simple) and 'dimension' (namespaced V2) keys 326 const savedField = data ? (data.field || data.dimension) : null 327 const selected = (savedField === field) ? 'selected' : '' 328 329 // NOTE: datasetKey is available on the parent row via model select, no need to duplicate in data-dataset 330 fieldOptions += `<option value="${field}" ${selected}>${label}</option>` 331 }) 332 333 fieldSelect.innerHTML = fieldOptions 334 335 // Transform Legacy Data if needed (ensure op/value are ready) 336 if (data) { 337 this.handleFieldChange(row, data) 338 } 339 } 340 341 handleFieldChange(row, data = null) { 342 const modelSelect = row.querySelector('.filter-model') 343 const datasetKey = modelSelect ? modelSelect.value : null 344 345 const fieldSelect = row.querySelector('.filter-field') 346 const field = fieldSelect.value 347 348 if (!field || !datasetKey) return 349 350 const ds = this.defsByDataset[datasetKey] 351 if (!ds) return 352 const def = ds.definitions[field] 353 if (!def) return 354 355 const type = def.type || 'string' 356 const multiple = !!def.multiple 357 const options = def.values || def.options || [] 358 359 // Render Operator 360 this.renderOperator(row, type, data ? data.op : null, multiple) 361 362 // Relation filter (e.g. organizer -> Organization): load options via API 363 // into a searchable select instead of degrading to a plain text input. 364 if (def.model && options.length === 0) { 365 this.renderRelationValueInput(row, def, multiple, data ? data.value : null) 366 return 367 } 368 369 // Render Value Input 370 // If data is present, we pass it. If not, null. 371 // We pass 'op' explicitly so value renderer knows if it's 'between' etc. 372 const currentOp = data ? data.op : null 373 this.renderValueInput(row, type, multiple, options, data ? data.value : null, currentOp) 374 375 // Init Choices if needed 376 const input = row.querySelector('.filter-value') 377 if (input && multiple && options.length > 0) { 378 this.initChoices(input, def.values || def.options || []) // Pass options explicit if needed for Choices defaults? 379 // Actually options are already in DOM <select>. Choices reads them. 380 } 381 } 382 383 renderRelationValueInput(row, def, multiple, value) { 384 const container = row.querySelector('.filter-value-container') 385 386 // Tear down any existing Choices instance before replacing 387 const existing = container.querySelector('select.filter-value') 388 if (existing && existing.choicesInstance) existing.choicesInstance.destroy() 389 390 container.innerHTML = `<select class="form-select filter-value" ${multiple ? 'multiple' : ''} disabled><option value="">Chargement...</option></select>` 391 const select = container.querySelector('select') 392 393 loadRelationOptions(def.model).then(options => { 394 if (!select.isConnected) return // Field changed while loading 395 select.disabled = false 396 fillRelationSelect(select, options, { multiple, selectedValue: value }) 397 }) 398 } 399 400 handleOperatorChange(row) {
401 // When op changes, we may need to re-render value input (e.g. switching to/from 'between') 402 const modelSelect = row.querySelector('.filter-model') 403 const datasetKey = modelSelect ? modelSelect.value : null 404 405 const fieldSelect = row.querySelector('.filter-field') 406 const field = fieldSelect.value 407 408 if (!field || !datasetKey) return 409 410 const def = this.defsByDataset[datasetKey].definitions[field] 411 const type = def.type || 'string' 412 const multiple = !!def.multiple 413 const options = def.values || def.options || [] 414 415 // Relation filters keep the same searchable select across eq/neq/in; 416 // don't re-render (it would wipe the loaded options and selection). 417 if (def.model && options.length === 0) return 418 419 const opSelect = row.querySelector('.filter-op') 420 const op = opSelect ? opSelect.value : 'eq' 421 422 // Re-render value input with new op, clearing value (or attempting preserve?) 423 // Clearing is safer. 424 this.renderValueInput(row, type, multiple, options, null, op) 425 426 const input = row.querySelector('.filter-value') 427 if (input && multiple) { 428 this.initChoices(input) 429 } 430 } 431 432 renderOperator(row, type, selectedOp, multiple = false) { 433 const container = row.querySelector('.filter-operator-container') 434 435 if (multiple) { 436 container.innerHTML = `<select class="form-select filter-op" disabled style="background-color: #e9ecef;"><option value="in" selected>Is One Of</option></select>` 437 return 438 } 439 440 const ops = this.getOps(type) 441 let html = '<select class="form-select filter-op">' 442 ops.forEach(o => { 443 const sel = (selectedOp === o.v) ? 'selected' : '' 444 html += `<option value="${o.v}" ${sel}>${o.l}</option>` 445 }) 446 html += '</select>' 447 448 container.innerHTML = html 449 } 450 451 renderValueInput(row, type, multiple, options, value, op = null) { 452 const container = row.querySelector('.filter-value-container') 453 454 // Determine effective op if not passed (e.g. first render) 455 if (!op) { 456 const opSelect = row.querySelector('.filter-op') 457 op = opSelect ? opSelect.value : (multiple ? 'in' : 'eq') 458 } 459 460 if (op === 'between') { 461 const v1 = (value && value.from) ? value.from : (Array.isArray(value) ? value[0] : '') 462 const v2 = (value && value.to) ? value.to : (Array.isArray(value) ? value[1] : '') 463 const inputType = (type === 'date') ? 'date' : 'number' 464 container.innerHTML = ` 465 <div class="input-group input-group-sm"> 466 <input type="${inputType}" class="form-control filter-value-from" placeholder="From" value="${v1}"> 467 <span class="input-group-text">-</span> 468 <input type="${inputType}" class="form-control filter-value-to" placeholder="To" value="${v2}"> 469 </div> 470 ` 471 return 472 } 473 474 // Select (Multiple or Single with Options) 475 if (options.length > 0) { 476 const attrs = multiple ? 'multiple' : '' 477 const placeholder = multiple ? "Select options..." : "Select..." 478 479 let html = `<select class="form-select filter-value" ${attrs} placeholder="${placeholder}">` 480 if (!multiple) { 481 html += `<option value="">${placeholder}</option>` 482 } 483 484 options.forEach(opt => { 485 let optVal, optLabel 486 if (Array.isArray(opt)) { 487 if (opt.length === 2) { optLabel = opt[0]; optVal = opt[1] } 488 else { optLabel = opt[0]; optVal = opt[0] } 489 } else { 490 optLabel = opt; optVal = opt 491 } 492 493 let isSelected = false 494 if (value != null) { 495 if (multiple && Array.isArray(value)) { 496 isSelected = value.some(v => String(v) === String(optVal)) 497 } else { 498 isSelected = (String(value) === String(optVal)) 499 } 500 } 501 502 html += `<option value="${optVal}" ${isSelected ? 'selected' : ''}>${optLabel}</option>` 503 }) 504 html += `</select>` 505 container.innerHTML = html 506 return 507 } 508 509 const inputType = (type === 'date') ? 'date' : (type === 'number' ? 'number' : 'text') 510 container.innerHTML = `<input type="${inputType}" class="form-control filter-value" value="${value || ''}">` 511 } 512 513 getOps(type) { 514 const mapping = {
515 string: [ 516 { v: 'eq', l: 'Equals' }, { v: 'not_eq', l: 'Not Equals' }, 517 { v: 'contains', l: 'Contains' }, { v: 'starts_with', l: 'Starts With' }, 518 { v: 'in', l: 'Is One Of' } 519 ], 520 enum: [ 521 { v: 'eq', l: 'Is' }, { v: 'not_eq', l: 'Is Not' }, { v: 'in', l: 'Is One Of' } 522 ], 523 number: [ 524 { v: 'eq', l: '=' }, { v: 'gt', l: '>' }, { v: 'lt', l: '<' }, 525 { v: 'between', l: 'Between' } 526 ], 527 date: [ 528 { v: 'eq', l: 'On' }, { v: 'gt', l: 'After' }, { v: 'lt', l: 'Before' }, 529 { v: 'between', l: 'Between' } 530 ] 531 } 532 return mapping[type] || mapping['string'] 533 } 534 535 initChoices(element) { 536 if (window.Choices) { 537 // Destroy if exists? 538 if (element.choicesInstance) element.choicesInstance.destroy() 539 540 element.choicesInstance = new window.Choices(element, { 541 itemSelectText: '', 542 removeItemButton: true, 543 shouldSort: false 544 }) 545 } 546 } 547 548 removeFilter(event) { 549 const row = event.target.closest('.filter-row') 550 const input = row.querySelector('.filter-value') 551 if (input && input.choicesInstance) { 552 input.choicesInstance.destroy() 553 } 554 row.remove() 555 } 556 557 558};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.