infotable 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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1043 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +1412 -0
  6. data/app/assets/javascripts/infotable/controllers/table_controller.js +921 -0
  7. data/app/assets/javascripts/infotable/index.js +14 -0
  8. data/app/controllers/infotable/application_controller.rb +4 -0
  9. data/app/controllers/infotable/tables_controller.rb +299 -0
  10. data/app/helpers/infotable/application_helper.rb +5 -0
  11. data/app/helpers/infotable/tables_helper.rb +354 -0
  12. data/app/jobs/infotable/application_job.rb +4 -0
  13. data/app/mailers/infotable/application_mailer.rb +6 -0
  14. data/app/models/infotable/application_record.rb +5 -0
  15. data/app/views/infotable/tables/_column_visibility.html.erb +100 -0
  16. data/app/views/infotable/tables/_filter_drawer.html.erb +65 -0
  17. data/app/views/infotable/tables/_next_page_frame.html.erb +39 -0
  18. data/app/views/infotable/tables/_pagination.html.erb +101 -0
  19. data/app/views/infotable/tables/_side_panel.html.erb +63 -0
  20. data/app/views/infotable/tables/_table.html.erb +135 -0
  21. data/app/views/infotable/tables/_table_rows.html.erb +38 -0
  22. data/app/views/infotable/tables/filters/_boolean.html.erb +22 -0
  23. data/app/views/infotable/tables/filters/_date.html.erb +30 -0
  24. data/app/views/infotable/tables/filters/_number.html.erb +32 -0
  25. data/app/views/infotable/tables/filters/_select.html.erb +68 -0
  26. data/app/views/infotable/tables/filters/_text.html.erb +38 -0
  27. data/app/views/infotable/tables/show.html.erb +113 -0
  28. data/app/views/layouts/infotable/application.html.erb +15 -0
  29. data/config/importmap.rb +6 -0
  30. data/config/locales/en.yml +88 -0
  31. data/config/locales/pl.yml +88 -0
  32. data/config/routes.rb +6 -0
  33. data/lib/generators/infotable/install/install_generator.rb +565 -0
  34. data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +131 -0
  35. data/lib/generators/infotable/install/templates/initializer.rb +26 -0
  36. data/lib/generators/infotable/install/templates/tailwind.config.js +284 -0
  37. data/lib/generators/infotable/table/table_generator.rb +95 -0
  38. data/lib/generators/infotable/table/templates/table.rb.tt +26 -0
  39. data/lib/infotable/base.rb +463 -0
  40. data/lib/infotable/batch_actions.rb +34 -0
  41. data/lib/infotable/configuration.rb +53 -0
  42. data/lib/infotable/engine.rb +51 -0
  43. data/lib/infotable/filter_processor.rb +326 -0
  44. data/lib/infotable/formatters/action_formatter.rb +141 -0
  45. data/lib/infotable/formatters/avatar_formatter.rb +49 -0
  46. data/lib/infotable/formatters/badge_formatter.rb +80 -0
  47. data/lib/infotable/formatters/base_formatter.rb +47 -0
  48. data/lib/infotable/formatters/boolean_formatter.rb +38 -0
  49. data/lib/infotable/formatters/currency_formatter.rb +22 -0
  50. data/lib/infotable/formatters/date_formatter.rb +58 -0
  51. data/lib/infotable/formatters/link_formatter.rb +48 -0
  52. data/lib/infotable/formatters/percent_formatter.rb +27 -0
  53. data/lib/infotable/search_processor.rb +42 -0
  54. data/lib/infotable/table_resolver.rb +45 -0
  55. data/lib/infotable/version.rb +3 -0
  56. data/lib/infotable.rb +54 -0
  57. data/lib/tasks/infotable.rake +55 -0
  58. metadata +216 -0
data/README.md ADDED
@@ -0,0 +1,1043 @@
1
+ # InfoTable
2
+
3
+ Modern, flexible datatable engine for Rails 8+ with Hotwire. Build beautiful, interactive tables with server-side filtering, sorting, searching, and pagination—without page reloads.
4
+
5
+ ## Features
6
+
7
+ - ✨ **Server-side operations** - Filtering, sorting, searching, pagination all on the backend
8
+ - ⚡ **No page reloads** - Built with Hotwire (Turbo + Stimulus)
9
+ - 🎨 **Pure Tailwind CSS** - Beautiful, modern UI using only Tailwind utility classes (no custom CSS!)
10
+ - 🔧 **Highly customizable** - Easy to override defaults and add custom formatters
11
+ - 📦 **Works with any bundler** - Importmap, esbuild, webpack, vite—we support them all
12
+ - 🚀 **Rails 8+ ready** - Built for modern Rails applications
13
+ - 🌍 **i18n Support** - English and Polish translations included
14
+ - 🎯 **Advanced Features** - Infinite scroll, column pinning, drag-to-reorder, row links, and more
15
+
16
+ ## Quick Start
17
+
18
+ ```ruby
19
+ # 1. Add to Gemfile
20
+ gem 'infotable'
21
+
22
+ # 2. Install
23
+ bundle install
24
+ rails generate infotable:install
25
+
26
+ # 3. Create a table definition (app/infotable/users_table.rb)
27
+ class UsersTable < Infotable::Base
28
+ query { User.all }
29
+
30
+ column :name, label: "Name", type: :string, sortable: true
31
+ column :email, label: "Email", type: :string, sortable: true
32
+ column :created_at, label: "Created", type: :datetime, sortable: true
33
+
34
+ per_page 25
35
+ end
36
+
37
+ # 4. Use in your view
38
+ <%= infotable :users_table %>
39
+ ```
40
+
41
+ That's it! You now have a fully functional datatable with sorting and pagination.
42
+
43
+ ## Requirements
44
+
45
+ - Rails 8.0+
46
+ - Ruby 3.3+
47
+ - PostgreSQL (filters use `ILIKE`, `HAVING` and `CAST(... AS numeric)`; other databases are not supported)
48
+ - [Pagy](https://github.com/ddnexus/pagy) 43 (installed as a dependency)
49
+ - Tailwind CSS v4 (via `tailwindcss-rails` gem) - **Required for styling**
50
+ - Hotwire (Turbo + Stimulus)
51
+
52
+ **Note:** InfoTable uses pure Tailwind CSS utility classes for all styling. No custom CSS is included or required.
53
+
54
+ ## Installation
55
+
56
+ ### Automatic Installation (Recommended)
57
+
58
+ ```bash
59
+ bundle add infotable
60
+ rails generate infotable:install
61
+ ```
62
+
63
+ The installer will:
64
+ - Auto-detect your JavaScript bundler (importmap, esbuild, webpack, vite)
65
+ - Configure Tailwind CSS (v4 or v3) to scan InfoTable views
66
+ - Mount engine routes
67
+ - Create `app/infotable/` directory
68
+ - Generate configuration file
69
+
70
+ **After installation:**
71
+ ```bash
72
+ # Rebuild Tailwind to include InfoTable styles
73
+ rails tailwindcss:build
74
+
75
+ # Restart your Rails server
76
+ rails server
77
+ ```
78
+
79
+ **Important:** Verify your layout (`app/views/layouts/application.html.erb`) includes:
80
+ ```erb
81
+ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
82
+ ```
83
+
84
+ ### Manual Installation
85
+
86
+ See [docs/installation.md](docs/installation.md) for detailed instructions for each bundler type.
87
+
88
+ ## Bundler Support
89
+
90
+ InfoTable works seamlessly with:
91
+ - **Importmap** (Rails default)
92
+ - **esbuild**
93
+ - **webpack**
94
+ - **vite**
95
+ - Other JavaScript bundlers
96
+
97
+ The installer automatically detects and configures your setup.
98
+
99
+ ---
100
+
101
+ ## Configuration
102
+
103
+ ### Global Configuration
104
+
105
+ Customize in `config/initializers/infotable.rb`:
106
+
107
+ ```ruby
108
+ Infotable.configure do |config|
109
+ # Pagination defaults
110
+ config.default_per_page = 25
111
+ config.per_page_options = [10, 25, 50, 100]
112
+
113
+ # Drawer/side panel settings
114
+ config.drawer_position = :right # :left or :right
115
+ config.drawer_width = "26rem" # px, rem, em, vw or %
116
+
117
+ # Controller the engine controllers inherit from, so authentication, locale,
118
+ # tenant scoping etc. also protect the tables
119
+ config.parent_controller = "ApplicationController"
120
+
121
+ # Table height defaults
122
+ config.table_min_height = "400px"
123
+ config.table_max_height = "600px"
124
+
125
+ # Filter behavior
126
+ config.autofilter = true # true = auto-apply filters (debounced), false = manual apply button
127
+ end
128
+ ```
129
+
130
+ ### Available Global Options
131
+
132
+ | Option | Type | Default | Description |
133
+ |--------|------|---------|-------------|
134
+ | `default_per_page` | Integer | `25` | Default number of rows per page |
135
+ | `per_page_options` | Array | `[10, 25, 50, 100]` | Available rows per page options |
136
+ | `drawer_position` | Symbol | `:right` | Position of filter/column drawer (`:left` or `:right`) |
137
+ | `drawer_width` | String | `"26rem"` | Width of the drawer (`px`, `rem`, `em`, `vw` or `%`; anything else falls back to the default) |
138
+ | `parent_controller` | String | `"ActionController::Base"` | Controller the engine's controllers inherit from. Set it to `"ApplicationController"` to reuse your authentication |
139
+ | `table_min_height` | String | `"400px"` | Minimum table height |
140
+ | `table_max_height` | String | `"600px"` | Maximum table height (enables scrolling) |
141
+ | `autofilter` | Boolean | `true` | Auto-apply filters on change (debounced) vs manual apply |
142
+ | `url_state` | Boolean | `true` | Mirror search, filters, sort and page in the address bar (override per table with `url_state false`) |
143
+ | `max_filter_options` | Integer | `500` | Cap on `<option>`s in a select filter (`nil` = no cap) |
144
+
145
+ `theme`, `turbo_enabled` and `pagination_gem` are still accepted so old initializers keep loading, but they do nothing.
146
+
147
+ ---
148
+
149
+ ## Table Definition
150
+
151
+ Generate a starting point from a model's columns:
152
+
153
+ ```bash
154
+ rails generate infotable:table User
155
+ rails generate infotable:table Admin::Order --only=id number status # or --skip=notes
156
+ ```
157
+
158
+ It writes `app/infotable/users_table.rb` with a column per attribute (types and filters inferred, enums become select filters, passwords/tokens and foreign keys are left out, `belongs_to` associations come as commented hints).
159
+
160
+ Or create table classes in `app/infotable/` by hand:
161
+
162
+ ```ruby
163
+ class UsersTable < Infotable::Base
164
+ # Define data source
165
+ query { User.all }
166
+
167
+ # Define columns
168
+ column :name, label: "Full Name", type: :string, sortable: true
169
+
170
+ # Configure table settings
171
+ per_page 25
172
+ default_sort :created_at, :desc
173
+ end
174
+ ```
175
+
176
+ ### Organizing Tables in Folders
177
+
178
+ You can organize table definitions in subfolders. Rails' Zeitwerk autoloader automatically creates namespaces from the directory structure:
179
+
180
+ **Single-level nesting:**
181
+ ```ruby
182
+ # app/infotable/cms/users_table.rb
183
+ class Cms::UsersTable < Infotable::Base
184
+ query { User.all }
185
+ # ... columns
186
+ end
187
+
188
+ # Use in views:
189
+ <%= infotable :cms_users_table %>
190
+ ```
191
+
192
+ **Multi-level nesting:**
193
+ ```ruby
194
+ # app/infotable/cms/admins/users_table.rb
195
+ class Cms::Admins::UsersTable < Infotable::Base
196
+ query { User.where(admin: true) }
197
+ # ... columns
198
+ end
199
+
200
+ # Use in views:
201
+ <%= infotable :cms_admins_users_table %>
202
+ ```
203
+
204
+ **How it works:**
205
+ - Rails' Zeitwerk autoloader infers namespaces from directory structure
206
+ - File path: `app/infotable/cms/users_table.rb` → Class: `Cms::UsersTable` → Symbol: `:cms_users_table`
207
+ - File path: `app/infotable/cms/admins/users_table.rb` → Class: `Cms::Admins::UsersTable` → Symbol: `:cms_admins_users_table`
208
+ - No need for `module` wrappers - the directory structure provides the namespace
209
+
210
+ **Naming convention:**
211
+ - Pattern: folder names joined with underscores, followed by the table name
212
+ - Example: folder `cms/admins` + file `users_table.rb` = symbol `:cms_admins_users_table`
213
+
214
+ ### Data Source Methods
215
+
216
+ #### `query`
217
+ Define data source using ActiveRecord relation:
218
+
219
+ ```ruby
220
+ query { User.includes(:company).where(active: true) }
221
+ ```
222
+
223
+ The block receives the controller, so you can scope by the current user:
224
+
225
+ ```ruby
226
+ query { |controller| controller.current_user.projects }
227
+ ```
228
+
229
+ #### `query_sql`
230
+ Define data source using raw SQL. A `model:` is required; it supplies the column types and the table alias. The SQL is wrapped in a sub-select, so filters, search, sorting, pagination and export all work:
231
+
232
+ ```ruby
233
+ query_sql "SELECT * FROM users WHERE active = true", model: User
234
+ ```
235
+
236
+ #### `authorize`
237
+ Return false to answer `403 Forbidden` for the table, its data and its export:
238
+
239
+ ```ruby
240
+ authorize { |controller| controller.current_user&.admin? }
241
+ ```
242
+
243
+ ### Column Definition
244
+
245
+ ```ruby
246
+ column :name, **options
247
+ ```
248
+
249
+ #### Virtual/Computed Columns
250
+
251
+ For aggregated or computed columns (COUNT, SUM, subqueries, calculations), mark them as `virtual: true`:
252
+
253
+ ```ruby
254
+ query do
255
+ Shop.select(
256
+ "shops.*",
257
+ "COUNT(products.id) AS products_count"
258
+ )
259
+ .left_joins(:products)
260
+ .group("shops.id")
261
+ end
262
+
263
+ # Regular column
264
+ column :name, sortable: true, filterable: true
265
+
266
+ # Virtual column (aggregate)
267
+ column :products_count,
268
+ type: :integer,
269
+ sortable: true, # ✅ Sorting works
270
+ filterable: true, # ✅ Filtering uses HAVING clause
271
+ filter_type: :number,
272
+ virtual: true, # ⭐ Required for aggregates!
273
+ virtual_sql: "COUNT(products.id)" # ⭐ SQL expression for filtering (without AS alias)
274
+ ```
275
+
276
+ **Why `virtual: true` and `virtual_sql`?**
277
+ - Aggregated columns require `HAVING` clause for filtering (not `WHERE`)
278
+ - The `virtual_sql` option provides the SQL expression used in both HAVING and ORDER BY clauses
279
+ - InfoTable automatically uses the correct SQL clauses for filtering and sorting
280
+ - Works with all filter types
281
+ - See [docs/virtual_columns.md](docs/virtual_columns.md) for detailed examples
282
+
283
+ #### Column Options
284
+
285
+ | Option | Type | Default | Description |
286
+ |--------|------|---------|-------------|
287
+ | `label` | String | Humanized column name | Display label for column header |
288
+ | `type` | Symbol | `:string` | Data type (see Column Types below) |
289
+ | `sortable` | Boolean | `false` | Enable sorting for this column |
290
+ | `searchable` | Boolean | `false` | Include in global search |
291
+ | `filterable` | Boolean | `false` | Enable filtering for this column |
292
+ | `visible` | Boolean | `true` | Show column by default |
293
+ | `pinned` | Symbol | `nil` | Pin column (`:left`, `:right`, or `nil`) |
294
+ | `width` | String | Auto (see defaults) | Column width (e.g., `"200px"`) |
295
+ | `formatter` | Symbol/Proc/Hash | `nil` | Custom formatter (see Formatters) |
296
+ | `filter_type` | Symbol | Auto-detected | Filter UI type (`:text`, `:number`, `:date`, `:select`, `:boolean`) |
297
+ | `filter_options` | Array/Proc | `nil` | Options for select filters (`["a", "b"]`, `[["Label", value]]` or a Proc) |
298
+ | `filter_options_cache` | Duration | `nil` | Cache a Proc's result with `Rails.cache`, e.g. `5.minutes`. Keyed per table and column, so do not use it for options that depend on the current user |
299
+ | `filter_options_limit` | Integer | `max_filter_options` | Most `<option>`s rendered for this filter; the drawer says when the list was cut. For big lists use a text filter instead |
300
+ | `virtual` | Boolean | `false` | Mark column as virtual/computed (aggregates, subqueries) - uses HAVING for filters |
301
+ | `virtual_sql` | String | `nil` | SQL expression for virtual columns (used in HAVING and ORDER BY clauses, without AS alias) |
302
+ | `sql` | String | `nil` | Row-level SQL expression to filter and search on instead of the column (e.g. `"comments.post_id"`). Not an aggregate, so it goes in `WHERE` |
303
+ | `sort_sql` | String | `nil` | SQL expression to sort by (e.g. `"authors.name"` for an association column) |
304
+ | `value` | Proc | `nil` | Computes the cell value from the record: `value: ->(post) { post.author.name }` |
305
+ | `includes` | Symbol/Array/Hash | `nil` | Association to preload for this column (avoids N+1 in the table and the CSV export) |
306
+ | `filter_multi_select` | Boolean | `false` | Let a `:select` filter take several values |
307
+
308
+ #### Column Types
309
+
310
+ Available types and their default widths:
311
+
312
+ | Type | Default Width | Description |
313
+ |------|---------------|-------------|
314
+ | `:string` | `200px` | Text string |
315
+ | `:text` | `300px` | Long text content |
316
+ | `:integer` | `100px` | Integer number |
317
+ | `:decimal` | `120px` | Decimal number |
318
+ | `:float` | `120px` | Float number |
319
+ | `:currency` | `120px` | Currency value |
320
+ | `:boolean` | `80px` | True/false value |
321
+ | `:date` | `120px` | Date only |
322
+ | `:datetime` | `180px` | Date and time |
323
+ | `:badge` | `120px` | Badge/status indicator |
324
+ | `:status` | `120px` | Alias for badge |
325
+ | `:action` | `100px` | Action buttons |
326
+ | `:actions` | `100px` | Alias for action |
327
+
328
+ ### Table Configuration Methods
329
+
330
+ #### Pagination
331
+
332
+ ```ruby
333
+ # Set rows per page
334
+ per_page 25
335
+
336
+ # Set per-page options dropdown
337
+ per_page_options [10, 25, 50, 100]
338
+ ```
339
+
340
+ #### Sorting
341
+
342
+ ```ruby
343
+ # Set default sort
344
+ default_sort :created_at, :desc # column, direction (:asc or :desc)
345
+ ```
346
+
347
+ Click a header to sort; **Shift+click** adds up to three sort columns (`?sort=name:asc,age:desc`). The primary key is always appended as a tiebreaker, so pages never repeat or skip rows.
348
+
349
+ #### Search
350
+
351
+ Mark columns `searchable: true` to get a search box. Every word (up to 5) must match at least one searchable column, case-insensitively.
352
+
353
+ #### Filters
354
+
355
+ Filter types are inferred from the column type or set with `filter_type:`.
356
+
357
+ | Type | Operators |
358
+ |------|-----------|
359
+ | text | contains, equals, starts with, ends with, not contains, is empty, is not empty |
360
+ | number | `eq` `neq` `gt` `gte` `lt` `lte` `between` `is_empty` `is_not_empty` (plain `from`/`to` still work) |
361
+ | date | `on` `before` `after` `between` `is_empty` `is_not_empty` (plain `from`/`to` still work) |
362
+ | boolean | any, yes, no |
363
+ | select | one value, or several with `filter_multi_select: true` |
364
+
365
+ Filters, search and sort survive pagination, per-page changes and CSV export.
366
+
367
+ #### Table state in the URL
368
+
369
+ Search, filters, sort, page and page size are mirrored in the page's address bar, one namespace per table (`/users?users_table[search]=ann&users_table[page]=2`). Links can be shared and bookmarked, a reload keeps the table as it was, and the Back button steps through the states. Turn it off globally with `config.url_state = false` or per table with `url_state false` (e.g. tables inside modals or tabs).
370
+
371
+ #### Large tables
372
+
373
+ ```ruby
374
+ # Skip SELECT COUNT(*): only previous/next, no total or page numbers
375
+ countless
376
+ ```
377
+
378
+ #### Batch actions
379
+
380
+ Row checkboxes plus an action bar. The gem renders them and sends the selected ids as `ids[]` to your endpoints:
381
+
382
+ ```ruby
383
+ batch_actions do
384
+ action :feature, label: "Feature", url: "/posts/batch", method: :patch, color: :primary
385
+ action :delete, label: "Delete", url: "/posts/batch", method: :delete, color: :danger,
386
+ confirm: "Delete %{count} posts?"
387
+ end
388
+
389
+ # Checkboxes only (read the selection from your own JavaScript)
390
+ checkbox_column
391
+ ```
392
+
393
+ `color:` is one of `:default`, `:primary`, `:success`, `:warning`, `:danger`; `method:` is one of `:get`, `:post`, `:put`, `:patch`, `:delete`.
394
+
395
+ #### Table Height
396
+
397
+ ```ruby
398
+ # Override global height settings for this table
399
+ table_min_height "500px"
400
+ table_max_height "700px"
401
+ ```
402
+
403
+ #### Infinite Scroll
404
+
405
+ ```ruby
406
+ # Enable infinite scroll (replaces pagination)
407
+ infinite_scroll true
408
+ ```
409
+
410
+ #### Row Links
411
+
412
+ Make entire rows clickable:
413
+
414
+ ```ruby
415
+ # Dynamic URL based on record
416
+ row_link ->(record) { "/users/#{record.id}" }
417
+
418
+ # Static URL
419
+ row_link "/users/show"
420
+ ```
421
+
422
+ Features:
423
+ - Click row to navigate
424
+ - Cmd/Ctrl+Click to open in new tab
425
+ - Ignores clicks on buttons, links, and inputs
426
+
427
+ #### Row Size
428
+
429
+ Control table row padding and text size with three options:
430
+
431
+ ```ruby
432
+ # Slim - Compact padding (px-2 py-1) with smaller text
433
+ row_size :slim
434
+
435
+ # Mid - Medium padding (px-4 py-2.5) - default text size
436
+ row_size :mid
437
+
438
+ # Default - Full padding (px-6 py-4) with normal text (default)
439
+ row_size :default
440
+ ```
441
+
442
+ **Backward compatibility:** The old `slim_rows true` method still works and maps to `row_size :slim`
443
+
444
+ **Visual comparison:**
445
+ - **Slim**: Minimal padding, smaller text - ideal for dense data tables
446
+ - **Mid**: Moderate padding, normal text - balanced appearance
447
+ - **Default**: Generous padding, normal text - comfortable reading
448
+
449
+ #### Filters Button Visibility
450
+
451
+ Show or hide the filters button:
452
+
453
+ ```ruby
454
+ show_filters_button false # Hide filters button
455
+ ```
456
+
457
+ ---
458
+
459
+ ## Formatters
460
+
461
+ Formatters customize how values are displayed.
462
+
463
+ ### Built-in Formatters
464
+
465
+ #### 1. Date Formatter (`:date`)
466
+
467
+ ```ruby
468
+ column :created_at,
469
+ type: :datetime,
470
+ formatter: :date,
471
+ formatter_options: { format: :short } # :short, :long, :datetime
472
+ ```
473
+
474
+ #### 2. Currency Formatter (`:currency`)
475
+
476
+ ```ruby
477
+ column :price,
478
+ type: :decimal,
479
+ formatter: :currency,
480
+ formatter_options: { precision: 2 }
481
+ ```
482
+
483
+ #### 3. Boolean Formatter (`:boolean`)
484
+
485
+ ```ruby
486
+ column :active,
487
+ type: :boolean,
488
+ formatter: :boolean
489
+ ```
490
+
491
+ Displays checkmark (✓) for true, X (✗) for false.
492
+
493
+ #### 4. Badge Formatter (`:badge`)
494
+
495
+ ```ruby
496
+ column :status,
497
+ type: :badge,
498
+ formatter: :badge,
499
+ formatter_options: {
500
+ colors: {
501
+ "active" => "green",
502
+ "inactive" => "gray",
503
+ "pending" => "yellow"
504
+ }
505
+ }
506
+ ```
507
+
508
+ Available colors: `green`, `red`, `yellow`, `blue`, `indigo`, `purple`, `pink`, `gray`, `cyan`
509
+
510
+ #### 5. Action Formatter (`:action`)
511
+
512
+ ```ruby
513
+ column :actions,
514
+ type: :action,
515
+ formatter: :action,
516
+ formatter_options: {
517
+ actions: [
518
+ {
519
+ label: "View",
520
+ url: ->(record) { "/users/#{record.id}" },
521
+ color: "primary" # primary, secondary, danger
522
+ },
523
+ {
524
+ label: "Delete",
525
+ url: ->(record) { "/users/#{record.id}" },
526
+ method: :delete,
527
+ color: "danger",
528
+ confirm: "Are you sure?"
529
+ }
530
+ ],
531
+ style: :buttons # :buttons (default), :links or :dropdown (a keyboard-friendly menu, no JavaScript)
532
+ }
533
+ ```
534
+
535
+ `method:` and `confirm:` use Turbo (`data-turbo-method`, `data-turbo-confirm`). Labels are HTML-escaped.
536
+
537
+ ### Custom Formatters
538
+
539
+ #### Lambda/Proc Formatter
540
+
541
+ ```ruby
542
+ column :name,
543
+ formatter: ->(value, record) { content_tag(:strong, value) }
544
+ ```
545
+
546
+ The lambda receives:
547
+ - `value` - Column value
548
+ - `record` - Full record object
549
+
550
+ The returned string is rendered as-is when you mark it `html_safe`, so escape anything that comes from user data: `safe_join(["<strong>".html_safe, value, "</strong>".html_safe])` or `content_tag(:strong, value)`.
551
+
552
+ #### Hash Formatter
553
+
554
+ ```ruby
555
+ column :status,
556
+ formatter: {
557
+ type: :badge,
558
+ options: { colors: { "active" => "green" } }
559
+ }
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Filters
565
+
566
+ ### Filter Types
567
+
568
+ Filters are automatically enabled for columns with `filterable: true`.
569
+
570
+ #### Text Filter (`:text`)
571
+
572
+ ```ruby
573
+ column :name,
574
+ filterable: true,
575
+ filter_type: :text
576
+ ```
577
+
578
+ Operators: Contains, Starts with, Ends with, Equals, Does not contain
579
+
580
+ #### Number Filter (`:number`)
581
+
582
+ ```ruby
583
+ column :age,
584
+ filterable: true,
585
+ filter_type: :number
586
+ ```
587
+
588
+ Operators: Equals, Greater than, Less than, Greater or equal, Less or equal, Between
589
+
590
+ #### Date Filter (`:date`)
591
+
592
+ ```ruby
593
+ column :created_at,
594
+ filterable: true,
595
+ filter_type: :date
596
+ ```
597
+
598
+ Operators: On, Before, After, Between
599
+
600
+ #### Select Filter (`:select`)
601
+
602
+ ```ruby
603
+ column :status,
604
+ filterable: true,
605
+ filter_type: :select,
606
+ filter_options: ["active", "inactive", "pending"]
607
+
608
+ # Or with dynamic options (Proc)
609
+ column :company,
610
+ filterable: true,
611
+ filter_type: :select,
612
+ filter_options: -> { Company.pluck(:name) }
613
+
614
+ # Or with custom label/value pairs
615
+ column :status,
616
+ filterable: true,
617
+ filter_type: :select,
618
+ filter_options: [["Active", "active"], ["Inactive", "inactive"]]
619
+ ```
620
+
621
+ #### Boolean Filter (`:boolean`)
622
+
623
+ ```ruby
624
+ column :active,
625
+ filterable: true,
626
+ filter_type: :boolean
627
+ ```
628
+
629
+ Options: True, False, Any
630
+
631
+ ### Auto-apply Filters
632
+
633
+ Control filter application behavior:
634
+
635
+ ```ruby
636
+ # Global (in initializer)
637
+ config.autofilter = true # Apply filters automatically (debounced)
638
+ config.autofilter = false # Require "Apply Filters" button click
639
+
640
+ # Per-table (in view)
641
+ <%= infotable :users_table, autofilter: false %>
642
+ ```
643
+
644
+ ---
645
+
646
+ ## View Helpers
647
+
648
+ ### Basic Usage
649
+
650
+ ```ruby
651
+ <%= infotable :users_table %>
652
+ ```
653
+
654
+ ### Available Options
655
+
656
+ ```ruby
657
+ <%= infotable :users_table,
658
+ title: "Active Users",
659
+ subtitle: "Currently logged in users",
660
+ autofilter: false,
661
+ per_page: 50,
662
+ per_page_options: [25, 50, 100],
663
+ drawer_position: :left,
664
+ drawer_width: "500px",
665
+ theme: :default
666
+ %>
667
+ ```
668
+
669
+ | Option | Type | Description |
670
+ |--------|------|-------------|
671
+ | `title` | String | Table title (defaults to table class name) |
672
+ | `subtitle` | String | Optional subtitle (not shown if nil) |
673
+ | `autofilter` | Boolean | Override global autofilter setting |
674
+ | `per_page` | Integer | Override default per page |
675
+ | `per_page_options` | Array | Override per page options |
676
+ | `drawer_position` | Symbol | Override drawer position |
677
+ | `drawer_width` | String | Override drawer width |
678
+ | `theme` | Symbol | Override theme |
679
+
680
+ ---
681
+
682
+ ## Internationalization (i18n)
683
+
684
+ InfoTable includes English and Polish translations.
685
+
686
+ ### Set Locale
687
+
688
+ ```ruby
689
+ # config/application.rb
690
+ config.i18n.default_locale = :en
691
+ config.i18n.available_locales = [:en, :pl]
692
+
693
+ # Or per-request
694
+ class ApplicationController < ActionController::Base
695
+ before_action :set_locale
696
+
697
+ def set_locale
698
+ I18n.locale = params[:locale] || I18n.default_locale
699
+ end
700
+ end
701
+ ```
702
+
703
+ ### Available Languages
704
+
705
+ - English (`:en`) - Default
706
+ - Polish (`:pl`)
707
+
708
+ ### Customize Translations
709
+
710
+ Override translations in your app:
711
+
712
+ ```yaml
713
+ # config/locales/infotable.en.yml
714
+ en:
715
+ infotable:
716
+ table:
717
+ buttons:
718
+ export_csv: "Download CSV"
719
+ ```
720
+
721
+ ### Add New Language
722
+
723
+ ```yaml
724
+ # config/locales/infotable.es.yml
725
+ es:
726
+ infotable:
727
+ table:
728
+ buttons:
729
+ export_csv: "Exportar CSV"
730
+ filters: "Filtros"
731
+ # ... see config/locales/en.yml for full structure
732
+ ```
733
+
734
+ See [docs/i18n.md](docs/i18n.md) for complete translation documentation.
735
+
736
+ ---
737
+
738
+ ## Advanced Features
739
+
740
+ ### Column Visibility & Ordering
741
+
742
+ Users can:
743
+ - Show/hide columns
744
+ - Drag to reorder columns
745
+ - Pin columns to left or right
746
+ - Reset to defaults
747
+
748
+ Changes persist in localStorage per table.
749
+
750
+ ### Pinned Columns
751
+
752
+ Pin columns by default:
753
+
754
+ ```ruby
755
+ column :id, pinned: :left # Pin to left
756
+ column :actions, pinned: :right # Pin to right
757
+ ```
758
+
759
+ Users can also pin/unpin columns via the UI.
760
+
761
+ ### Sticky Header
762
+
763
+ Table headers remain visible while scrolling through table content.
764
+
765
+ ### Export to CSV
766
+
767
+ Built-in CSV export with:
768
+ - Current filters applied
769
+ - Current sort order
770
+ - Only visible columns
771
+ - All pages (not just current)
772
+
773
+ ---
774
+
775
+ ## Examples
776
+
777
+ ### Basic Table
778
+
779
+ ```ruby
780
+ class UsersTable < Infotable::Base
781
+ query { User.all }
782
+
783
+ column :id, sortable: true
784
+ column :name, sortable: true, searchable: true
785
+ column :email, sortable: true
786
+ column :created_at, type: :datetime, sortable: true
787
+
788
+ per_page 25
789
+ default_sort :created_at, :desc
790
+ end
791
+ ```
792
+
793
+ ### Table with Filters
794
+
795
+ ```ruby
796
+ class ProductsTable < Infotable::Base
797
+ query { Product.all }
798
+
799
+ column :name,
800
+ sortable: true,
801
+ filterable: true,
802
+ filter_type: :text
803
+
804
+ column :category,
805
+ sortable: true,
806
+ filterable: true,
807
+ filter_type: :select,
808
+ filter_options: -> { Product.distinct.pluck(:category) }
809
+
810
+ column :price,
811
+ type: :decimal,
812
+ sortable: true,
813
+ filterable: true,
814
+ filter_type: :number,
815
+ formatter: :currency
816
+
817
+ column :in_stock,
818
+ type: :boolean,
819
+ filterable: true,
820
+ formatter: :boolean
821
+
822
+ per_page 25
823
+ end
824
+ ```
825
+
826
+ ### Table with Custom Formatters
827
+
828
+ ```ruby
829
+ class OrdersTable < Infotable::Base
830
+ query { Order.includes(:customer) }
831
+
832
+ column :order_number,
833
+ formatter: ->(value, record) {
834
+ "<code class='font-mono'>#{value}</code>".html_safe
835
+ }
836
+
837
+ column :customer,
838
+ formatter: ->(value, record) { record.customer.name }
839
+
840
+ column :status,
841
+ type: :badge,
842
+ formatter: :badge,
843
+ formatter_options: {
844
+ colors: {
845
+ "pending" => "yellow",
846
+ "processing" => "blue",
847
+ "shipped" => "green",
848
+ "delivered" => "green",
849
+ "cancelled" => "red"
850
+ }
851
+ }
852
+
853
+ column :total,
854
+ type: :currency,
855
+ formatter: :currency,
856
+ formatter_options: { precision: 2 }
857
+
858
+ column :actions,
859
+ type: :action,
860
+ formatter: :action,
861
+ formatter_options: {
862
+ actions: [
863
+ { label: "View", url: ->(r) { "/orders/#{r.id}" } },
864
+ { label: "Invoice", url: ->(r) { "/orders/#{r.id}/invoice" } }
865
+ ]
866
+ }
867
+ end
868
+ ```
869
+
870
+ ### Infinite Scroll Table
871
+
872
+ ```ruby
873
+ class PostsTable < Infotable::Base
874
+ query { Post.published.includes(:author) }
875
+
876
+ column :title, sortable: true
877
+ column :author, sortable: true
878
+ column :published_at, type: :datetime, sortable: true
879
+
880
+ infinite_scroll true
881
+ per_page 50
882
+ default_sort :published_at, :desc
883
+ end
884
+ ```
885
+
886
+ ### Clickable Rows Table
887
+
888
+ ```ruby
889
+ class CommentsTable < Infotable::Base
890
+ query { Comment.includes(:post) }
891
+
892
+ column :body, type: :text
893
+ column :author_name
894
+ column :created_at, type: :datetime
895
+
896
+ # Make rows clickable
897
+ row_link ->(record) { "/comments/#{record.id}" }
898
+
899
+ # Use slim rows for compact display
900
+ row_size :slim
901
+ end
902
+ ```
903
+
904
+ ### Table with Pinned Columns
905
+
906
+ ```ruby
907
+ class SalesTable < Infotable::Base
908
+ query { Sale.all }
909
+
910
+ column :id, pinned: :left, width: "60px"
911
+ column :product_name
912
+ column :quantity
913
+ column :price, type: :currency
914
+ column :total, type: :currency
915
+ column :actions, pinned: :right, type: :action
916
+ end
917
+ ```
918
+
919
+ ### Tables with Different Row Sizes
920
+
921
+ ```ruby
922
+ # Slim - Compact for dense data
923
+ class CompactDataTable < Infotable::Base
924
+ query { Data.all }
925
+
926
+ column :id
927
+ column :name
928
+ column :value
929
+
930
+ row_size :slim # Minimal padding, smaller text
931
+ end
932
+
933
+ # Mid - Balanced appearance
934
+ class BalancedTable < Infotable::Base
935
+ query { Product.all }
936
+
937
+ column :id
938
+ column :name
939
+ column :price, type: :currency
940
+
941
+ row_size :mid # Medium padding, normal text
942
+ end
943
+
944
+ # Default - Comfortable reading
945
+ class ReadableTable < Infotable::Base
946
+ query { Article.all }
947
+
948
+ column :title
949
+ column :author
950
+ column :published_at, type: :datetime
951
+
952
+ row_size :default # Full padding (or omit - it's the default)
953
+ end
954
+ ```
955
+
956
+ ### Table with Virtual (Aggregated) Columns
957
+
958
+ ```ruby
959
+ class ShopsTable < Infotable::Base
960
+ # Query with aggregated columns
961
+ query do
962
+ Shop.select(
963
+ "shops.*",
964
+ "COUNT(products.id) AS products_count",
965
+ "COUNT(accessories.id) AS accessories_count",
966
+ "SUM(products.price) AS total_products_value"
967
+ )
968
+ .left_joins(:products, :accessories)
969
+ .group("shops.id")
970
+ end
971
+
972
+ # Regular columns - use WHERE for filtering
973
+ column :id, sortable: true
974
+ column :name, sortable: true, filterable: true, filter_type: :text
975
+ column :active, type: :boolean, sortable: true, filterable: true
976
+
977
+ # Virtual columns - use HAVING for filtering
978
+ column :products_count,
979
+ label: "Products",
980
+ type: :integer,
981
+ sortable: true,
982
+ filterable: true,
983
+ filter_type: :number,
984
+ virtual: true, # ⭐ Enables HAVING clause for filters
985
+ virtual_sql: "COUNT(products.id)" # SQL expression for filtering
986
+
987
+ column :accessories_count,
988
+ label: "Accessories",
989
+ type: :integer,
990
+ sortable: true,
991
+ filterable: true,
992
+ filter_type: :number,
993
+ virtual: true, # ⭐ Enables HAVING clause for filters
994
+ virtual_sql: "COUNT(accessories.id)" # SQL expression for filtering
995
+
996
+ column :total_products_value,
997
+ label: "Total Value",
998
+ type: :decimal,
999
+ sortable: true,
1000
+ filterable: true,
1001
+ filter_type: :number,
1002
+ virtual: true, # ⭐ Enables HAVING clause for filters
1003
+ virtual_sql: "SUM(products.price)", # SQL expression for filtering
1004
+ formatter: :currency
1005
+
1006
+ per_page 25
1007
+ end
1008
+ ```
1009
+
1010
+ **See [docs/virtual_columns.md](docs/virtual_columns.md) for more examples and detailed explanation.**
1011
+
1012
+ ---
1013
+
1014
+ ## Roadmap
1015
+
1016
+ - [ ] Server-side persistence of column settings
1017
+ - [ ] Table state in the URL
1018
+ - [ ] More formatters (link, avatar, percent)
1019
+ - [ ] `infotable:table` generator
1020
+ - [ ] Tailwind v3 support
1021
+
1022
+ ---
1023
+
1024
+ ## Documentation
1025
+
1026
+ - [Installation Guide](docs/installation.md) - Detailed setup for all bundlers
1027
+ - [Tailwind setup](docs/tailwind.md)
1028
+ - [Bundler support](docs/bundlers.md)
1029
+ - [Virtual columns](docs/virtual_columns.md)
1030
+ - [i18n Guide](docs/i18n.md)
1031
+ - [Changelog](CHANGELOG.md)
1032
+
1033
+ ---
1034
+
1035
+ ## Contributing
1036
+
1037
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jcieslar/infotable.
1038
+
1039
+ ---
1040
+
1041
+ ## License
1042
+
1043
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).