infotable 1.5.0 β 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +144 -10
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +19 -18
- data/app/assets/javascripts/infotable/controllers/table_controller.js +42 -23
- data/app/controllers/infotable/tables_controller.rb +1 -1
- data/app/helpers/infotable/tables_helper.rb +10 -25
- data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
- data/app/views/infotable/tables/_pagination.html.erb +4 -7
- data/app/views/infotable/tables/_table.html.erb +1 -1
- data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
- data/app/views/infotable/tables/filters/_range.html.erb +1 -1
- data/app/views/infotable/tables/filters/_select.html.erb +2 -2
- data/app/views/infotable/tables/filters/_text.html.erb +2 -2
- data/config/locales/de.yml +126 -0
- data/config/locales/el.yml +126 -0
- data/config/locales/en.yml +18 -4
- data/config/locales/es.yml +126 -0
- data/config/locales/fr.yml +126 -0
- data/config/locales/it.yml +126 -0
- data/config/locales/pl.yml +18 -4
- data/exe/infotable-mcp +13 -0
- data/lib/generators/infotable/table/table_generator.rb +23 -1
- data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
- data/lib/infotable/base.rb +5 -4
- data/lib/infotable/formatters/currency_formatter.rb +4 -12
- data/lib/infotable/formatters/date_formatter.rb +3 -9
- data/lib/infotable/formatters/duration_formatter.rb +51 -0
- data/lib/infotable/formatters/filesize_formatter.rb +14 -0
- data/lib/infotable/formatters/image_formatter.rb +31 -0
- data/lib/infotable/formatters/number_formatter.rb +46 -0
- data/lib/infotable/formatters/tags_formatter.rb +44 -0
- data/lib/infotable/formatters/truncate_formatter.rb +19 -0
- data/lib/infotable/mcp/introspection.rb +94 -0
- data/lib/infotable/mcp/server.rb +26 -0
- data/lib/infotable/mcp/table_checker.rb +80 -0
- data/lib/infotable/mcp/table_writer.rb +66 -0
- data/lib/infotable/mcp/tools.rb +122 -0
- data/lib/infotable/mcp.rb +30 -0
- data/lib/infotable/version.rb +1 -1
- data/lib/infotable.rb +22 -0
- metadata +51 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31096b54bd71fb8799c287dbd43f91c2e05a3a5d5c8a652bd89f84e97c9fa5ed
|
|
4
|
+
data.tar.gz: 4cec84bed5d53f7599753fa30183e667833278aa4ebd89484cc2495ace2b7262
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa78ae7189b9c5ed8363ecd04c9abe444ed51ba3fcf231644fb14b624d5f7aa776811335efafc8ba521b99cfb9d8724ceccec647e3f3398e27377f5bc0ebaec6
|
|
7
|
+
data.tar.gz: 6bdaf90359935c4a3a1bad5575748b0dd40c47ce01ebd2fc7f8c10c6604d3b7720f9edde5fa387cd5e16868ce48165cc24183d6eeba1474b01b337621bd3829b
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Modern, flexible datatable engine for Rails 8+ with Hotwire. Build beautiful, in
|
|
|
12
12
|
- π§ **Highly customizable** - Easy to override defaults and add custom formatters
|
|
13
13
|
- π¦ **Works with any bundler** - Importmap, esbuild, webpack, viteβwe support them all
|
|
14
14
|
- π **Rails 8+ ready** - Built for modern Rails applications
|
|
15
|
-
- π **i18n Support** - English and
|
|
15
|
+
- π **i18n Support** - English, Polish, German, French, Spanish, Greek and Italian translations included
|
|
16
16
|
- π― **Advanced Features** - Infinite scroll, column pinning, drag-to-reorder, row links, and more
|
|
17
17
|
|
|
18
18
|
## Quick Start
|
|
@@ -165,6 +165,8 @@ Generate a starting point from a model's columns:
|
|
|
165
165
|
```bash
|
|
166
166
|
rails generate infotable:table User
|
|
167
167
|
rails generate infotable:table Admin::Order --only=id number status # or --skip=notes
|
|
168
|
+
rails generate infotable:table Order --per-page=50 --sort=placed_at:desc --toolbar=rail --checkbox \
|
|
169
|
+
--authorize="controller.current_user&.admin?" # also --row-click --infinite-scroll --countless
|
|
168
170
|
```
|
|
169
171
|
|
|
170
172
|
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).
|
|
@@ -600,6 +602,100 @@ column :actions,
|
|
|
600
602
|
|
|
601
603
|
`method:` and `confirm:` use Turbo (`data-turbo-method`, `data-turbo-confirm`). Labels are HTML-escaped.
|
|
602
604
|
|
|
605
|
+
#### 6. Link Formatter (`:link`)
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
column :website, formatter: :link, formatter_options: { external: true } # the value is the URL
|
|
609
|
+
|
|
610
|
+
column :email,
|
|
611
|
+
formatter: :link,
|
|
612
|
+
formatter_options: { url: ->(record) { "mailto:#{record.email}" } } # email / phone: mailto: and tel:
|
|
613
|
+
|
|
614
|
+
column :name,
|
|
615
|
+
formatter: :link,
|
|
616
|
+
formatter_options: { url: ->(record) { user_path(record) } } # Proc (run in the view), Symbol or String
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Only relative URLs, `http(s)`, `mailto` and `tel` are linked (`javascript:` and friends render as plain text). Options: `url`, `label`, `external` (`target="_blank"` + `rel="noopener noreferrer"`), `class`.
|
|
620
|
+
|
|
621
|
+
#### 7. Avatar Formatter (`:avatar`)
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
column :avatar_url,
|
|
625
|
+
formatter: :avatar,
|
|
626
|
+
formatter_options: { name: ->(record) { record.name }, size: :md } # :sm, :md, :lg
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
The value is the image URL; without one, the initials of `name` are shown on a coloured disc. `label: false` hides the name next to the picture.
|
|
630
|
+
|
|
631
|
+
#### 8. Percent Formatter (`:percent`)
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
column :completion, formatter: :percent, formatter_options: { precision: 1, bar: true }
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Options: `precision` (default 0), `scale: :percent` (0-100, default) or `:ratio` (0-1), `bar` (small progress bar).
|
|
638
|
+
|
|
639
|
+
#### 9. Number Formatter (`:number`)
|
|
640
|
+
|
|
641
|
+
```ruby
|
|
642
|
+
column :views_count, formatter: :number, formatter_options: { compact: true } # 1.5k, 3.4M
|
|
643
|
+
column :latency, formatter: :number, formatter_options: { unit: "ms", precision: 1 }
|
|
644
|
+
column :stock, formatter: :number, formatter_options: { zero: "-" }
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Options: `precision`, `delimiter` (`","`), `separator` (`"."`), `unit` (appended), `compact`, `zero` (shown instead of 0).
|
|
648
|
+
|
|
649
|
+
#### 10. Tags Formatter (`:tags`, alias `:list`)
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
column :roles,
|
|
653
|
+
formatter: :tags,
|
|
654
|
+
formatter_options: { limit: 3, colors: { "admin" => "red" } }
|
|
655
|
+
|
|
656
|
+
# several values in a cell that is not an attribute: use `value:`
|
|
657
|
+
column :labels, value: ->(post) { post.tags.map(&:name) }, formatter: :tags, formatter_options: { limit: 2 }
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The value is an Array, a relation or a comma separated string; each item is a small badge. `limit` shows N items and "+M" (the rest is in its tooltip), `label: ->(item) { item.name }` picks the text, `colors` maps item to badge colour (as in `:badge`), `separator: ", "` renders plain text instead of badges. The CSV export joins the items with `", "`.
|
|
661
|
+
|
|
662
|
+
#### 11. Truncate Formatter (`:truncate`)
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
column :description, formatter: :truncate, formatter_options: { length: 80 }
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Long text is cut to `length` (default 60, `omission` default `"β¦"`); the full text is in the cell's tooltip.
|
|
669
|
+
|
|
670
|
+
#### 12. Image Formatter (`:image`)
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
column :photo_url,
|
|
674
|
+
formatter: :image,
|
|
675
|
+
formatter_options: { size: :md, alt: ->(record) { record.name }, link: true } # :sm (32px), :md (48px), :lg (64px)
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
A square thumbnail (use `:avatar` for round pictures). Only http(s) and relative URLs are rendered. `link: true` opens the full image in a new tab.
|
|
679
|
+
|
|
680
|
+
#### 13. Filesize Formatter (`:filesize`)
|
|
681
|
+
|
|
682
|
+
```ruby
|
|
683
|
+
column :byte_size, formatter: :filesize # 1536 => "1.5 KB"
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`precision` is the number of significant digits (default 2).
|
|
687
|
+
|
|
688
|
+
#### 14. Duration Formatter (`:duration`)
|
|
689
|
+
|
|
690
|
+
```ruby
|
|
691
|
+
column :duration_seconds, formatter: :duration # 3900 => "1h 05m"
|
|
692
|
+
column :duration_seconds, formatter: :duration, formatter_options: { format: :long } # "1 hour 5 minutes"
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
The value is in seconds. `parts` limits how many units are shown (default 2). Translated through `infotable.formatters.duration` (Polish plural forms work out of the box).
|
|
696
|
+
|
|
697
|
+
The Date formatter's `format: :relative` keeps the exact time in a tooltip, and the Currency formatter takes `unit`, `precision`, `delimiter` and `separator` from your locale (`number.currency.format`) for whatever you do not pass.
|
|
698
|
+
|
|
603
699
|
### Custom Formatters
|
|
604
700
|
|
|
605
701
|
#### Lambda/Proc Formatter
|
|
@@ -747,7 +843,7 @@ config.autofilter = false # Require "Apply Filters" button click
|
|
|
747
843
|
|
|
748
844
|
## Internationalization (i18n)
|
|
749
845
|
|
|
750
|
-
InfoTable includes English and
|
|
846
|
+
InfoTable includes English, Polish, German, French, Spanish, Greek and Italian translations. Any key can be overridden in your app.
|
|
751
847
|
|
|
752
848
|
### Set Locale
|
|
753
849
|
|
|
@@ -769,7 +865,7 @@ end
|
|
|
769
865
|
### Available Languages
|
|
770
866
|
|
|
771
867
|
- English (`:en`) - Default
|
|
772
|
-
- Polish (`:pl`)
|
|
868
|
+
- Polish (`:pl`), German (`:de`), French (`:fr`), Spanish (`:es`), Greek (`:el`), Italian (`:it`)
|
|
773
869
|
|
|
774
870
|
### Customize Translations
|
|
775
871
|
|
|
@@ -784,11 +880,11 @@ en:
|
|
|
784
880
|
export_csv: "Download CSV"
|
|
785
881
|
```
|
|
786
882
|
|
|
787
|
-
### Add
|
|
883
|
+
### Add Another Language
|
|
788
884
|
|
|
789
885
|
```yaml
|
|
790
|
-
# config/locales/infotable.
|
|
791
|
-
|
|
886
|
+
# config/locales/infotable.pt.yml
|
|
887
|
+
pt:
|
|
792
888
|
infotable:
|
|
793
889
|
table:
|
|
794
890
|
buttons:
|
|
@@ -838,6 +934,48 @@ Built-in CSV export with:
|
|
|
838
934
|
|
|
839
935
|
---
|
|
840
936
|
|
|
937
|
+
## MCP Server (AI assistants)
|
|
938
|
+
|
|
939
|
+
The gem ships an [MCP](https://modelcontextprotocol.io) server, so an AI assistant (Claude Code, Cursor, ...) can create tables in your app: it reads your models, asks you how the table should look, generates `app/infotable/*_table.rb` and checks that it works. It talks to your app over stdio on your machine, no port is opened.
|
|
940
|
+
|
|
941
|
+
Add the `mcp` gem to the app (it is not a dependency of infotable):
|
|
942
|
+
|
|
943
|
+
```ruby
|
|
944
|
+
# Gemfile
|
|
945
|
+
gem "mcp", group: :development
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
Register the server in the client, from the app's root. For Claude Code:
|
|
949
|
+
|
|
950
|
+
```bash
|
|
951
|
+
claude mcp add infotable -- bundle exec infotable-mcp
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
or commit a `.mcp.json` (Cursor: `.cursor/mcp.json`):
|
|
955
|
+
|
|
956
|
+
```json
|
|
957
|
+
{
|
|
958
|
+
"mcpServers": {
|
|
959
|
+
"infotable": { "command": "bundle", "args": ["exec", "infotable-mcp"] }
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
Then ask: *"create an InfoTable table for Order"*. Or use the `new_table` prompt, which makes the assistant ask for the settings first.
|
|
965
|
+
|
|
966
|
+
| Tool | What it does |
|
|
967
|
+
|------|--------------|
|
|
968
|
+
| `list_models` | Models with column types, enums and associations |
|
|
969
|
+
| `list_tables` | Tables that already exist, with their settings (the house style) |
|
|
970
|
+
| `describe_dsl` | Column options, types, filters, formatters and settings of the installed version |
|
|
971
|
+
| `table_options` | Questions about a new table (columns, sort, per page, toolbar, selection, authorization...) with defaults proposed from the model |
|
|
972
|
+
| `generate_table` | Renders the table with the `infotable:table` generator. Only shows the code; `write: true` saves it, an existing file is replaced only with `overwrite: true` |
|
|
973
|
+
| `validate_table` | Loads a table by name or from source and reports missing columns, a bad `default_sort`, a failing query or a missing `authorize`; `preview: 5` returns the first rows |
|
|
974
|
+
|
|
975
|
+
The full README is available to the assistant as the `infotable://readme` resource.
|
|
976
|
+
|
|
977
|
+
Notes: the server boots your app (development environment unless `RAILS_ENV` is set) and reads the development database; it writes only to `app/infotable/`. Tables are generated without `authorize` unless you give one, and `validate_table` warns about that (see [Security](#security)).
|
|
978
|
+
|
|
841
979
|
## Examples
|
|
842
980
|
|
|
843
981
|
### Basic Table
|
|
@@ -1080,10 +1218,6 @@ end
|
|
|
1080
1218
|
## Roadmap
|
|
1081
1219
|
|
|
1082
1220
|
- [ ] Server-side persistence of column settings
|
|
1083
|
-
- [ ] Table state in the URL
|
|
1084
|
-
- [ ] More formatters (link, avatar, percent)
|
|
1085
|
-
- [ ] `infotable:table` generator
|
|
1086
|
-
- [ ] Tailwind v3 support
|
|
1087
1221
|
|
|
1088
1222
|
---
|
|
1089
1223
|
|
|
@@ -976,7 +976,7 @@ export default class extends Controller {
|
|
|
976
976
|
ghostClass: 'opacity-50',
|
|
977
977
|
dragClass: 'shadow-lg',
|
|
978
978
|
onEnd: (evt) => {
|
|
979
|
-
this.handleColumnReorder(tableName, list)
|
|
979
|
+
this.handleColumnReorder(tableName, list, evt.item)
|
|
980
980
|
}
|
|
981
981
|
})
|
|
982
982
|
|
|
@@ -988,27 +988,28 @@ export default class extends Controller {
|
|
|
988
988
|
})
|
|
989
989
|
}
|
|
990
990
|
|
|
991
|
-
// Handle column reorder after drag and drop
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const
|
|
991
|
+
// Handle column reorder after drag and drop.
|
|
992
|
+
// Pins survive a reorder; only the dragged column can change: dropped among the left pins it pins
|
|
993
|
+
// left, among the right pins it pins right, anywhere else it is unpinned.
|
|
994
|
+
handleColumnReorder(tableName, list, dragged) {
|
|
995
|
+
const items = Array.from(list.querySelectorAll('.column-item'))
|
|
996
|
+
const pinnedData = this.getAllColumnsPinned(tableName)
|
|
996
997
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
998
|
+
if (dragged) {
|
|
999
|
+
const at = items.indexOf(dragged)
|
|
1000
|
+
const nextPin = pinnedData[items[at + 1]?.dataset.columnName]
|
|
1001
|
+
const prevPin = pinnedData[items[at - 1]?.dataset.columnName]
|
|
1002
|
+
const pinned = nextPin === 'left' ? 'left' : (prevPin === 'right' ? 'right' : null)
|
|
1002
1003
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
})
|
|
1004
|
+
this.saveColumnPinned(tableName, dragged.dataset.columnName, pinned)
|
|
1005
|
+
this.updatePinButtons(dragged, pinned)
|
|
1006
|
+
}
|
|
1007
1007
|
|
|
1008
|
-
//
|
|
1009
|
-
this.
|
|
1008
|
+
// Keep the list as left pins, the rest, right pins (this also saves the order)
|
|
1009
|
+
this.reorderColumnsWithPinning(tableName)
|
|
1010
|
+
const newOrder = Array.from(list.querySelectorAll('.column-item')).map(item => item.dataset.columnName)
|
|
1010
1011
|
|
|
1011
|
-
// Apply order to the table
|
|
1012
|
+
// Apply order and pins to the table
|
|
1012
1013
|
this.applyToTable(tableName, newOrder)
|
|
1013
1014
|
|
|
1014
1015
|
// Dispatch event for export link update
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
+
// Innermost pinned column on each side: an inset 1px line. A border would stay behind when the
|
|
4
|
+
// cell sticks (collapsed borders belong to the table), a box-shadow moves with the cell
|
|
5
|
+
const LEFT_EDGE = 'inset -1px 0 0 0 rgba(128, 128, 128, 0.45)'
|
|
6
|
+
const RIGHT_EDGE = 'inset 1px 0 0 0 rgba(128, 128, 128, 0.45)'
|
|
7
|
+
|
|
3
8
|
// Main table controller that coordinates table interactions
|
|
4
9
|
export default class extends Controller {
|
|
5
10
|
static values = {
|
|
@@ -989,7 +994,7 @@ export default class extends Controller {
|
|
|
989
994
|
return
|
|
990
995
|
}
|
|
991
996
|
|
|
992
|
-
|
|
997
|
+
let headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
|
|
993
998
|
|
|
994
999
|
// Columns the user never pinned/unpinned fall back to the table's `pinned:` default
|
|
995
1000
|
headers.forEach(header => {
|
|
@@ -1000,6 +1005,32 @@ export default class extends Controller {
|
|
|
1000
1005
|
})
|
|
1001
1006
|
const rows = table.querySelectorAll("tbody tr")
|
|
1002
1007
|
|
|
1008
|
+
// Pinned columns jump to their edge, keeping their relative order, so they sit side by side
|
|
1009
|
+
// with no gap; unpinning restores the base order (stored order, else the server's)
|
|
1010
|
+
const names = headers.map(h => h.dataset.columnName)
|
|
1011
|
+
headers.forEach((h, i) => { if (h.dataset.pinBase === undefined) h.dataset.pinBase = String(i) })
|
|
1012
|
+
const storedOrder = this.loadColumnOrder(tableName)
|
|
1013
|
+
const base = storedOrder && this.isSchemaValid(storedOrder, names)
|
|
1014
|
+
? storedOrder
|
|
1015
|
+
: [...headers].sort((a, b) => a.dataset.pinBase - b.dataset.pinBase).map(h => h.dataset.columnName)
|
|
1016
|
+
const leftNames = base.filter(n => pinnedData[n] === 'left')
|
|
1017
|
+
const rightNames = base.filter(n => pinnedData[n] === 'right')
|
|
1018
|
+
const pinOrder = [...leftNames, ...base.filter(n => !leftNames.includes(n) && !rightNames.includes(n)), ...rightNames]
|
|
1019
|
+
// Re-appending a node drops its focus, so only touch rows that are out of order
|
|
1020
|
+
const inOrder = els => els.every((el, i) => el.dataset.columnName === pinOrder[i])
|
|
1021
|
+
const headerRow = headers[0]?.parentElement
|
|
1022
|
+
if (headerRow && !inOrder(headers)) {
|
|
1023
|
+
const byName = new Map(headers.map(h => [h.dataset.columnName, h]))
|
|
1024
|
+
pinOrder.forEach(n => byName.has(n) && headerRow.appendChild(byName.get(n)))
|
|
1025
|
+
headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
|
|
1026
|
+
}
|
|
1027
|
+
rows.forEach(row => {
|
|
1028
|
+
const cells = Array.from(row.querySelectorAll("td[data-column-name]"))
|
|
1029
|
+
if (inOrder(cells)) return
|
|
1030
|
+
const cellByName = new Map(cells.map(c => [c.dataset.columnName, c]))
|
|
1031
|
+
pinOrder.forEach(n => cellByName.has(n) && row.appendChild(cellByName.get(n)))
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1003
1034
|
|
|
1004
1035
|
// Calculate positions for pinned columns
|
|
1005
1036
|
let leftOffset = 0
|
|
@@ -1023,7 +1054,7 @@ export default class extends Controller {
|
|
|
1023
1054
|
header.style.backgroundClip = ''
|
|
1024
1055
|
header.style.boxShadow = ''
|
|
1025
1056
|
header.querySelectorAll('[data-pin-icon]').forEach(icon => icon.remove())
|
|
1026
|
-
header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800'
|
|
1057
|
+
header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
|
|
1027
1058
|
})
|
|
1028
1059
|
|
|
1029
1060
|
rows.forEach(row => {
|
|
@@ -1043,7 +1074,7 @@ export default class extends Controller {
|
|
|
1043
1074
|
cell.style.removeProperty('opacity')
|
|
1044
1075
|
cell.style.backgroundClip = ''
|
|
1045
1076
|
cell.style.boxShadow = ''
|
|
1046
|
-
cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800'
|
|
1077
|
+
cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
|
|
1047
1078
|
})
|
|
1048
1079
|
})
|
|
1049
1080
|
|
|
@@ -1062,8 +1093,10 @@ export default class extends Controller {
|
|
|
1062
1093
|
}
|
|
1063
1094
|
})
|
|
1064
1095
|
|
|
1065
|
-
// Apply sticky positioning for pinned columns
|
|
1066
|
-
headers.
|
|
1096
|
+
// Apply sticky positioning for pinned columns (right ones from the outer edge inwards)
|
|
1097
|
+
const rightPinnedHeaders = headers.filter(h => pinnedData[h.dataset.columnName] === 'right')
|
|
1098
|
+
const applyOrder = [...headers.filter(h => pinnedData[h.dataset.columnName] === 'left'), ...rightPinnedHeaders.reverse()]
|
|
1099
|
+
applyOrder.forEach(header => {
|
|
1067
1100
|
const columnName = header.dataset.columnName
|
|
1068
1101
|
const pinned = pinnedData[columnName]
|
|
1069
1102
|
|
|
@@ -1079,7 +1112,7 @@ export default class extends Controller {
|
|
|
1079
1112
|
z-index: 30 !important;
|
|
1080
1113
|
opacity: 1 !important;
|
|
1081
1114
|
background-clip: padding-box !important;
|
|
1082
|
-
box-shadow:
|
|
1115
|
+
box-shadow: ${columnName === lastLeftPinned ? LEFT_EDGE : 'none'} !important;
|
|
1083
1116
|
${existingStyles}
|
|
1084
1117
|
`.replace(/\s+/g, ' ').trim()
|
|
1085
1118
|
|
|
@@ -1088,10 +1121,6 @@ export default class extends Controller {
|
|
|
1088
1121
|
header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
|
|
1089
1122
|
this.addPinIcon(header, 'left')
|
|
1090
1123
|
|
|
1091
|
-
// Mark last left-pinned column
|
|
1092
|
-
if (columnName === lastLeftPinned) {
|
|
1093
|
-
header.classList.add("after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]")
|
|
1094
|
-
}
|
|
1095
1124
|
|
|
1096
1125
|
// Apply to cells in all rows
|
|
1097
1126
|
rows.forEach(row => {
|
|
@@ -1107,16 +1136,13 @@ export default class extends Controller {
|
|
|
1107
1136
|
z-index: 15 !important;
|
|
1108
1137
|
opacity: 1 !important;
|
|
1109
1138
|
background-clip: padding-box !important;
|
|
1110
|
-
box-shadow:
|
|
1139
|
+
box-shadow: ${columnName === lastLeftPinned ? LEFT_EDGE : 'none'} !important;
|
|
1111
1140
|
${existingStyles}
|
|
1112
1141
|
`.replace(/\s+/g, ' ').trim()
|
|
1113
1142
|
|
|
1114
1143
|
cell.setAttribute('style', newStyles)
|
|
1115
1144
|
cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
|
|
1116
1145
|
|
|
1117
|
-
if (columnName === lastLeftPinned) {
|
|
1118
|
-
cell.classList.add("after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]")
|
|
1119
|
-
}
|
|
1120
1146
|
}
|
|
1121
1147
|
})
|
|
1122
1148
|
|
|
@@ -1133,7 +1159,7 @@ export default class extends Controller {
|
|
|
1133
1159
|
z-index: 30 !important;
|
|
1134
1160
|
opacity: 1 !important;
|
|
1135
1161
|
background-clip: padding-box !important;
|
|
1136
|
-
box-shadow:
|
|
1162
|
+
box-shadow: ${columnName === firstRightPinned ? RIGHT_EDGE : 'none'} !important;
|
|
1137
1163
|
${existingStyles}
|
|
1138
1164
|
`.replace(/\s+/g, ' ').trim()
|
|
1139
1165
|
|
|
@@ -1141,10 +1167,6 @@ export default class extends Controller {
|
|
|
1141
1167
|
header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
|
|
1142
1168
|
this.addPinIcon(header, 'right')
|
|
1143
1169
|
|
|
1144
|
-
// Mark first right-pinned column
|
|
1145
|
-
if (columnName === firstRightPinned) {
|
|
1146
|
-
header.classList.add("before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
|
|
1147
|
-
}
|
|
1148
1170
|
|
|
1149
1171
|
// Apply to cells in all rows
|
|
1150
1172
|
rows.forEach(row => {
|
|
@@ -1160,16 +1182,13 @@ export default class extends Controller {
|
|
|
1160
1182
|
z-index: 15 !important;
|
|
1161
1183
|
opacity: 1 !important;
|
|
1162
1184
|
background-clip: padding-box !important;
|
|
1163
|
-
box-shadow:
|
|
1185
|
+
box-shadow: ${columnName === firstRightPinned ? RIGHT_EDGE : 'none'} !important;
|
|
1164
1186
|
${existingStyles}
|
|
1165
1187
|
`.replace(/\s+/g, ' ').trim()
|
|
1166
1188
|
|
|
1167
1189
|
cell.setAttribute('style', newStyles)
|
|
1168
1190
|
cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
|
|
1169
1191
|
|
|
1170
|
-
if (columnName === firstRightPinned) {
|
|
1171
|
-
cell.classList.add("before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
|
|
1172
|
-
}
|
|
1173
1192
|
}
|
|
1174
1193
|
})
|
|
1175
1194
|
|
|
@@ -120,29 +120,11 @@ module Infotable
|
|
|
120
120
|
formatter_name = column[:formatter]
|
|
121
121
|
formatter_options = column[:formatter_options] || {}
|
|
122
122
|
|
|
123
|
-
formatter_class =
|
|
124
|
-
|
|
125
|
-
Infotable::Formatters::DateFormatter
|
|
126
|
-
when :currency
|
|
127
|
-
Infotable::Formatters::CurrencyFormatter
|
|
128
|
-
when :boolean
|
|
129
|
-
Infotable::Formatters::BooleanFormatter
|
|
130
|
-
when :badge, :status
|
|
131
|
-
Infotable::Formatters::BadgeFormatter
|
|
132
|
-
when :action, :actions
|
|
133
|
-
Infotable::Formatters::ActionFormatter
|
|
134
|
-
when :link
|
|
135
|
-
Infotable::Formatters::LinkFormatter
|
|
136
|
-
when :avatar
|
|
137
|
-
Infotable::Formatters::AvatarFormatter
|
|
138
|
-
when :percent
|
|
139
|
-
Infotable::Formatters::PercentFormatter
|
|
140
|
-
else
|
|
141
|
-
# Custom formatter: :avatar => AvatarFormatter (or Namespaced::AvatarFormatter)
|
|
123
|
+
formatter_class = Infotable::Formatters.built_in[formatter_name.to_sym] ||
|
|
124
|
+
# Custom formatter: :stars => StarsFormatter (or Namespaced::StarsFormatter)
|
|
142
125
|
"#{formatter_name.to_s.camelize}Formatter".safe_constantize ||
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
end
|
|
126
|
+
raise(Infotable::Error, "Unknown formatter :#{formatter_name} for column :#{column[:name]}. " \
|
|
127
|
+
"Built-ins: #{Infotable::Formatters.built_in.keys.join(', ')}; custom: define #{formatter_name.to_s.camelize}Formatter.")
|
|
146
128
|
|
|
147
129
|
formatter_class.new(self).format(value, record, formatter_options)
|
|
148
130
|
end
|
|
@@ -239,14 +221,17 @@ module Infotable
|
|
|
239
221
|
DEFAULT_DRAWER_WIDTH = "26rem".freeze
|
|
240
222
|
|
|
241
223
|
# HTML attributes for the slide-in drawer (side and width from config)
|
|
242
|
-
|
|
224
|
+
# `rail`: the drawer sits flush against the rail, so no rounded corners or heavy shadow
|
|
225
|
+
def infotable_drawer_attributes(config, rail: false)
|
|
243
226
|
side = config.drawer_position.to_s == "left" ? "left" : "right"
|
|
244
227
|
width = config.drawer_width.to_s.match?(DRAWER_WIDTH_FORMAT) ? config.drawer_width.to_s : DEFAULT_DRAWER_WIDTH
|
|
245
228
|
hidden = side == "left" ? "-100%" : "100%"
|
|
246
229
|
|
|
230
|
+
# With a rail the drawer must line up with the card's inner edge: inset it by the card's 1px border
|
|
231
|
+
inset = rail ? "1px" : "0"
|
|
247
232
|
{
|
|
248
|
-
style: "#{side}:
|
|
249
|
-
class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-r-xl' : '
|
|
233
|
+
style: "#{side}: #{inset}; #{"top: 1px; bottom: 1px; " if rail}width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
|
|
234
|
+
class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r' : 'border-l'}#{side == 'left' ? (rail ? ' rounded-l-[11px]' : ' rounded-r-xl') : (rail ? ' rounded-r-[11px]' : ' rounded-l-xl')} border-gray-200 dark:border-gray-700 #{rail ? 'shadow-none' : 'shadow-xl'} transition-all duration-300 ease-in-out z-50",
|
|
250
235
|
role: "dialog",
|
|
251
236
|
inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
|
|
252
237
|
aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
end %>
|
|
7
7
|
<div class="flex flex-col h-full">
|
|
8
8
|
<%# Drawer Content - Scrollable %>
|
|
9
|
-
<div class="flex-1 overflow-y-auto px-
|
|
9
|
+
<div class="flex-1 overflow-y-auto px-5 py-1 bg-white dark:bg-gray-900">
|
|
10
10
|
<form id="filter-form-<%= table_name %>"
|
|
11
11
|
action="<%= infotable_table_data_path(table_name: table_name) %>"
|
|
12
12
|
method="get"
|
|
13
13
|
data-table-frame="<%= table_name %>_content"
|
|
14
14
|
data-action="submit->infotable--drawer#submitFilters"
|
|
15
15
|
data-autofilter="<%= autofilter.to_s %>"
|
|
16
|
-
class="
|
|
16
|
+
class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
17
17
|
|
|
18
18
|
<%# Preserve sort and per_page params %>
|
|
19
19
|
<% if params[:sort].is_a?(String) && params[:sort].present? %>
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
<%# Render filter for each filterable column %>
|
|
31
31
|
<% table_class.filterable_columns.each do |column| %>
|
|
32
|
-
<div class="
|
|
33
|
-
<label class="block text-
|
|
32
|
+
<div class="py-4">
|
|
33
|
+
<label class="block text-[13px] font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
34
34
|
<%= column[:label] %>
|
|
35
35
|
</label>
|
|
36
36
|
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
47
|
<%# Drawer Footer with actions %>
|
|
48
|
-
<div class="px-
|
|
48
|
+
<div class="px-5 py-3 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
49
49
|
<div class="flex gap-2">
|
|
50
50
|
<button type="button"
|
|
51
51
|
data-action="click->infotable--drawer#clearFilters"
|
|
52
52
|
data-table-name="<%= table_name %>"
|
|
53
|
-
class="<%= autofilter ? 'w-full' : 'flex-1' %> px-4 py-2
|
|
53
|
+
class="<%= autofilter ? 'w-full' : 'flex-1' %> px-4 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
|
|
54
54
|
<%= t('infotable.filters.actions.clear_all') %>
|
|
55
55
|
</button>
|
|
56
56
|
<% unless autofilter %>
|
|
@@ -3,14 +3,11 @@
|
|
|
3
3
|
<%# Results info and per-page selector %>
|
|
4
4
|
<div class="flex-1 flex items-center gap-4">
|
|
5
5
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
|
6
|
-
|
|
7
|
-
<span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.from %></span>
|
|
8
|
-
<%= t('infotable.pagination.to') %>
|
|
9
|
-
<span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.to %></span>
|
|
6
|
+
<% bold = ->(value) { tag.span(value, class: "font-semibold text-gray-900 dark:text-gray-100") } %>
|
|
10
7
|
<% if pagy.count %>
|
|
11
|
-
<%= t('infotable.pagination.
|
|
12
|
-
|
|
13
|
-
<%= t('infotable.pagination.
|
|
8
|
+
<%= t('infotable.pagination.summary_html', from: bold.(pagy.from), to: bold.(pagy.to), total: bold.(pagy.count)) %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<%= t('infotable.pagination.summary_no_total_html', from: bold.(pagy.from), to: bold.(pagy.to)) %>
|
|
14
11
|
<% end %>
|
|
15
12
|
</p>
|
|
16
13
|
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
|
|
144
144
|
<%# Side panel drawer - always available for column visibility, filters if available %>
|
|
145
145
|
<%# Drawer - positioned within table bounds, starts completely hidden off-screen %>
|
|
146
|
-
<div <%= tag.attributes(infotable_drawer_attributes(config)) %>>
|
|
146
|
+
<div <%= tag.attributes(infotable_drawer_attributes(config, rail: rail)) %>>
|
|
147
147
|
<div data-infotable--drawer-target="content" class="h-full flex flex-col overflow-hidden">
|
|
148
148
|
<%= render "infotable/tables/side_panel", table_name: table_name, table_class: table_class, config: config %>
|
|
149
149
|
</div>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div class="relative">
|
|
3
3
|
<select name="filters[<%= column[:name] %>][value]"
|
|
4
4
|
id="<%= infotable_filter_id(table_name, column, 'value') %>"
|
|
5
|
-
class="block w-full appearance-none rounded-lg border-0 bg-
|
|
5
|
+
class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
|
|
6
6
|
<option value=""><%= t('infotable.filters.all') %></option>
|
|
7
7
|
<option value="true"><%= t('infotable.filters.boolean.true') %></option>
|
|
8
8
|
<option value="false"><%= t('infotable.filters.boolean.false') %></option>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
kind = kind.to_sym
|
|
9
9
|
input_type = kind == :date ? "date" : "number"
|
|
10
10
|
ops = kind == :date ? %w[on before after] : %w[eq neq gt gte lt lte]
|
|
11
|
-
input_class = "block w-full rounded-lg border-0 bg-
|
|
11
|
+
input_class = "block w-full rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-3 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors disabled:cursor-not-allowed disabled:bg-gray-100"
|
|
12
12
|
select_class = "#{input_class} appearance-none pr-10 cursor-pointer bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]"
|
|
13
13
|
step = kind == :number ? "any" : nil
|
|
14
14
|
%>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
id="<%= infotable_filter_id(table_name, column, 'value') %>"
|
|
15
15
|
multiple
|
|
16
16
|
size="6"
|
|
17
|
-
class="block w-full appearance-none rounded-lg border-0 bg-
|
|
17
|
+
class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pl-3 py-2 pr-8 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75"
|
|
18
18
|
style="min-height: 120px;">
|
|
19
19
|
<% options.each do |option| %>
|
|
20
20
|
<% if option.is_a?(Array) %>
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
<div class="relative">
|
|
42
42
|
<select name="filters[<%= column[:name] %>][value]"
|
|
43
43
|
id="<%= infotable_filter_id(table_name, column, 'value') %>"
|
|
44
|
-
class="block w-full appearance-none rounded-lg border-0 bg-
|
|
44
|
+
class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
|
|
45
45
|
<option value=""><%= t('infotable.filters.all') %></option>
|
|
46
46
|
<% options.each do |option| %>
|
|
47
47
|
<% if option.is_a?(Array) %>
|