power_grid 0.2.16 → 0.3.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 +4 -4
- data/.gitignore +6 -0
- data/README.md +84 -25
- data/app/components/power_grid/table_component.html.erb +22 -77
- data/app/components/power_grid/table_component.rb +122 -6
- data/app/components/power_grid/toolbar_component.html.erb +47 -0
- data/app/components/power_grid/toolbar_component.rb +114 -0
- data/app/javascript/controllers/power_grid/flatpickr_controller.js +23 -0
- data/lib/power_grid/base.rb +67 -21
- data/lib/power_grid/engine.rb +6 -0
- data/lib/power_grid/exporter.rb +32 -0
- data/lib/power_grid/helper.rb +7 -0
- data/lib/power_grid/version.rb +1 -1
- data/lib/power_grid.rb +1 -0
- data/power_grid.gemspec +1 -1
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b24d27601829a61578dd76eac387de4420fbbb2d2eb6ca2132133d132a33b2e1
|
|
4
|
+
data.tar.gz: 9ae6ba1fcd1d96eab8603c37f5650b83f3ba3d4652b03aeb69c3d730fc0e9354
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e31d48d6058e43e0dee373560a3106d318384704c63dd7d268223ada9d16168f5a67ddd48b3474fa4002d2f2a8efe711225725fa5a0e38b9fe10b00d936c547
|
|
7
|
+
data.tar.gz: 66165a1a44b17c8c460f7514a6fe4d940e0812a4cbfab1ee0df7574caff76f83dfda1330ad42822826653e73cba22c169e2814a1663a432b695be49b88d47374
|
data/.gitignore
CHANGED
data/README.md
CHANGED
|
@@ -17,6 +17,10 @@ Built with **ViewComponent**, **Turbo Frames**, **Stimulus**, and **TailwindCSS*
|
|
|
17
17
|
- Multi-column search support.
|
|
18
18
|
- Search on **joined tables** via `sql_expression`.
|
|
19
19
|
- Automatic input debouncing.
|
|
20
|
+
- 🎛 **Advanced Filtering**:
|
|
21
|
+
- Support for **Search**, **Select**, **Checkboxes**, and **Radio Buttons**.
|
|
22
|
+
- Dynamic collections using Lambdas (e.g., `-> { User.all }`).
|
|
23
|
+
- Custom filtering logic via blocks.
|
|
20
24
|
- 📄 **Smart Pagination**:
|
|
21
25
|
- Numbered pagination window (e.g., `1 2 ... 5 6 7 ... 10`).
|
|
22
26
|
- Dynamic **Per Page** limits.
|
|
@@ -24,6 +28,9 @@ Built with **ViewComponent**, **Turbo Frames**, **Stimulus**, and **TailwindCSS*
|
|
|
24
28
|
- 🎨 **Visuals**:
|
|
25
29
|
- Professional "Slate" color palette.
|
|
26
30
|
- Fully responsive and Dark Mode compatible.
|
|
31
|
+
- **Customizable CSS** classes.
|
|
32
|
+
- 🛠 **Modular Toolbar**:
|
|
33
|
+
- Render the toolbar (search/filters) separately from the table for flexible layouts.
|
|
27
34
|
- 🚀 **Optimization**: Built-in support for `includes` to automatically prevent N+1 queries.
|
|
28
35
|
|
|
29
36
|
## Installation
|
|
@@ -44,7 +51,7 @@ $ bundle install
|
|
|
44
51
|
|
|
45
52
|
### 1. Define your Grid
|
|
46
53
|
|
|
47
|
-
Create a class that inherits from `PowerGrid::Base`. This class defines your data source and
|
|
54
|
+
Create a class that inherits from `PowerGrid::Base`. This class defines your data source, columns, and filters.
|
|
48
55
|
|
|
49
56
|
```ruby
|
|
50
57
|
# app/grids/users_grid.rb
|
|
@@ -59,6 +66,14 @@ class UsersGrid < PowerGrid::Base
|
|
|
59
66
|
column :status do |user|
|
|
60
67
|
tag.span(user.status.humanize, class: "badge badge-#{user.status}")
|
|
61
68
|
end
|
|
69
|
+
|
|
70
|
+
# Filters
|
|
71
|
+
filter :role, collection: ["Admin", "User"], include_blank: "All Roles"
|
|
72
|
+
|
|
73
|
+
# Advanced Filter (Checkbox with custom logic)
|
|
74
|
+
filter :department_ids, type: :checkbox, collection: -> { Department.pluck(:name, :id) } do |scope, value|
|
|
75
|
+
scope.where(department_id: value)
|
|
76
|
+
end
|
|
62
77
|
end
|
|
63
78
|
```
|
|
64
79
|
|
|
@@ -84,36 +99,85 @@ Render the `PowerGrid::TableComponent`, passing the grid instance.
|
|
|
84
99
|
<%= render PowerGrid::TableComponent.new(@grid) %>
|
|
85
100
|
```
|
|
86
101
|
|
|
87
|
-
##
|
|
102
|
+
## Advanced Filtering
|
|
88
103
|
|
|
89
|
-
|
|
104
|
+
PowerGrid supports rich filtering options beyond simple text search.
|
|
90
105
|
|
|
91
|
-
|
|
106
|
+
### Checkboxes (Multi-select)
|
|
107
|
+
Use `type: :checkbox` to render a list of checkboxes.
|
|
92
108
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
```ruby
|
|
110
|
+
filter :category_ids, header: "Categories", type: :checkbox, collection: -> { Category.pluck(:name, :id) }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Radio Buttons (Single-select)
|
|
114
|
+
Use `type: :radio` to render radio buttons.
|
|
99
115
|
|
|
100
|
-
**Example: Joined Column**
|
|
101
116
|
```ruby
|
|
102
|
-
|
|
103
|
-
searchable: true,
|
|
104
|
-
sortable: true,
|
|
105
|
-
sql_expression: "posts.title",
|
|
106
|
-
includes: :posts
|
|
117
|
+
filter :active, type: :radio, collection: [["Yes", true], ["No", false]]
|
|
107
118
|
```
|
|
108
119
|
|
|
109
|
-
###
|
|
120
|
+
### Dynamic Collections
|
|
121
|
+
Pass a `lambda` or `Proc` to `collection:` to load options dynamically at request time.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
filter :manager_id, collection: -> { User.where(role: 'manager').pluck(:name, :id) }
|
|
125
|
+
```
|
|
110
126
|
|
|
111
|
-
|
|
127
|
+
### Custom Filtering Logic
|
|
128
|
+
Pass a block to `filter` to customize how the query is modified.
|
|
112
129
|
|
|
113
130
|
```ruby
|
|
114
|
-
|
|
131
|
+
filter :query_date, type: :text do |scope, value|
|
|
132
|
+
# Parse date string and filter range
|
|
133
|
+
date = Date.parse(value) rescue nil
|
|
134
|
+
date ? scope.where(created_at: date.all_day) : scope
|
|
135
|
+
end
|
|
115
136
|
```
|
|
116
137
|
|
|
138
|
+
## CSS Customization
|
|
139
|
+
|
|
140
|
+
PowerGrid uses TailwindCSS by default but allows you to override classes for deep customization. Pass a `css:` hash to the component.
|
|
141
|
+
|
|
142
|
+
```erb
|
|
143
|
+
<%= render PowerGrid::TableComponent.new(@grid, css: {
|
|
144
|
+
table: "min-w-full divide-y divide-gray-200 border border-gray-300",
|
|
145
|
+
th: "px-6 py-3 bg-blue-50 text-left text-xs font-medium text-blue-500 uppercase tracking-wider",
|
|
146
|
+
tr: "bg-white hover:bg-blue-50 transition-colors duration-150"
|
|
147
|
+
}) %>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Available keys in `DEFAULT_CSS`: `container`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `pagination`, `page_link`, `page_link_active`, `page_prev`, `page_next`, `page_gap`, `search_input`, `filter_select`, `filter_input`.
|
|
151
|
+
|
|
152
|
+
## Modular Toolbar
|
|
153
|
+
|
|
154
|
+
You can render the toolbar (Search, Filters, Per Page) separately from the table. This is useful for placing filters in a sidebar or sticky header.
|
|
155
|
+
|
|
156
|
+
1. Disable the built-in toolbar in `TableComponent`:
|
|
157
|
+
```erb
|
|
158
|
+
<%= render PowerGrid::TableComponent.new(@grid, toolbar: false) %>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
2. Render `ToolbarComponent` where you want it:
|
|
162
|
+
```erb
|
|
163
|
+
<div class="sticky top-0 bg-white z-10 shadow p-4">
|
|
164
|
+
<%= render PowerGrid::ToolbarComponent.new(@grid) %>
|
|
165
|
+
</div>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Detailed API Reference
|
|
169
|
+
|
|
170
|
+
### `column(name, options = {}, &block)`
|
|
171
|
+
|
|
172
|
+
Defines a column in the table.
|
|
173
|
+
|
|
174
|
+
| Option | Type | Description |
|
|
175
|
+
| :--- | :--- | :--- |
|
|
176
|
+
| `sortable` | `boolean` | If true, the column header will be clickable to sort. |
|
|
177
|
+
| `searchable` | `boolean` | If true, the global search input will check this column using `LIKE`. |
|
|
178
|
+
| `sql_expression` | `string` | The raw SQL column name/expression to use for sorting/searching. Essential for joined tables (e.g., `"posts.title"`). |
|
|
179
|
+
| `includes` | `symbol/array` | Association(s) to eager load when rendering this column to prevent N+1 queries. |
|
|
180
|
+
|
|
117
181
|
### `initialize(params, initial_scope: nil)`
|
|
118
182
|
|
|
119
183
|
- `params`: The Rails `params` hash (required for sorting/filtering state).
|
|
@@ -123,18 +187,13 @@ scope { User.active.order(created_at: :desc) }
|
|
|
123
187
|
|
|
124
188
|
### TailwindCSS
|
|
125
189
|
|
|
126
|
-
PowerGrid creates HTML with standard Tailwind utility classes (using the `slate` color palette). Ensure your Tailwind configuration scans your gem paths
|
|
127
|
-
- Colors: `slate-50` to `slate-900`.
|
|
128
|
-
- Spacing, Borders, Flexbox, Typography.
|
|
190
|
+
PowerGrid creates HTML with standard Tailwind utility classes (using the `slate` color palette). Ensure your Tailwind configuration scans your gem paths.
|
|
129
191
|
|
|
130
192
|
### Hotwire & Stimulus
|
|
131
193
|
|
|
132
194
|
Ensure your application has `turbo-rails` and `stimulus-rails` installed.
|
|
133
195
|
The gem includes a Stimulus controller `power_grid--table_controller` which handles search input debouncing and auto-submission.
|
|
134
196
|
|
|
135
|
-
If using **Importmap** (default in Rails 7+), this is configured automatically.
|
|
136
|
-
If using **esbuild/webpack**, manually register the controller if needed, or ensure the gem's assets are in your load path.
|
|
137
|
-
|
|
138
197
|
## Contributing
|
|
139
198
|
|
|
140
199
|
Bug reports and pull requests are welcome on GitHub at https://github.com/bhavinNandani/power_grid.
|
|
@@ -1,77 +1,22 @@
|
|
|
1
|
-
<div class="
|
|
1
|
+
<div class="<%= css_class(:container) %>" data-controller="power-grid-table">
|
|
2
2
|
|
|
3
3
|
<!-- Clean Toolbar -->
|
|
4
|
-
<%
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<!-- Search -->
|
|
8
|
-
<div class="w-full md:w-72">
|
|
9
|
-
<% if @grid.searchable? %>
|
|
10
|
-
<div class="relative">
|
|
11
|
-
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
12
|
-
<svg class="h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
13
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
14
|
-
</svg>
|
|
15
|
-
</div>
|
|
16
|
-
<%= text_field_tag :q, params[:q], placeholder: "Search...",
|
|
17
|
-
class: "block w-full pl-12 pr-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg leading-5 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",
|
|
18
|
-
data: { action: "input->power-grid-table#search" },
|
|
19
|
-
form: "power_grid_form_#{request.path.parameterize}"
|
|
20
|
-
%>
|
|
21
|
-
</div>
|
|
22
|
-
<% end %>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
|
-
<!-- Filters & Actions -->
|
|
26
|
-
<div class="flex items-center gap-3">
|
|
27
|
-
<%= form_with url: request.path, method: :get, data: { turbo_frame: "power_grid_table", turbo_action: "advance", power_grid_table_target: "form" }, class: "flex items-center gap-3", id: "power_grid_form_#{request.path.parameterize}" do |f| %>
|
|
28
|
-
<% params.except(:per_page, :page, :q, *(@grid.class.defined_filters.keys)).each do |key, value| %>
|
|
29
|
-
<%= hidden_field_tag key, value %>
|
|
30
|
-
<% end %>
|
|
31
|
-
|
|
32
|
-
<!-- Filters -->
|
|
33
|
-
<% @grid.class.defined_filters.each do |name, options| %>
|
|
34
|
-
<div class="relative">
|
|
35
|
-
<% if options[:collection] %>
|
|
36
|
-
<%= select_tag name, options_for_select(options[:collection], params[name]),
|
|
37
|
-
include_blank: "All #{name.to_s.humanize.pluralize}",
|
|
38
|
-
class: "block w-full pl-3 pr-10 py-2 text-base border-slate-300 dark:border-slate-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-lg bg-white dark:bg-slate-800",
|
|
39
|
-
onchange: "this.form.requestSubmit()"
|
|
40
|
-
%>
|
|
41
|
-
<% else %>
|
|
42
|
-
<%= text_field_tag name, params[name], placeholder: name.to_s.humanize,
|
|
43
|
-
class: "block w-full pl-3 pr-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg leading-5 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",
|
|
44
|
-
data: { action: "input->power-grid-table#search" }
|
|
45
|
-
%>
|
|
46
|
-
<% end %>
|
|
47
|
-
</div>
|
|
48
|
-
<% end %>
|
|
49
|
-
|
|
50
|
-
<!-- Per Page -->
|
|
51
|
-
<div>
|
|
52
|
-
<%= select_tag :per_page, options_for_select([10, 25, 50, 100], @grid.per_page),
|
|
53
|
-
class: "block w-full pl-3 pr-10 py-2 text-base border-slate-300 dark:border-slate-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-lg bg-white dark:bg-slate-800",
|
|
54
|
-
onchange: "this.form.requestSubmit()"
|
|
55
|
-
%>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<% end %>
|
|
59
|
-
</div>
|
|
60
|
-
</div>
|
|
4
|
+
<% if show_toolbar? %>
|
|
5
|
+
<%= render PowerGrid::ToolbarComponent.new(@grid, css: @css, headless: @headless) %>
|
|
61
6
|
<% end %>
|
|
62
7
|
|
|
63
8
|
<%= turbo_frame_tag "power_grid_table", class: "w-full block" do %>
|
|
64
9
|
<div class="overflow-x-auto">
|
|
65
|
-
<table class="
|
|
66
|
-
<thead class="
|
|
10
|
+
<table class="<%= css_class(:table) %>">
|
|
11
|
+
<thead class="<%= css_class(:thead) %>">
|
|
67
12
|
<tr>
|
|
68
13
|
<% columns.each_with_index do |(key, options), idx| %>
|
|
69
|
-
<th scope="col" class="
|
|
14
|
+
<th scope="col" class="<%= css_class(:th) %>">
|
|
70
15
|
<% label = options[:header] || key.to_s.humanize %>
|
|
71
16
|
<% if options[:sortable] %>
|
|
72
|
-
<%= link_to request.params.merge(order: key, dir: (params[:order] == key.to_s && params[:dir] == "asc" ? "desc" : "asc")), class: "group inline-flex items-center space-x-1 hover:text-
|
|
17
|
+
<%= link_to request.params.merge(order: key, dir: (params[:order] == key.to_s && params[:dir] == "asc" ? "desc" : "asc")), class: "group inline-flex items-center space-x-1 hover:text-gray-700 dark:hover:text-gray-200", data: { turbo_action: "advance" } do %>
|
|
73
18
|
<span><%= label %></span>
|
|
74
|
-
<span class="flex-none rounded text-
|
|
19
|
+
<span class="flex-none rounded text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300">
|
|
75
20
|
<% if params[:order] == key.to_s %>
|
|
76
21
|
<% if params[:dir] == "asc" %>
|
|
77
22
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z" clip-rule="evenodd" /></svg>
|
|
@@ -90,11 +35,11 @@
|
|
|
90
35
|
<% end %>
|
|
91
36
|
</tr>
|
|
92
37
|
</thead>
|
|
93
|
-
<tbody class="
|
|
38
|
+
<tbody class="<%= css_class(:tbody) %>">
|
|
94
39
|
<% records.each_with_index do |record, index| %>
|
|
95
|
-
<tr class="
|
|
40
|
+
<tr class="<%= css_class(:tr) %>">
|
|
96
41
|
<% columns.each do |key, options| %>
|
|
97
|
-
<td class="
|
|
42
|
+
<td class="<%= css_class(:td) %> <%= options[:class] %>">
|
|
98
43
|
<% if options[:block] %>
|
|
99
44
|
<%= options[:block].call(record) %>
|
|
100
45
|
<% else %>
|
|
@@ -106,15 +51,15 @@
|
|
|
106
51
|
<% end %>
|
|
107
52
|
<% if records.empty? %>
|
|
108
53
|
<tr>
|
|
109
|
-
<td colspan="<%= columns.size %>" class="px-6 py-12 text-center text-sm text-
|
|
54
|
+
<td colspan="<%= columns.size %>" class="px-6 py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
110
55
|
<div class="flex flex-col items-center justify-center space-y-3">
|
|
111
|
-
<div style="height: 48px; width: 48px;" class="mx-auto text-
|
|
56
|
+
<div style="height: 48px; width: 48px;" class="mx-auto text-gray-300">
|
|
112
57
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" width="48" height="48" style="min-width: 48px; min-height: 48px;">
|
|
113
58
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
114
59
|
</svg>
|
|
115
60
|
</div>
|
|
116
|
-
<p class="text-base font-medium text-
|
|
117
|
-
<p class="text-
|
|
61
|
+
<p class="text-base font-medium text-gray-900 dark:text-white">No records found</p>
|
|
62
|
+
<p class="text-gray-400">Try adjusting your search or filters</p>
|
|
118
63
|
</div>
|
|
119
64
|
</td>
|
|
120
65
|
</tr>
|
|
@@ -125,9 +70,9 @@
|
|
|
125
70
|
|
|
126
71
|
<!-- Clean Pagination Footer -->
|
|
127
72
|
<% if @grid.total_pages > 1 %>
|
|
128
|
-
<div class="
|
|
73
|
+
<div class="<%= css_class(:pagination) %>">
|
|
129
74
|
<!-- Mobile Pagination help text -->
|
|
130
|
-
<div class="text-sm text-
|
|
75
|
+
<div class="text-sm text-gray-700 dark:text-gray-400 hidden sm:block">
|
|
131
76
|
Showing <span class="font-medium"><%= @grid.current_page %></span> of <span class="font-medium"><%= @grid.total_pages %></span> pages
|
|
132
77
|
</div>
|
|
133
78
|
|
|
@@ -136,7 +81,7 @@
|
|
|
136
81
|
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
|
137
82
|
<!-- Prev -->
|
|
138
83
|
<%= link_to (@grid.current_page > 1 ? request.params.merge(page: @grid.current_page - 1) : "#"),
|
|
139
|
-
class:
|
|
84
|
+
class: css_class(@grid.current_page > 1 ? :page_prev : :page_prev_disabled),
|
|
140
85
|
disabled: @grid.current_page <= 1,
|
|
141
86
|
data: { turbo_action: "advance" } do %>
|
|
142
87
|
<span class="sr-only">Previous</span>
|
|
@@ -147,17 +92,17 @@
|
|
|
147
92
|
|
|
148
93
|
<% @grid.pagination_window.each do |page| %>
|
|
149
94
|
<% if page == :gap %>
|
|
150
|
-
<span class="
|
|
95
|
+
<span class="<%= css_class(:page_gap) %>">...</span>
|
|
151
96
|
<% elsif page == @grid.current_page %>
|
|
152
|
-
<span class="
|
|
97
|
+
<span class="<%= css_class(:page_link_active) %>"><%= page %></span>
|
|
153
98
|
<% else %>
|
|
154
|
-
<%= link_to page, request.params.merge(page: page), class:
|
|
99
|
+
<%= link_to page, request.params.merge(page: page), class: css_class(:page_link), data: { turbo_action: "advance" } %>
|
|
155
100
|
<% end %>
|
|
156
101
|
<% end %>
|
|
157
102
|
|
|
158
103
|
<!-- Next -->
|
|
159
104
|
<%= link_to (@grid.current_page < @grid.total_pages ? request.params.merge(page: @grid.current_page + 1) : "#"),
|
|
160
|
-
class:
|
|
105
|
+
class: css_class(@grid.current_page < @grid.total_pages ? :page_next : :page_next_disabled),
|
|
161
106
|
disabled: @grid.current_page >= @grid.total_pages,
|
|
162
107
|
data: { turbo_action: "advance" } do %>
|
|
163
108
|
<span class="sr-only">Next</span>
|
|
@@ -4,13 +4,57 @@ module PowerGrid
|
|
|
4
4
|
class TableComponent < ViewComponent::Base
|
|
5
5
|
include Turbo::FramesHelper
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
DEFAULT_CSS = {
|
|
8
|
+
container: "w-full bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 overflow-hidden ring-1 ring-black/5",
|
|
9
|
+
toolbar: "flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900",
|
|
10
|
+
search_input: "block w-full rounded-lg border-0 py-1.5 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 bg-white dark:bg-gray-950 transition-shadow",
|
|
11
|
+
filter_select: "block w-full rounded-lg border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 bg-white dark:bg-gray-950 transition-shadow",
|
|
12
|
+
filter_input: "block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 bg-white dark:bg-gray-950 transition-shadow",
|
|
13
|
+
table: "min-w-full divide-y divide-gray-200 dark:divide-gray-800",
|
|
14
|
+
thead: "bg-gray-50 dark:bg-gray-800/50",
|
|
15
|
+
th: "px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider",
|
|
16
|
+
tbody: "divide-y divide-gray-200 dark:divide-gray-800 bg-white dark:bg-gray-900",
|
|
17
|
+
tr: "hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors duration-150 ease-in-out group",
|
|
18
|
+
td: "px-4 py-3 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300",
|
|
19
|
+
pagination: "flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900",
|
|
20
|
+
pagination_summary: "text-sm text-gray-700 dark:text-gray-400 hidden sm:block",
|
|
21
|
+
page_link: "relative inline-flex items-center px-3 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 focus:z-20 focus:outline-offset-0 rounded-lg mx-0.5 transition-all",
|
|
22
|
+
page_link_active: "relative z-10 inline-flex items-center px-3 py-1.5 text-sm font-semibold text-white bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 rounded-lg mx-0.5 shadow-sm",
|
|
23
|
+
page_prev: "relative inline-flex items-center rounded-l-lg px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 focus:z-20 focus:outline-offset-0 mr-1 transition-all",
|
|
24
|
+
page_prev_disabled: "relative inline-flex items-center rounded-l-lg px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-200 dark:ring-gray-800 cursor-not-allowed mr-1 bg-gray-50 dark:bg-gray-900/50",
|
|
25
|
+
page_next: "relative inline-flex items-center rounded-r-lg px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 focus:z-20 focus:outline-offset-0 ml-1 transition-all",
|
|
26
|
+
page_next_disabled: "relative inline-flex items-center rounded-r-lg px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-200 dark:ring-gray-800 cursor-not-allowed ml-1 bg-gray-50 dark:bg-gray-900/50",
|
|
27
|
+
page_gap: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 rounded-lg mx-0.5"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :headless
|
|
31
|
+
|
|
32
|
+
def initialize(grid_or_class, params: nil, css: {}, toolbar: true, headless: false)
|
|
33
|
+
if grid_or_class.is_a?(Class)
|
|
34
|
+
@grid = grid_or_class.new(params)
|
|
35
|
+
else
|
|
36
|
+
@grid = grid_or_class
|
|
37
|
+
end
|
|
38
|
+
@show_toolbar = toolbar
|
|
39
|
+
@headless = headless
|
|
40
|
+
if @headless
|
|
41
|
+
@css = css
|
|
42
|
+
else
|
|
43
|
+
@css = DEFAULT_CSS.merge(css)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def show_toolbar?
|
|
48
|
+
@show_toolbar
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def css_class(key)
|
|
52
|
+
if headless
|
|
53
|
+
@css[key]
|
|
54
|
+
else
|
|
55
|
+
@css[key] || DEFAULT_CSS[key]
|
|
56
|
+
end
|
|
12
57
|
end
|
|
13
|
-
end
|
|
14
58
|
|
|
15
59
|
def columns
|
|
16
60
|
@grid.class.defined_columns
|
|
@@ -20,6 +64,78 @@ module PowerGrid
|
|
|
20
64
|
@grid.records
|
|
21
65
|
end
|
|
22
66
|
|
|
67
|
+
def render_filter(name, options)
|
|
68
|
+
label = options[:header] || options[:label] || name.to_s.humanize
|
|
69
|
+
value = @grid.params[name]
|
|
70
|
+
|
|
71
|
+
collection = options[:collection]
|
|
72
|
+
collection = collection.call if collection.respond_to?(:call)
|
|
73
|
+
|
|
74
|
+
if options[:type].to_s == "checkbox" && collection
|
|
75
|
+
# Checkbox Logic
|
|
76
|
+
# ...
|
|
77
|
+
current_values = Array(value).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
content_tag(:div, class: "flex flex-col gap-1") do
|
|
80
|
+
concat content_tag(:span, label, class: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1")
|
|
81
|
+
content_tag(:div, class: "flex flex-wrap gap-2") do
|
|
82
|
+
collection.each do |item|
|
|
83
|
+
# Handle collection item being [label, value] or just value
|
|
84
|
+
display, val = item.is_a?(Array) ? item : [item.to_s.humanize, item]
|
|
85
|
+
checked = current_values.include?(val.to_s)
|
|
86
|
+
|
|
87
|
+
concat(
|
|
88
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
89
|
+
concat check_box_tag("#{name}[]", val, checked, class: "form-checkbox rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
90
|
+
concat content_tag(:span, display, class: "text-sm text-gray-700 dark:text-gray-300")
|
|
91
|
+
end
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
elsif options[:type].to_s == "radio" && collection
|
|
98
|
+
# Radio Logic
|
|
99
|
+
content_tag(:div, class: "flex flex-col gap-1") do
|
|
100
|
+
concat content_tag(:span, label, class: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1")
|
|
101
|
+
content_tag(:div, class: "flex flex-wrap gap-2") do
|
|
102
|
+
# Add "All" option for Radio
|
|
103
|
+
all_checked = value.blank?
|
|
104
|
+
concat(
|
|
105
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
106
|
+
concat radio_button_tag(name, "", all_checked, class: "form-radio text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
107
|
+
concat content_tag(:span, "All", class: "text-sm text-gray-700 dark:text-gray-300")
|
|
108
|
+
end
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
collection.each do |item|
|
|
112
|
+
display, val = item.is_a?(Array) ? item : [item.to_s.humanize, item]
|
|
113
|
+
checked = value.to_s == val.to_s
|
|
114
|
+
|
|
115
|
+
concat(
|
|
116
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
117
|
+
concat radio_button_tag(name, val, checked, class: "form-radio text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
118
|
+
concat content_tag(:span, display, class: "text-sm text-gray-700 dark:text-gray-300")
|
|
119
|
+
end
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
elsif collection
|
|
126
|
+
# Select Logic (Default for collections)
|
|
127
|
+
select_tag name, options_for_select(collection, value),
|
|
128
|
+
include_blank: "All #{name.to_s.humanize.pluralize}",
|
|
129
|
+
class: css_class(:filter_select),
|
|
130
|
+
onchange: "this.form.requestSubmit()"
|
|
131
|
+
else
|
|
132
|
+
# Text Input Logic
|
|
133
|
+
text_field_tag name, value, placeholder: label,
|
|
134
|
+
class: css_class(:filter_input),
|
|
135
|
+
data: { action: "input->power-grid-table#search" }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
23
139
|
def sort_url(column, direction)
|
|
24
140
|
# TODO: Helper to generate URL with updated params
|
|
25
141
|
# For now just return hash or similar, we might need a helper that merges params
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
|
|
2
|
+
<% unless @grid.try(:hide_controls) %>
|
|
3
|
+
<div class="<%= css_class(:container) %>">
|
|
4
|
+
|
|
5
|
+
<!-- Search -->
|
|
6
|
+
<div class="w-full md:w-72">
|
|
7
|
+
<% if @grid.searchable? %>
|
|
8
|
+
<div class="relative">
|
|
9
|
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
10
|
+
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
11
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
12
|
+
</svg>
|
|
13
|
+
</div>
|
|
14
|
+
<%= text_field_tag :q, @grid.params[:q], placeholder: "Search...",
|
|
15
|
+
class: css_class(:search_input),
|
|
16
|
+
data: { action: "input->power-grid-table#search" },
|
|
17
|
+
form: "power_grid_form_#{request.path.parameterize}"
|
|
18
|
+
%>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- Filters & Actions -->
|
|
24
|
+
<div class="flex items-center gap-3">
|
|
25
|
+
<%= form_with url: request.path, method: :get, data: { turbo_frame: "power_grid_table", turbo_action: "advance", power_grid_table_target: "form" }, class: "flex items-center gap-3", id: "power_grid_form_#{request.path.parameterize}" do |f| %>
|
|
26
|
+
<% @grid.params.except(:per_page, :page, :q, *(@grid.class.defined_filters.keys)).each do |key, value| %>
|
|
27
|
+
<%= hidden_field_tag key, value %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<!-- Filters -->
|
|
31
|
+
<% @grid.class.defined_filters.each do |name, options| %>
|
|
32
|
+
<div class="relative">
|
|
33
|
+
<%= render_filter(name, options) %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<!-- Per Page -->
|
|
38
|
+
<div>
|
|
39
|
+
<%= select_tag :per_page, options_for_select([10, 25, 50, 100], @grid.per_page),
|
|
40
|
+
class: css_class(:filter_select),
|
|
41
|
+
onchange: "this.form.requestSubmit()"
|
|
42
|
+
%>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "view_component"
|
|
2
|
+
|
|
3
|
+
module PowerGrid
|
|
4
|
+
class ToolbarComponent < ViewComponent::Base
|
|
5
|
+
include Turbo::FramesHelper
|
|
6
|
+
|
|
7
|
+
DEFAULT_CSS = {
|
|
8
|
+
container: "flex flex-col md:flex-row md:items-center justify-between gap-4 p-4",
|
|
9
|
+
search_input: "block w-full pl-12 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg leading-5 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",
|
|
10
|
+
filter_select: "block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-lg bg-white dark:bg-gray-800",
|
|
11
|
+
filter_input: "block w-full pl-3 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg leading-5 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(grid, css: {}, headless: false)
|
|
15
|
+
@grid = grid
|
|
16
|
+
@css = css
|
|
17
|
+
@headless = headless
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def css_class(key)
|
|
21
|
+
if @headless
|
|
22
|
+
@css[key]
|
|
23
|
+
else
|
|
24
|
+
@css[key] || DEFAULT_CSS[key]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_filter(name, options)
|
|
29
|
+
label = options[:header] || options[:label] || name.to_s.humanize
|
|
30
|
+
value = @grid.params[name] || @grid.params[name.to_s]
|
|
31
|
+
|
|
32
|
+
collection = options[:collection]
|
|
33
|
+
collection = collection.call if collection.respond_to?(:call)
|
|
34
|
+
|
|
35
|
+
filter_type = options[:type].to_s.downcase
|
|
36
|
+
|
|
37
|
+
if filter_type == "checkbox" && collection
|
|
38
|
+
current_values = Array(value).map(&:to_s)
|
|
39
|
+
content_tag(:div, class: "flex flex-col gap-1") do
|
|
40
|
+
concat content_tag(:span, label, class: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1")
|
|
41
|
+
concat(content_tag(:div, class: "flex flex-wrap gap-2") do
|
|
42
|
+
collection.each do |item|
|
|
43
|
+
# Handle collection item being [label, value] or just value
|
|
44
|
+
display, val = item.is_a?(Array) ? item : [item.to_s.humanize, item]
|
|
45
|
+
checked = current_values.include?(val.to_s)
|
|
46
|
+
concat(
|
|
47
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
48
|
+
concat check_box_tag("#{name}[]", val, checked, class: "form-checkbox rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
49
|
+
concat content_tag(:span, display, class: "text-sm text-gray-700 dark:text-gray-300")
|
|
50
|
+
end
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end)
|
|
55
|
+
end
|
|
56
|
+
elsif filter_type == "radio" && collection
|
|
57
|
+
content_tag(:div, class: "flex flex-col gap-1") do
|
|
58
|
+
concat content_tag(:span, label, class: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1")
|
|
59
|
+
concat(content_tag(:div, class: "flex flex-wrap gap-2") do
|
|
60
|
+
all_checked = value.blank?
|
|
61
|
+
concat(
|
|
62
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
63
|
+
concat radio_button_tag(name, "", all_checked, class: "form-radio text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
64
|
+
concat content_tag(:span, "All", class: "text-sm text-gray-700 dark:text-gray-300")
|
|
65
|
+
end
|
|
66
|
+
)
|
|
67
|
+
collection.each do |item|
|
|
68
|
+
display, val = item.is_a?(Array) ? item : [item.to_s.humanize, item]
|
|
69
|
+
checked = value.to_s == val.to_s
|
|
70
|
+
concat(
|
|
71
|
+
content_tag(:label, class: "inline-flex items-center space-x-2 cursor-pointer") do
|
|
72
|
+
concat radio_button_tag(name, val, checked, class: "form-radio text-indigo-600 focus:ring-indigo-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800", onchange: "this.form.requestSubmit()")
|
|
73
|
+
concat content_tag(:span, display, class: "text-sm text-gray-700 dark:text-gray-300")
|
|
74
|
+
end
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
nil
|
|
78
|
+
end)
|
|
79
|
+
end
|
|
80
|
+
elsif filter_type == "number_range"
|
|
81
|
+
min_value = @grid.params["#{name}_min"]
|
|
82
|
+
max_value = @grid.params["#{name}_max"]
|
|
83
|
+
content_tag(:div, class: "flex flex-col gap-1") do
|
|
84
|
+
concat content_tag(:span, label, class: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1")
|
|
85
|
+
concat(content_tag(:div, class: "flex items-center space-x-2") do
|
|
86
|
+
concat text_field_tag("#{name}_min", min_value, placeholder: "Min", class: css_class(:filter_input), data: { action: "input->power-grid-table#search" })
|
|
87
|
+
concat content_tag(:span, "-", class: "text-gray-500")
|
|
88
|
+
concat text_field_tag("#{name}_max", max_value, placeholder: "Max", class: css_class(:filter_input), data: { action: "input->power-grid-table#search" })
|
|
89
|
+
end)
|
|
90
|
+
end
|
|
91
|
+
elsif filter_type == "boolean"
|
|
92
|
+
boolean_options = [["Yes", "true"], ["No", "false"]]
|
|
93
|
+
select_tag name, options_for_select(boolean_options, value),
|
|
94
|
+
include_blank: "All #{name.to_s.humanize.pluralize}",
|
|
95
|
+
class: css_class(:filter_select),
|
|
96
|
+
onchange: "this.form.requestSubmit()"
|
|
97
|
+
elsif filter_type == "date_range"
|
|
98
|
+
text_field_tag name, value,
|
|
99
|
+
placeholder: label,
|
|
100
|
+
class: css_class(:filter_input),
|
|
101
|
+
data: { controller: "power-grid--flatpickr" }
|
|
102
|
+
elsif collection
|
|
103
|
+
select_tag name, options_for_select(collection, value),
|
|
104
|
+
include_blank: "All #{name.to_s.humanize.pluralize}",
|
|
105
|
+
class: css_class(:filter_select),
|
|
106
|
+
onchange: "this.form.requestSubmit()"
|
|
107
|
+
else
|
|
108
|
+
text_field_tag name, value, placeholder: label,
|
|
109
|
+
class: css_class(:filter_input),
|
|
110
|
+
data: { action: "input->power-grid-table#search" }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import flatpickr from "flatpickr"
|
|
3
|
+
|
|
4
|
+
// Connects to data-controller="power-grid--flatpickr"
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
connect() {
|
|
7
|
+
this.fp = flatpickr(this.element, {
|
|
8
|
+
mode: "range",
|
|
9
|
+
dateFormat: "Y-m-d",
|
|
10
|
+
altInput: true,
|
|
11
|
+
altFormat: "F j, Y",
|
|
12
|
+
onChange: (selectedDates, dateStr, instance) => {
|
|
13
|
+
if (selectedDates.length === 2 || selectedDates.length === 0) {
|
|
14
|
+
this.element.form.requestSubmit()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.fp.destroy()
|
|
22
|
+
}
|
|
23
|
+
}
|
data/lib/power_grid/base.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
module PowerGrid
|
|
2
|
+
require_relative "exporter"
|
|
2
3
|
class Base
|
|
3
4
|
class << self
|
|
4
5
|
def scope(&block)
|
|
@@ -10,9 +11,9 @@ module PowerGrid
|
|
|
10
11
|
@columns[name] = options.merge(block: block)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def filter(name, **options)
|
|
14
|
+
def filter(name, **options, &block)
|
|
14
15
|
@filters ||= {}
|
|
15
|
-
@filters[name] = options
|
|
16
|
+
@filters[name] = options.merge(block: block)
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def defined_scope
|
|
@@ -32,25 +33,36 @@ module PowerGrid
|
|
|
32
33
|
attr_accessor :hide_controls
|
|
33
34
|
|
|
34
35
|
def initialize(params = {}, initial_scope: nil, **options)
|
|
36
|
+
params ||= {}
|
|
35
37
|
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
|
38
|
+
params = params.with_indifferent_access
|
|
39
|
+
|
|
40
|
+
# Handle namespaced params (common pattern for custom forms: grid[field])
|
|
41
|
+
if params[:grid].present? && params[:grid].is_a?(Hash)
|
|
42
|
+
params = params.merge(params[:grid])
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
@params = params.merge(options)
|
|
37
46
|
@initial_scope = initial_scope
|
|
38
47
|
@hide_controls = false # Initialize to false by default
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
def records
|
|
42
|
-
@records ||=
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
@records ||= apply_pagination(scope)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scope
|
|
55
|
+
@scope ||= begin
|
|
56
|
+
|
|
57
|
+
s = @initial_scope || instance_eval(&self.class.defined_scope)
|
|
58
|
+
s = apply_includes(s)
|
|
59
|
+
s = apply_filters(s)
|
|
60
|
+
s = apply_search(s)
|
|
61
|
+
s = apply_sort(s)
|
|
48
62
|
|
|
49
63
|
# Capture total count after filtering but before pagination
|
|
50
|
-
@total_count =
|
|
51
|
-
|
|
52
|
-
scope = apply_pagination(scope)
|
|
53
|
-
scope
|
|
64
|
+
@total_count = s.count
|
|
65
|
+
s
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
@@ -112,6 +124,11 @@ module PowerGrid
|
|
|
112
124
|
range
|
|
113
125
|
end
|
|
114
126
|
|
|
127
|
+
|
|
128
|
+
def to_csv
|
|
129
|
+
Exporter.new(self).to_csv
|
|
130
|
+
end
|
|
131
|
+
|
|
115
132
|
private
|
|
116
133
|
|
|
117
134
|
def apply_includes(scope)
|
|
@@ -158,20 +175,49 @@ module PowerGrid
|
|
|
158
175
|
end
|
|
159
176
|
|
|
160
177
|
def apply_filters(scope)
|
|
161
|
-
self.class.defined_filters.each do |name, options|
|
|
162
|
-
value = params[name]
|
|
163
|
-
next if value.blank?
|
|
164
178
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# If sql_expression option exists, use that
|
|
169
|
-
col_name = options[:sql_expression] || name
|
|
179
|
+
self.class.defined_filters.each do |name, options|
|
|
180
|
+
value = @params[name] || @params[name.to_s]
|
|
170
181
|
|
|
171
|
-
|
|
182
|
+
is_number_range = options[:type].to_s == "number_range"
|
|
183
|
+
has_min_max = @params["#{name}_min"].present? || @params["#{name}_max"].present?
|
|
184
|
+
|
|
185
|
+
next if value.blank? && !(is_number_range && has_min_max)
|
|
186
|
+
|
|
187
|
+
if options[:block]
|
|
188
|
+
scope = options[:block].call(scope, value)
|
|
189
|
+
elsif options[:type].to_s == "date_range" && value.is_a?(String) && value.include?(" to ")
|
|
190
|
+
start_date, end_date = value.split(" to ")
|
|
191
|
+
col_name = options[:sql_expression] || name
|
|
192
|
+
scope = scope.where(col_name => start_date..end_date)
|
|
193
|
+
elsif options[:type].to_s == "number_range"
|
|
194
|
+
min = @params["#{name}_min"]
|
|
195
|
+
max = @params["#{name}_max"]
|
|
196
|
+
col_name = options[:sql_expression] || name
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if min.present? && max.present?
|
|
201
|
+
scope = scope.where(col_name => min..max)
|
|
202
|
+
elsif min.present?
|
|
203
|
+
scope = scope.where("#{col_name} >= ?", min)
|
|
204
|
+
elsif max.present?
|
|
205
|
+
scope = scope.where("#{col_name} <= ?", max)
|
|
206
|
+
end
|
|
207
|
+
elsif options[:type].to_s == "boolean"
|
|
208
|
+
col_name = options[:sql_expression] || name
|
|
209
|
+
bool_value = ActiveRecord::Type::Boolean.new.cast(value)
|
|
210
|
+
scope = scope.where(col_name => bool_value)
|
|
211
|
+
else
|
|
212
|
+
# Basic equality filter
|
|
213
|
+
# If sql_expression option exists, use that
|
|
214
|
+
col_name = options[:sql_expression] || name
|
|
215
|
+
scope = scope.where(col_name => value)
|
|
216
|
+
end
|
|
172
217
|
end
|
|
173
218
|
scope
|
|
174
219
|
end
|
|
175
220
|
|
|
221
|
+
|
|
176
222
|
end
|
|
177
223
|
end
|
data/lib/power_grid/engine.rb
CHANGED
|
@@ -13,6 +13,12 @@ module PowerGrid
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
initializer "power_grid.helper" do
|
|
17
|
+
ActiveSupport.on_load(:action_view) do
|
|
18
|
+
include PowerGrid::Helper
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
# Removed importmap auto-configuration to prevent "Is a directory" errors.
|
|
17
23
|
# Users can pin the controller manually if needed.
|
|
18
24
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
|
|
3
|
+
module PowerGrid
|
|
4
|
+
class Exporter
|
|
5
|
+
def initialize(grid)
|
|
6
|
+
@grid = grid
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_csv
|
|
10
|
+
collection = @grid.records
|
|
11
|
+
columns = @grid.class.defined_columns
|
|
12
|
+
|
|
13
|
+
CSV.generate(headers: true) do |csv|
|
|
14
|
+
# Header Row
|
|
15
|
+
csv << columns.map { |name, options| options[:header] || name.to_s.humanize }
|
|
16
|
+
|
|
17
|
+
# Data Rows
|
|
18
|
+
collection.each do |record|
|
|
19
|
+
csv << columns.map do |name, options|
|
|
20
|
+
# Try to fetch value from record
|
|
21
|
+
if record.respond_to?(name)
|
|
22
|
+
record.public_send(name)
|
|
23
|
+
else
|
|
24
|
+
# Fallback for virtual columns not on the model
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/power_grid/version.rb
CHANGED
data/lib/power_grid.rb
CHANGED
data/power_grid.gemspec
CHANGED
|
@@ -4,7 +4,7 @@ Gem::Specification.new do |spec|
|
|
|
4
4
|
spec.name = "power_grid"
|
|
5
5
|
spec.version = PowerGrid::VERSION
|
|
6
6
|
spec.authors = ["Bhavin Nandani"]
|
|
7
|
-
spec.email = ["
|
|
7
|
+
spec.email = ["nandanibhavin@gmail.com"]
|
|
8
8
|
|
|
9
9
|
spec.summary = "A powerful, server-side processed table component for Rails."
|
|
10
10
|
spec.description = "PowerGrid provides a view component based table with server-side sorting, filtering, and pagination."
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: power_grid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bhavin Nandani
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-12-
|
|
11
|
+
date: 2025-12-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -141,7 +141,7 @@ dependencies:
|
|
|
141
141
|
description: PowerGrid provides a view component based table with server-side sorting,
|
|
142
142
|
filtering, and pagination.
|
|
143
143
|
email:
|
|
144
|
-
-
|
|
144
|
+
- nandanibhavin@gmail.com
|
|
145
145
|
executables: []
|
|
146
146
|
extensions: []
|
|
147
147
|
extra_rdoc_files: []
|
|
@@ -157,6 +157,9 @@ files:
|
|
|
157
157
|
- Rakefile
|
|
158
158
|
- app/components/power_grid/table_component.html.erb
|
|
159
159
|
- app/components/power_grid/table_component.rb
|
|
160
|
+
- app/components/power_grid/toolbar_component.html.erb
|
|
161
|
+
- app/components/power_grid/toolbar_component.rb
|
|
162
|
+
- app/javascript/controllers/power_grid/flatpickr_controller.js
|
|
160
163
|
- app/javascript/controllers/power_grid/table_controller.js
|
|
161
164
|
- bin/console
|
|
162
165
|
- bin/setup
|
|
@@ -164,6 +167,8 @@ files:
|
|
|
164
167
|
- lib/power_grid.rb
|
|
165
168
|
- lib/power_grid/base.rb
|
|
166
169
|
- lib/power_grid/engine.rb
|
|
170
|
+
- lib/power_grid/exporter.rb
|
|
171
|
+
- lib/power_grid/helper.rb
|
|
167
172
|
- lib/power_grid/version.rb
|
|
168
173
|
- power_grid.gemspec
|
|
169
174
|
homepage: https://github.com/bhavinNandani/power_grid
|
|
@@ -189,7 +194,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
189
194
|
- !ruby/object:Gem::Version
|
|
190
195
|
version: '0'
|
|
191
196
|
requirements: []
|
|
192
|
-
rubygems_version: 3.
|
|
197
|
+
rubygems_version: 3.4.22
|
|
193
198
|
signing_key:
|
|
194
199
|
specification_version: 4
|
|
195
200
|
summary: A powerful, server-side processed table component for Rails.
|