PageSourceSearch

https://www.standard-advisory.com/assets/controllers/dashboard_fil…db45465bc857dc515a4bd7d696234217fab766c5.js

js standard-advisory.com collected 2026-10-09 22:36:18 UTC 21,879 bytes, 558 lines download raw bytes

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.