stimulus_rails_datatables 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8fb4d9d9a89a2835c85454e5233bbc7c6c842f3edfc773c66cf989f88a1d7360
4
+ data.tar.gz: ed4597b08c8d1a00063f48c963a2f11f7ba96a5436260d5a5a3348cd5f99f8f9
5
+ SHA512:
6
+ metadata.gz: bdc04f63df7a51b0f49b3c179087aa16b9eed8aeea00133806b098a117d455eb1290f615683813cadc49bd45f68112d9282a30aacae1051d28df851ecd095eb4
7
+ data.tar.gz: eb61a469d12d5ee064556c3d947348067e091449e105be7bb04fa33bed8628c18518c5674d62d57599b459ccbccb2c3e9bb510d02ea4d803b14beb60b28c6743
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-11-09
9
+
10
+ ### Added
11
+ - Initial release
12
+ - DataTable helper for creating server-side DataTables
13
+ - Filter helper with advanced filtering capabilities
14
+ - Stimulus controllers for datatables and filters
15
+ - BaseDatatable class for easy backend integration
16
+ - Support for dependent filters and remote data loading
17
+ - LocalStorage state persistence for filters
18
+ - Bootstrap 5 theme support
19
+ - Bundled DataTables.net JavaScript libraries
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Den Meralpis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # StimulusRailsDatatables
2
+
3
+ A comprehensive Rails gem that provides DataTables integration with server-side processing, advanced filtering, and Stimulus controllers for modern Rails applications.
4
+
5
+ ## Features
6
+
7
+ - **Server-side DataTables**: Full integration with ajax-datatables-rails
8
+ - **Advanced Filtering**: Dynamic filters with dependent selects and date ranges
9
+ - **Stimulus Controllers**: Modern JavaScript integration using Hotwire Stimulus
10
+ - **Bootstrap 5 Support**: Beautiful, responsive tables out of the box
11
+ - **LocalStorage State**: Filter and table state persistence
12
+ - **Flexible API**: Easy-to-use Ruby helpers and JavaScript API
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'stimulus_rails_datatables'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ $ bundle install
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Helpers
31
+
32
+ #### Basic DataTable
33
+
34
+ ```ruby
35
+ <%= datatable_for 'users-table', source: users_path do |dt| %>
36
+ <% dt.column :id, title: 'ID' %>
37
+ <% dt.column :name, title: 'Name' %>
38
+ <% dt.column :email, title: 'Email' %>
39
+ <% dt.column :created_at, title: 'Created', orderable: false %>
40
+ <% end %>
41
+ ```
42
+
43
+ #### With Filters
44
+
45
+ ```ruby
46
+ <%= filter_for 'users-table' do |f| %>
47
+ <%= f.status do |opts| %>
48
+ <%= opts.option '', 'All Statuses' %>
49
+ <%= opts.option 'active', 'Active' %>
50
+ <%= opts.option 'inactive', 'Inactive' %>
51
+ <% end %>
52
+
53
+ <%= f.role(
54
+ remote: {
55
+ url: roles_path,
56
+ label: 'name',
57
+ value: 'id',
58
+ placeholder: 'Select Role'
59
+ }
60
+ ) %>
61
+
62
+ <%= f.duration do |opts| %>
63
+ <%= opts.option '', 'All Time' %>
64
+ <%= opts.option 'today', 'Today' %>
65
+ <%= opts.option 'this_week', 'This Week' %>
66
+ <%= opts.option 'custom', 'Custom Range' %>
67
+ <% end %>
68
+ <% end %>
69
+ ```
70
+
71
+ ### Backend DataTable Class
72
+
73
+ ```ruby
74
+ class UserDatatable < StimulusRailsDatatables::BaseDatatable
75
+ def view_columns
76
+ @view_columns ||= {
77
+ id: { source: "User.id" },
78
+ name: { source: "User.name" },
79
+ email: { source: "User.email" },
80
+ created_at: { source: "User.created_at" }
81
+ }
82
+ end
83
+
84
+ def data
85
+ records.map do |record|
86
+ {
87
+ id: record.id,
88
+ name: record.name,
89
+ email: record.email,
90
+ created_at: record.created_at.strftime('%Y-%m-%d')
91
+ }
92
+ end
93
+ end
94
+
95
+ def get_raw_records
96
+ User.all.then { |relation| apply_filters(relation) }
97
+ end
98
+
99
+ private
100
+
101
+ def apply_filters(relation)
102
+ relation = relation.where(status: query_filters[:status]) if query_filters[:status].present?
103
+ relation = relation.where(role_id: query_filters[:role_id]) if query_filters[:role_id].present?
104
+ relation
105
+ end
106
+ end
107
+ ```
108
+
109
+ ### JavaScript API
110
+
111
+ ```javascript
112
+ // Reload a specific datatable
113
+ AppDataTable.reload('#users-table')
114
+
115
+ // Load with new URL
116
+ AppDataTable.load('#users-table', '/users?status=active')
117
+
118
+ // Reload all datatables on page
119
+ AppDataTable.reloadAll()
120
+ ```
121
+
122
+ ## Configuration
123
+
124
+ The gem automatically registers Stimulus controllers and imports required JavaScript dependencies. The following controllers are available:
125
+
126
+ - `datatable` - Main DataTable controller
127
+ - `filter` - Filter controller with state management
128
+
129
+ ## Dependencies
130
+
131
+ - Rails >= 7.0
132
+ - ajax-datatables-rails ~> 1.4
133
+ - importmap-rails
134
+ - @hotwired/stimulus
135
+ - DataTables.net with Bootstrap 5 theme
136
+
137
+ ## Development
138
+
139
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
140
+
141
+ ## Contributing
142
+
143
+ Bug reports and pull requests are welcome on GitHub at https://github.com/denmarkmeralpis/stimulus_rails_datatables.
144
+
145
+ ## License
146
+
147
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,34 @@
1
+ import DataTable from 'datatables.net-responsive-bs5'
2
+
3
+ class AppDataTable {
4
+ constructor(selector, options) {
5
+ // Initialize the DataTable just like the original one
6
+ this.table = new DataTable(selector, options)
7
+ this.id = selector.replace('#', '') // Extract ID from selector
8
+ }
9
+
10
+ // Reload a specific DataTable by ID
11
+ static reload(datatableID) {
12
+ const datatable = new AppDataTable(datatableID).table
13
+
14
+ if (datatable.context.length === 1) {
15
+ datatable.ajax.reload(null, false)
16
+ }
17
+ }
18
+
19
+ // Change the data source & reload
20
+ static load(datatableID, url) {
21
+ const datatable = new AppDataTable(datatableID).table
22
+
23
+ if (datatable.context.length === 1) {
24
+ datatable.ajax.url(url).load()
25
+ }
26
+ }
27
+
28
+ // Reload all DataTables
29
+ static reloadAll() {
30
+ DataTable.tables({ api: true }).ajax.reload(null, false)
31
+ }
32
+ }
33
+
34
+ export { AppDataTable }
@@ -0,0 +1,123 @@
1
+ /* eslint-disable */
2
+ // Disabling eslint since this is a library file and may not conform to all rules
3
+
4
+ import { AppDataTable } from 'stimulus_rails_datatables/app_datatable'
5
+ import { Controller } from '@hotwired/stimulus'
6
+
7
+ export default class extends Controller {
8
+ static values = {
9
+ id: String,
10
+ source: String,
11
+ columns: { type: Array, default: [] },
12
+ order: { type: Array, default: [[1, 'desc']] },
13
+ stateSave: { type: Boolean, default: true },
14
+ serverSide: { type: Boolean, default: true },
15
+ processing: { type: Boolean, default: true },
16
+ pagingType: { type: String, default: 'simple_numbers' },
17
+ searching: { type: Boolean, default: true },
18
+ lengthChange: { type: Boolean, default: true }
19
+ }
20
+
21
+ connect() {
22
+ // try to use saved filters if present, else listen for filters:ready
23
+ const filterEl = document.querySelector('.filter-form[data-filter-root-key]')
24
+ if (filterEl) {
25
+ const dtid = filterEl.dataset.filterDatatableId
26
+ const raw = localStorage.getItem(`filterState:${dtid}`)
27
+ if (raw) {
28
+ try {
29
+ const saved = JSON.parse(raw)
30
+ this.initializeWithParams(saved)
31
+ return
32
+ } catch (e) { /* fallthrough to event listening */ }
33
+ }
34
+
35
+ // if no saved params, wait for filters:ready once
36
+ document.addEventListener('filters:ready', (e) => {
37
+ this.initializeWithParams(e.detail.params)
38
+ }, { once: true })
39
+
40
+ // safety fallback: if no filters:ready arrives, init after 2s with normal source
41
+ this._initFallbackTimer = setTimeout(() => {
42
+ this.initializeDataTable()
43
+ }, 2000)
44
+ } else {
45
+ // no filter form on page — init normally
46
+ this.initializeDataTable()
47
+ }
48
+ }
49
+
50
+ disconnect() {
51
+ if (this._initFallbackTimer) clearTimeout(this._initFallbackTimer)
52
+ }
53
+
54
+ // e.g. params = { filters: { a: 1, b: 2 } }
55
+ initializeWithParams(paramsObj) {
56
+ if (!paramsObj || Object.keys(paramsObj).length === 0) {
57
+ this.initializeDataTable()
58
+ return
59
+ }
60
+
61
+ // if fallback timer set, cancel it
62
+ if (this._initFallbackTimer) {
63
+ clearTimeout(this._initFallbackTimer)
64
+ this._initFallbackTimer = null
65
+ }
66
+
67
+ // build query string (supports nested like filters[a]=1)
68
+ const qs = this.toQuery(paramsObj)
69
+ const base = this.sourceValue || this.source
70
+ const ajaxUrl = qs ? `${base}?${qs}` : base
71
+ this.initializeDataTable(ajaxUrl)
72
+ }
73
+
74
+ initializeDataTable(url = this.sourceValue) {
75
+ const datatableId = this.idValue
76
+ const datatableWrapper = document.getElementById(`${datatableId}_wrapper`)
77
+ let appDataTable = null
78
+
79
+ if (datatableWrapper === null) {
80
+ Turbo.cache.exemptPageFromCache()
81
+
82
+ appDataTable = new AppDataTable(`#${datatableId}`, {
83
+ lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
84
+ searching: this.searchingValue,
85
+ lengthChange: this.lengthChangeValue,
86
+ processing: this.processingValue,
87
+ serverSide: this.serverSideValue,
88
+ stateSave: this.stateSaveValue,
89
+ ajax: url,
90
+ pagingType: this.pagingTypeValue,
91
+ order: this.orderValue,
92
+ columns: this.columnsValue,
93
+ responsive: true,
94
+ language: {
95
+ processing: '<div class="spinner-border"></div><div class="mt-2">Loading...</div>',
96
+ lengthMenu: 'show <span class="px-2">_MENU_</span> entries'
97
+ },
98
+ layout: {
99
+ topStart: 'pageLength',
100
+ topEnd: 'search',
101
+ bottomStart: 'info',
102
+ bottomEnd: 'paging'
103
+ }
104
+ })
105
+ }
106
+
107
+ return appDataTable
108
+ }
109
+
110
+ // helper to serialize nested object like { filters: { a: 1 } } => filters[a]=1
111
+ toQuery(obj, prefix) {
112
+ const pairs = []
113
+ for (const [key, value] of Object.entries(obj)) {
114
+ const fullKey = prefix ? `${prefix}[${key}]` : key
115
+ if (value !== null && typeof value === "object") {
116
+ pairs.push(this.toQuery(value, fullKey))
117
+ } else if (value !== "" && value !== null && value !== undefined) {
118
+ pairs.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(value)}`)
119
+ }
120
+ }
121
+ return pairs.filter(Boolean).join("&")
122
+ }
123
+ }
@@ -0,0 +1,239 @@
1
+ /* eslint-disable */
2
+ // Disabling eslint since this is a library file and may not conform to all rules
3
+
4
+ import { Controller } from '@hotwired/stimulus'
5
+
6
+ export default class extends Controller {
7
+ static targets = ['select', 'customFields']
8
+
9
+ get filterDtId() {
10
+ const today = new Date()
11
+ const key = today.toISOString().split('T')[0]
12
+
13
+ return `${key}:${this.element.dataset.filterDatatableId}`
14
+ }
15
+
16
+ connect() {
17
+ // begin restore; it is async but will dispatch filters:ready when done
18
+ this.restoreState()
19
+
20
+ // single delegated listener — saves and triggers dependent populates
21
+ this.element.addEventListener('change', (event) => {
22
+ if (!event.target.matches('[data-filter-field-name]')) return
23
+
24
+ // persist the user's change
25
+ this.saveState()
26
+
27
+ // if this field has dependents, re-populate them
28
+ this.populateDependents(event.target, this.currentParams()[this.element.dataset.filterRootKey] || {})
29
+
30
+ // trigger datatable reload
31
+ this.reloadAppDatatable()
32
+ })
33
+
34
+ // collect selects for later use
35
+ this.selects = Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
36
+
37
+ // if there are top-level remote selects we want them available for manual changes
38
+ this.selects.forEach(select => {
39
+ if (select.dataset.filterDependsOn) {
40
+ select.disabled = true
41
+ }
42
+ })
43
+ }
44
+
45
+ toQuery(obj, prefix) {
46
+ const pairs = []
47
+
48
+ for (const [key, value] of Object.entries(obj)) {
49
+ const fullKey = prefix ? `${prefix}[${key}]` : key
50
+
51
+ if (value !== null && typeof value === 'object') {
52
+ pairs.push(this.toQuery(value, fullKey))
53
+ }
54
+ else if (value !== '' && value !== null && value !== undefined) {
55
+ pairs.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(value)}`)
56
+ }
57
+ }
58
+
59
+ return pairs.join('&')
60
+ }
61
+
62
+ // reloadAppDatatable reloads the datatable with current params
63
+ async reloadAppDatatable() {
64
+ var id = this.element.dataset.filterDatatableId
65
+
66
+ if (!id) {
67
+ return
68
+ }
69
+ else {
70
+ const datatable = new AppDataTable(`#${id}`).table
71
+ const datatableUrl = datatable.ajax.url().split('?')[0]
72
+ const params = this.toQuery(this.currentParams())
73
+
74
+ datatable.ajax.url(`${datatableUrl}?${params}`).load(null, false)
75
+ }
76
+
77
+ }
78
+
79
+ // async populate returns when options appended
80
+ async populate(select) {
81
+ let url = select.dataset.filterRemoteUrlValue
82
+ const labelKey = select.dataset.filterLabelKey
83
+ const valueKey = select.dataset.filterValueKey
84
+ const placeholder = select.dataset.filterPlaceholder || 'Select'
85
+
86
+ url = decodeURIComponent(url).replace(/{(\w+)}/g, (_, key) => {
87
+ const input = this.element.querySelector(`[data-filter-field-name='${key}']`)
88
+ return input ? input.value : ''
89
+ })
90
+
91
+ if (!url) return
92
+
93
+ try {
94
+ const response = await fetch(url)
95
+ if (!response.ok) throw new Error(`Failed to fetch ${url}`)
96
+ const data = await response.json()
97
+
98
+ select.innerHTML = `<option value="">${placeholder}</option>`
99
+ data.forEach(item => {
100
+ const option = document.createElement('option')
101
+ option.value = item[valueKey]
102
+ option.textContent = item[labelKey]
103
+ select.appendChild(option)
104
+ })
105
+
106
+ select.disabled = false
107
+ } catch (e) {
108
+ console.error('[Filter] fetch error:', e)
109
+ select.disabled = false
110
+ }
111
+ }
112
+
113
+ currentParams() {
114
+ const rootKey = this.element.dataset.filterRootKey
115
+ const params = {}
116
+
117
+ this.element.querySelectorAll('[data-filter-field-name]').forEach(el => {
118
+ if (el.value) params[el.dataset.filterFieldName] = el.value
119
+ })
120
+
121
+ const clean = Object.entries(params).reduce((acc, [k, v]) => {
122
+ if (v !== '' && v !== null && v !== undefined) acc[k] = v
123
+ return acc
124
+ }, {})
125
+
126
+ return { [rootKey]: clean }
127
+ }
128
+
129
+ saveState() {
130
+ const state = this.currentParams()
131
+ // deterministic storage key that datatable can read
132
+ try {
133
+ localStorage.setItem(`filterState:${this.filterDtId}`, JSON.stringify(state))
134
+ } catch (e) {
135
+ // ignore quota errors
136
+ }
137
+ }
138
+
139
+ loadState() {
140
+ try {
141
+ const raw = localStorage.getItem(`filterState:${this.filterDtId}`)
142
+ return raw ? JSON.parse(raw) : {}
143
+ } catch (e) {
144
+ return {}
145
+ }
146
+ }
147
+
148
+ // restoreState waits for all remote populates+restore to finish,
149
+ // then dispatches "filters:ready" with { params: currentParams() }
150
+ async restoreState() {
151
+ const saved = this.loadState()
152
+ const rootKey = this.element.dataset.filterRootKey
153
+ const savedParams = saved[rootKey] || {}
154
+
155
+ // restore simple (non-remote) fields immediately
156
+ Object.entries(savedParams).forEach(([key, value]) => {
157
+ const el = this.element.querySelector(`[data-filter-field-name='${key}']`)
158
+ if (el && !el.dataset.filterRemoteUrlValue) el.value = value
159
+ })
160
+
161
+ // collect remote selects (as an array)
162
+ this.selects = Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
163
+
164
+ // find root remote selects (no depends_on)
165
+ const roots = this.selects.filter(s => !s.dataset.filterDependsOn)
166
+
167
+ // populate each root -> then recursively populate dependents and restore values
168
+ await Promise.all(roots.map(async (root) => {
169
+ await this.populate(root)
170
+ // after root options exist, restore saved root value (if any)
171
+ const sv = savedParams[root.dataset.filterFieldName]
172
+ if (sv) root.value = sv
173
+ // cascade down children
174
+ await this.populateDependents(root, savedParams)
175
+ }))
176
+
177
+ // restore start_date/end_date fields if duration was 'custom'
178
+ if (savedParams['duration'] === 'custom') {
179
+ this.durationChanged({ target: this.element.querySelector('[data-filter-field-name="duration"]') })
180
+ if (savedParams['start_date']) {
181
+ const sd = this.element.querySelector('[data-filter-field-name="start_date"]')
182
+ if (sd) sd.value = savedParams['start_date']
183
+ }
184
+
185
+ if (savedParams['end_date']) {
186
+ const ed = this.element.querySelector('[data-filter-field-name="end_date"]')
187
+ if (ed) ed.value = savedParams['end_date']
188
+ }
189
+ }
190
+
191
+ // emit event (use document, bubbles already) so any listener can catch
192
+ const payload = this.currentParams()
193
+ this.element.dispatchEvent(new CustomEvent('filters:ready', { detail: { params: payload }, bubbles: true }))
194
+
195
+ // also update deterministic storage (in case other code reads it)
196
+ try {
197
+ localStorage.setItem(`filterState:${this.filterDtId}`, JSON.stringify(payload))
198
+ } catch (e) {}
199
+ }
200
+
201
+ // recursively populate children of parent, restore each child's saved value, then recurse
202
+ async populateDependents(parent, savedParams = {}) {
203
+ this.selects = this.selects || Array.from(this.element.querySelectorAll('select[data-filter-remote-url-value]'))
204
+ const parentKey = parent.dataset.filterFieldName
205
+ const children = this.selects.filter(s => s.dataset.filterDependsOn === parentKey)
206
+
207
+ for (const child of children) {
208
+ // populate child using parent's current value substituted by populate()
209
+ await this.populate(child)
210
+ // restore child's saved value if exists
211
+ const childSaved = savedParams[child.dataset.filterFieldName]
212
+ if (childSaved) child.value = childSaved
213
+ // recurse deeper
214
+ await this.populateDependents(child, savedParams)
215
+ }
216
+ }
217
+
218
+ // duration handler (unchanged)
219
+ durationChanged(event) {
220
+ const select = event.target
221
+
222
+ if (select.value === 'custom') {
223
+ const fromDate = `<input type="date"
224
+ name="${select.name.replace('[duration]', '[start_date]')}"
225
+ class="textbox-n form-control form-control-sm mx-2 customDurationField"
226
+ data-filter-field-name="start_date" />`
227
+
228
+ const toDate = `<input type="date"
229
+ name="${select.name.replace('[duration]', '[end_date]')}"
230
+ class="textbox-n form-control form-control-sm customDurationField"
231
+ data-filter-field-name="end_date" />`
232
+
233
+ select.insertAdjacentHTML('afterend', toDate)
234
+ select.insertAdjacentHTML('afterend', fromDate)
235
+ } else {
236
+ document.querySelectorAll('.customDurationField').forEach(el => el.remove())
237
+ }
238
+ }
239
+ }
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusRailsDatatables
4
+ class BaseDatatable < AjaxDatatablesRails::ActiveRecord
5
+ def current_page
6
+ params.fetch(:start, 0).to_i
7
+ end
8
+
9
+ def query_filters
10
+ (filters || {}).symbolize_keys.compact_blank!
11
+ end
12
+
13
+ private
14
+
15
+ # Filters according to the provided arguments with OR condition.
16
+ # @param [Hash{Class => Array<Symbol>] **arguments Filtering objects based on columns.
17
+ # @return [Proc] A lambda function that filters the records based on the provided arguments.
18
+ # :nocov:
19
+ def or_search_by_fields(**arguments)
20
+ lambda do |column, _|
21
+ conditions = []
22
+
23
+ arguments.each do |model, fields|
24
+ arel_table = model.arel_table
25
+ fields.each do |field|
26
+ conditions << arel_table[field].matches("%#{column.search.value}%")
27
+ end
28
+ end
29
+
30
+ conditions.reduce { |combination, condition| combination.or(condition) }
31
+ end
32
+ end
33
+ # :nocov:
34
+
35
+ def filters
36
+ filter_hash = params[:filters]
37
+ filter_hash.permit!.to_h if filter_hash
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusRailsDatatables
4
+ module DatatableHelper
5
+ def datatable_for(id, source:, order: [[2, 'desc']], **options)
6
+ classes = options.fetch(:classes, 'align-middle table w-100')
7
+ searching = options.fetch(:searching, true)
8
+ length_change = options.fetch(:length_change, true)
9
+ columns = []
10
+
11
+ yield DatatableBuilder.new(columns)
12
+
13
+ data = {
14
+ controller: 'datatable',
15
+ datatable_id_value: id,
16
+ datatable_source_value: source,
17
+ datatable_order_value: order.to_json,
18
+ datatable_columns_value: columns.to_json,
19
+ datatable_searching_value: searching,
20
+ datatable_length_change_value: length_change
21
+ }
22
+
23
+ content_tag(:div, data: data) do
24
+ content_tag(:table, class: classes, id: id) do
25
+ content_tag(:thead, class: 'table-light align-middle') do
26
+ content_tag(:tr) do
27
+ safe_join(columns.map do |col|
28
+ content_tag(:th, col[:title] || col[:data].to_s.titleize, class: col[:class])
29
+ end)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ class DatatableBuilder
37
+ attr_reader :columns
38
+
39
+ def initialize(columns)
40
+ @columns = columns
41
+ end
42
+
43
+ def column(data, **options)
44
+ @columns << options.merge(data: data)
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end