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: bbb9263b496e3deed5f28f35c19530d54cdc311a032da2eeeefb60c0c9dfa07d
4
- data.tar.gz: 613bcfbd3ce0dd842d0bf1a3067b91ddb24cb5d773340726ae2851cd17040a15
3
+ metadata.gz: b850688d95d3bbcea4dc97863df1249b3a926b5e686b9b25f840ccd169ce46be
4
+ data.tar.gz: d4b7b87d91f94aa5bd5691e36d20027bbc98c2c672dc47c3fbb1c45dc1b79fd0
5
5
  SHA512:
6
- metadata.gz: 5619dd1eff78003687b588989cad96c353f52309eb184830764a5a42504033a61b5832bfc31fce8ef3fc856af7c4df30ebebc6554c3cfc1bb041b8a8b1c73ca7
7
- data.tar.gz: d24f5d85abafab0b8dd73fd9ca3be0832efad5bdfc60caa98b2f0aa44990390792211432a46124ec6169fc54474787477dcbcb3e3fe282190c9b8d29fe1b6244
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
- // collect selects for later use
34
- this.selects = Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
74
+ disconnect() {
75
+ this.tomSelects?.forEach(ts => ts.destroy())
76
+ this.tomSelects?.clear()
77
+ }
35
78
 
36
- // if there are top-level remote selects we want them available for manual changes
37
- this.selects.forEach(select => {
38
- if (select.dataset.filterDependsOn) {
39
- select.disabled = true
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 !== null && typeof value === 'object') {
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 set_value = select.dataset.filterSetValue || ''
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
- this.resetSelect(select, false)
98
- data.forEach(item => {
99
- const option = document.createElement('option')
100
- option.value = item[valueKey]
101
- option.textContent = item[labelKey]
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.appendChild(option)
109
- })
110
-
111
- select.disabled = false
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
- select.disabled = false
206
+ if (ts) ts.enable()
207
+ else select.disabled = false
115
208
  }
116
209
  }
117
210
 
118
211
  resetSelect(select, disabled = true) {
119
- const placeholder = select.dataset.filterPlaceholder || 'Select'
120
- const option = document.createElement('option')
121
-
122
- option.value = ''
123
- option.textContent = placeholder
124
-
125
- select.innerHTML = ''
126
- select.appendChild(option)
127
- select.value = ''
128
- select.disabled = disabled
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
- if (el.value) params[el.dataset.filterFieldName] = el.value
230
+ const value = this.getFieldValue(el)
231
+ if (!this.isValueEmpty(value)) params[el.dataset.filterFieldName] = value
137
232
  })
138
233
 
139
- const clean = Object.entries(params).reduce((acc, [k, v]) => {
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
- // collect remote selects (as an array)
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 set_value = root.dataset.filterSetValue || ''
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 (use document, bubbles already) so any listener can catch
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.selects.filter(s => s.dataset.filterDependsOn === parentKey)
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
- if (!parent.value) {
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
- // restore child's saved value if exists
319
+
238
320
  const childSaved = savedParams[child.dataset.filterFieldName]
239
- const set_value = child.dataset.filterSetValue || ''
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StimulusRailsDatatables
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stimulus_rails_datatables
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Den Meralpis