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 +7 -0
- data/CHANGELOG.md +19 -0
- data/MIT-LICENSE +21 -0
- data/README.md +147 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/stimulus_rails_datatables/app_datatable.js +34 -0
- data/app/assets/javascripts/stimulus_rails_datatables/datatables_controller.js +123 -0
- data/app/assets/javascripts/stimulus_rails_datatables/filter_controller.js +239 -0
- data/app/datatables/stimulus_rails_datatables/base_datatable.rb +40 -0
- data/app/helpers/stimulus_rails_datatables/datatable_helper.rb +49 -0
- data/app/helpers/stimulus_rails_datatables/filter_helper.rb +136 -0
- data/config/importmap.rb +12 -0
- data/lib/generators/nueca_datatables/install/install_generator.rb +48 -0
- data/lib/generators/nueca_datatables/install/templates/nueca_datatables.rb +18 -0
- data/lib/stimulus_rails_datatables/engine.rb +39 -0
- data/lib/stimulus_rails_datatables/version.rb +5 -0
- data/lib/stimulus_rails_datatables.rb +8 -0
- data/vendor/assets/javascripts/dataTables.bootstrap5.min.js +4 -0
- data/vendor/assets/javascripts/dataTables.min.js +4 -0
- data/vendor/assets/javascripts/dataTables.responsive.min.js +4 -0
- data/vendor/assets/javascripts/responsive.bootstrap5.min.js +4 -0
- metadata +146 -0
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,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
|