stimulus_rails_datatables 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b850688d95d3bbcea4dc97863df1249b3a926b5e686b9b25f840ccd169ce46be
|
|
4
|
+
data.tar.gz: d4b7b87d91f94aa5bd5691e36d20027bbc98c2c672dc47c3fbb1c45dc1b79fd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 740fb571e4e0836dc58df16ccbc009d55e2dd78dc7244948e85b9ab27fd0e79835e7be1f38f0e84d52bf7aac5f0d7524bb8bd596499fa533e77c8c0a13009038
|
|
7
|
+
data.tar.gz: 78e0b1238a6d89e08a17464303dca30cb3ba4b4a60604c8e4ce6c20116521541fa126a62d56441c600af4ac0fef4f1b880949aa994b06c586dce882be968cec5
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Disabling eslint since this is a library file and may not conform to all rules
|
|
3
3
|
|
|
4
4
|
import { Controller } from '@hotwired/stimulus'
|
|
5
|
+
import TomSelect from 'tom-select'
|
|
5
6
|
|
|
6
7
|
export default class extends Controller {
|
|
7
8
|
static targets = ['select', 'customFields']
|
|
@@ -13,6 +14,45 @@ export default class extends Controller {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
connect() {
|
|
17
|
+
this.tomSelects = new Map()
|
|
18
|
+
this.remoteSelects = Array.from(
|
|
19
|
+
this.element.querySelectorAll('select[data-filter-remote-url-value]')
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
this.remoteSelects.forEach(select => {
|
|
23
|
+
if (select.dataset.filterTomselect !== 'true') return
|
|
24
|
+
|
|
25
|
+
const isMultiple = select.multiple
|
|
26
|
+
const ts = new TomSelect(select, {
|
|
27
|
+
maxItems: isMultiple ? null : 1,
|
|
28
|
+
placeholder: select.dataset.filterPlaceholder || 'Select',
|
|
29
|
+
create: false,
|
|
30
|
+
hideSelected: isMultiple ? false : undefined,
|
|
31
|
+
render: isMultiple ? { item: () => '<div class="ts-hidden-item"></div>' } : {}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (isMultiple) {
|
|
35
|
+
const originalOnOptionSelect = ts.onOptionSelect.bind(ts)
|
|
36
|
+
ts.onOptionSelect = (evt, option) => {
|
|
37
|
+
const value = option.dataset.value
|
|
38
|
+
if (ts.items.includes(value)) {
|
|
39
|
+
evt.preventDefault()
|
|
40
|
+
ts.removeItem(value)
|
|
41
|
+
ts.refreshOptions(false)
|
|
42
|
+
} else {
|
|
43
|
+
originalOnOptionSelect(evt, option)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.updateSummaryLabel(select, ts)
|
|
47
|
+
ts.on('item_add', () => this.updateSummaryLabel(select, ts))
|
|
48
|
+
ts.on('item_remove', () => this.updateSummaryLabel(select, ts))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.tomSelects.set(select, ts)
|
|
52
|
+
|
|
53
|
+
if (select.dataset.filterDependsOn) ts.disable()
|
|
54
|
+
})
|
|
55
|
+
|
|
16
56
|
// begin restore; it is async but will dispatch filters:ready when done
|
|
17
57
|
this.restoreState()
|
|
18
58
|
|
|
@@ -29,16 +69,50 @@ export default class extends Controller {
|
|
|
29
69
|
// trigger datatable reload
|
|
30
70
|
this.reloadAppDatatable()
|
|
31
71
|
})
|
|
72
|
+
}
|
|
32
73
|
|
|
33
|
-
|
|
34
|
-
this.
|
|
74
|
+
disconnect() {
|
|
75
|
+
this.tomSelects?.forEach(ts => ts.destroy())
|
|
76
|
+
this.tomSelects?.clear()
|
|
77
|
+
}
|
|
35
78
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
79
|
+
// --- helpers: single source of truth for reading/writing a field, Tom-Select-aware ---
|
|
80
|
+
getFieldValue(el) {
|
|
81
|
+
const ts = this.tomSelects.get(el)
|
|
82
|
+
return ts ? ts.getValue() : el.value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isValueEmpty(value) {
|
|
86
|
+
return Array.isArray(value) ? value.length === 0 : !value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setFieldValue(el, value, silent = true) {
|
|
90
|
+
const ts = this.tomSelects.get(el)
|
|
91
|
+
if (ts) ts.setValue(value, silent)
|
|
92
|
+
else el.value = value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
updateSummaryLabel(select, ts) {
|
|
96
|
+
const count = ts.items.length
|
|
97
|
+
const placeholder = select.dataset.filterPlaceholder || 'Select'
|
|
98
|
+
|
|
99
|
+
let label = ts.control.querySelector('.ts-summary-label')
|
|
100
|
+
if (!label) {
|
|
101
|
+
label = document.createElement('div')
|
|
102
|
+
label.className = 'ts-summary-label'
|
|
103
|
+
ts.control.prepend(label)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hasSelection = count > 0
|
|
107
|
+
label.textContent = hasSelection ? `${count} Barangay${count > 1 ? 's' : ''} selected` : ''
|
|
108
|
+
label.style.display = hasSelection ? 'block' : 'none'
|
|
109
|
+
|
|
110
|
+
if (hasSelection) {
|
|
111
|
+
ts.control_input.style.setProperty('display', 'none', 'important')
|
|
112
|
+
} else {
|
|
113
|
+
ts.control_input.style.removeProperty('display')
|
|
114
|
+
ts.control_input.setAttribute('placeholder', placeholder)
|
|
115
|
+
}
|
|
42
116
|
}
|
|
43
117
|
|
|
44
118
|
toQuery(obj, prefix) {
|
|
@@ -47,7 +121,10 @@ export default class extends Controller {
|
|
|
47
121
|
for (const [key, value] of Object.entries(obj)) {
|
|
48
122
|
const fullKey = prefix ? `${prefix}[${key}]` : key
|
|
49
123
|
|
|
50
|
-
if (value
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
value.forEach(v => pairs.push(`${encodeURIComponent(fullKey)}[]=${encodeURIComponent(v)}`))
|
|
126
|
+
}
|
|
127
|
+
else if (value !== null && typeof value === 'object') {
|
|
51
128
|
pairs.push(this.toQuery(value, fullKey))
|
|
52
129
|
}
|
|
53
130
|
else if (value !== '' && value !== null && value !== undefined) {
|
|
@@ -72,15 +149,16 @@ export default class extends Controller {
|
|
|
72
149
|
|
|
73
150
|
datatable.ajax.url(`${datatableUrl}?${params}`).load(null, false)
|
|
74
151
|
}
|
|
75
|
-
|
|
76
152
|
}
|
|
77
153
|
|
|
78
154
|
// async populate returns when options appended
|
|
79
155
|
async populate(select) {
|
|
156
|
+
const ts = this.tomSelects.get(select)
|
|
80
157
|
let url = select.dataset.filterRemoteUrlValue
|
|
81
158
|
const labelKey = select.dataset.filterLabelKey
|
|
82
159
|
const valueKey = select.dataset.filterValueKey
|
|
83
|
-
const
|
|
160
|
+
const setValues = (select.dataset.filterSetValues || select.dataset.filterSetValue || '')
|
|
161
|
+
.split(',').map(v => v.trim()).filter(Boolean)
|
|
84
162
|
|
|
85
163
|
url = decodeURIComponent(url).replace(/{(\w+)}/g, (_, key) => {
|
|
86
164
|
const input = this.element.querySelector(`[data-filter-field-name='${key}']`)
|
|
@@ -94,38 +172,54 @@ export default class extends Controller {
|
|
|
94
172
|
if (!response.ok) throw new Error(`Failed to fetch ${url}`)
|
|
95
173
|
const data = await response.json()
|
|
96
174
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// If set_value matches, mark as selected
|
|
104
|
-
if (set_value && item[valueKey] == set_value) {
|
|
105
|
-
option.selected = true
|
|
175
|
+
if (ts) {
|
|
176
|
+
ts.clear(true) // clear current selection (silent — no change event)
|
|
177
|
+
ts.clearOptions() // wipe stale option list
|
|
178
|
+
if (!select.multiple) {
|
|
179
|
+
const placeholder = select.dataset.filterPlaceholder || 'All'
|
|
180
|
+
ts.addOption({ value: '', text: placeholder })
|
|
106
181
|
}
|
|
107
|
-
|
|
108
|
-
select.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
182
|
+
ts.addOptions(data.map(item => ({ value: String(item[valueKey]), text: item[labelKey] })))
|
|
183
|
+
if (setValues.length) this.setFieldValue(select, select.multiple ? setValues : setValues[0])
|
|
184
|
+
ts.enable()
|
|
185
|
+
if (select.multiple) this.updateSummaryLabel(select, ts)
|
|
186
|
+
} else {
|
|
187
|
+
select.innerHTML = ''
|
|
188
|
+
|
|
189
|
+
const placeholder = select.dataset.filterPlaceholder || 'All'
|
|
190
|
+
const blankOption = document.createElement('option')
|
|
191
|
+
blankOption.value = ''
|
|
192
|
+
blankOption.textContent = placeholder
|
|
193
|
+
select.appendChild(blankOption)
|
|
194
|
+
|
|
195
|
+
data.forEach(item => {
|
|
196
|
+
const option = document.createElement('option')
|
|
197
|
+
option.value = item[valueKey]
|
|
198
|
+
option.textContent = item[labelKey]
|
|
199
|
+
if (setValues.includes(String(item[valueKey]))) option.selected = true
|
|
200
|
+
select.appendChild(option)
|
|
201
|
+
})
|
|
202
|
+
select.disabled = false
|
|
203
|
+
}
|
|
112
204
|
} catch (e) {
|
|
113
205
|
console.error('[Filter] fetch error:', e)
|
|
114
|
-
|
|
206
|
+
if (ts) ts.enable()
|
|
207
|
+
else select.disabled = false
|
|
115
208
|
}
|
|
116
209
|
}
|
|
117
210
|
|
|
118
211
|
resetSelect(select, disabled = true) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
212
|
+
const ts = this.tomSelects.get(select)
|
|
213
|
+
if (ts) {
|
|
214
|
+
ts.clear(true)
|
|
215
|
+
ts.clearOptions()
|
|
216
|
+
disabled ? ts.disable() : ts.enable()
|
|
217
|
+
} else {
|
|
218
|
+
const placeholder = select.dataset.filterPlaceholder || 'Select'
|
|
219
|
+
select.innerHTML = `<option value="">${placeholder}</option>`
|
|
220
|
+
select.value = ''
|
|
221
|
+
select.disabled = disabled
|
|
222
|
+
}
|
|
129
223
|
}
|
|
130
224
|
|
|
131
225
|
currentParams() {
|
|
@@ -133,20 +227,15 @@ export default class extends Controller {
|
|
|
133
227
|
const params = {}
|
|
134
228
|
|
|
135
229
|
this.element.querySelectorAll('[data-filter-field-name]').forEach(el => {
|
|
136
|
-
|
|
230
|
+
const value = this.getFieldValue(el)
|
|
231
|
+
if (!this.isValueEmpty(value)) params[el.dataset.filterFieldName] = value
|
|
137
232
|
})
|
|
138
233
|
|
|
139
|
-
|
|
140
|
-
if (v !== '' && v !== null && v !== undefined) acc[k] = v
|
|
141
|
-
return acc
|
|
142
|
-
}, {})
|
|
143
|
-
|
|
144
|
-
return { [rootKey]: clean }
|
|
234
|
+
return { [rootKey]: params }
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
saveState() {
|
|
148
238
|
const state = this.currentParams()
|
|
149
|
-
// deterministic storage key that datatable can read
|
|
150
239
|
try {
|
|
151
240
|
localStorage.setItem(`filterState:${this.filterDtId}`, JSON.stringify(state))
|
|
152
241
|
} catch (e) {
|
|
@@ -176,21 +265,13 @@ export default class extends Controller {
|
|
|
176
265
|
if (el && !el.dataset.filterRemoteUrlValue) el.value = value
|
|
177
266
|
})
|
|
178
267
|
|
|
179
|
-
|
|
180
|
-
this.selects = Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
|
|
268
|
+
const roots = this.remoteSelects.filter(s => !s.dataset.filterDependsOn)
|
|
181
269
|
|
|
182
|
-
// find root remote selects (no depends_on)
|
|
183
|
-
const roots = this.selects.filter(s => !s.dataset.filterDependsOn)
|
|
184
|
-
|
|
185
|
-
// populate each root -> then recursively populate dependents and restore values
|
|
186
270
|
await Promise.all(roots.map(async (root) => {
|
|
187
271
|
await this.populate(root)
|
|
188
|
-
// after root options exist, restore saved root value (if any)
|
|
189
272
|
const sv = savedParams[root.dataset.filterFieldName]
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
if (sv && !set_value) root.value = sv
|
|
193
|
-
// cascade down children
|
|
273
|
+
const hasSetValue = root.dataset.filterSetValue || root.dataset.filterSetValues
|
|
274
|
+
if (sv && !hasSetValue) this.setFieldValue(root, sv)
|
|
194
275
|
await this.populateDependents(root, savedParams)
|
|
195
276
|
}))
|
|
196
277
|
|
|
@@ -201,52 +282,49 @@ export default class extends Controller {
|
|
|
201
282
|
const sd = this.element.querySelector('[data-filter-field-name="start_date"]')
|
|
202
283
|
if (sd) sd.value = savedParams['start_date']
|
|
203
284
|
}
|
|
204
|
-
|
|
205
285
|
if (savedParams['end_date']) {
|
|
206
286
|
const ed = this.element.querySelector('[data-filter-field-name="end_date"]')
|
|
207
287
|
if (ed) ed.value = savedParams['end_date']
|
|
208
288
|
}
|
|
209
289
|
}
|
|
210
290
|
|
|
211
|
-
// emit event
|
|
291
|
+
// emit event so any listener can catch
|
|
212
292
|
const payload = this.currentParams()
|
|
213
293
|
this.element.dispatchEvent(new CustomEvent('filters:ready', { detail: { params: payload }, bubbles: true }))
|
|
214
294
|
|
|
215
|
-
// also update deterministic storage (in case other code reads it)
|
|
216
295
|
try {
|
|
217
296
|
localStorage.setItem(`filterState:${this.filterDtId}`, JSON.stringify(payload))
|
|
218
297
|
} catch (e) {}
|
|
298
|
+
|
|
299
|
+
if (Object.keys(payload[rootKey] || {}).length > 0) {
|
|
300
|
+
this.reloadAppDatatable()
|
|
301
|
+
}
|
|
219
302
|
}
|
|
220
303
|
|
|
221
304
|
// recursively populate children of parent, restore each child's saved value, then recurse
|
|
222
305
|
async populateDependents(parent, savedParams = {}) {
|
|
223
|
-
this.selects = this.selects || Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
|
|
224
306
|
const parentKey = parent.dataset.filterFieldName
|
|
225
|
-
const children = this.
|
|
307
|
+
const children = this.remoteSelects.filter(s => s.dataset.filterDependsOn === parentKey)
|
|
226
308
|
|
|
227
309
|
for (const child of children) {
|
|
228
310
|
this.resetSelect(child)
|
|
229
311
|
|
|
230
|
-
|
|
312
|
+
const parentValue = this.getFieldValue(parent)
|
|
313
|
+
if (this.isValueEmpty(parentValue)) {
|
|
231
314
|
await this.populateDependents(child, savedParams)
|
|
232
315
|
continue
|
|
233
316
|
}
|
|
234
317
|
|
|
235
|
-
// populate child using parent's current value substituted by populate()
|
|
236
318
|
await this.populate(child)
|
|
237
|
-
|
|
319
|
+
|
|
238
320
|
const childSaved = savedParams[child.dataset.filterFieldName]
|
|
239
|
-
const
|
|
321
|
+
const hasSetValue = child.dataset.filterSetValue || child.dataset.filterSetValues
|
|
322
|
+
if (childSaved && !hasSetValue) this.setFieldValue(child, childSaved)
|
|
240
323
|
|
|
241
|
-
if (childSaved && !set_value) {
|
|
242
|
-
child.value = childSaved
|
|
243
|
-
}
|
|
244
|
-
// recurse deeper
|
|
245
324
|
await this.populateDependents(child, savedParams)
|
|
246
325
|
}
|
|
247
326
|
}
|
|
248
327
|
|
|
249
|
-
// duration handler (unchanged)
|
|
250
328
|
durationChanged(event) {
|
|
251
329
|
const select = event.target
|
|
252
330
|
|
|
@@ -267,4 +345,4 @@ export default class extends Controller {
|
|
|
267
345
|
document.querySelectorAll('.customDurationField').forEach(el => el.remove())
|
|
268
346
|
}
|
|
269
347
|
}
|
|
270
|
-
}
|
|
348
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
|
|
3
4
|
module StimulusRailsDatatables
|
|
4
5
|
module FilterHelper
|
|
5
6
|
def filter_for(datatable_id, root_key: 'filters', &)
|
|
@@ -39,6 +40,7 @@ module StimulusRailsDatatables
|
|
|
39
40
|
else
|
|
40
41
|
remote = options[:remote] || {}
|
|
41
42
|
depends_on = options[:depends_on]
|
|
43
|
+
multiple = options[:multiple] || false
|
|
42
44
|
|
|
43
45
|
attrs = {
|
|
44
46
|
filter_field_name: name,
|
|
@@ -47,16 +49,22 @@ module StimulusRailsDatatables
|
|
|
47
49
|
filter_value_key: remote[:value],
|
|
48
50
|
filter_depends_on: depends_on&.to_s,
|
|
49
51
|
filter_placeholder: remote[:placeholder] || 'Select an option',
|
|
50
|
-
filter_set_value: remote[:set_value]
|
|
52
|
+
filter_set_value: remote[:set_value],
|
|
53
|
+
filter_set_values: remote[:set_values],
|
|
54
|
+
filter_tomselect: options[:tomselect]
|
|
55
|
+
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
select_options = {
|
|
54
59
|
data: attrs,
|
|
55
|
-
class: html_class
|
|
60
|
+
class: html_class,
|
|
61
|
+
multiple: multiple
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
# Only disable if it depends on another field
|
|
59
65
|
select_options[:disabled] = true if depends_on.present?
|
|
66
|
+
field_name = "#{field_name}[]" if multiple
|
|
67
|
+
|
|
60
68
|
|
|
61
69
|
@view.select_tag(field_name, @view.options_for_select([]), **select_options)
|
|
62
70
|
end
|
|
@@ -94,6 +102,8 @@ module StimulusRailsDatatables
|
|
|
94
102
|
placeholder: 'All Barangays'
|
|
95
103
|
},
|
|
96
104
|
depends_on: :city_id,
|
|
105
|
+
multiple: true,
|
|
106
|
+
tomselect: true,
|
|
97
107
|
class: html_class
|
|
98
108
|
)
|
|
99
109
|
|