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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -10
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +19 -18
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +42 -23
  5. data/app/controllers/infotable/tables_controller.rb +1 -1
  6. data/app/helpers/infotable/tables_helper.rb +10 -25
  7. data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
  8. data/app/views/infotable/tables/_pagination.html.erb +4 -7
  9. data/app/views/infotable/tables/_table.html.erb +1 -1
  10. data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
  11. data/app/views/infotable/tables/filters/_range.html.erb +1 -1
  12. data/app/views/infotable/tables/filters/_select.html.erb +2 -2
  13. data/app/views/infotable/tables/filters/_text.html.erb +2 -2
  14. data/config/locales/de.yml +126 -0
  15. data/config/locales/el.yml +126 -0
  16. data/config/locales/en.yml +18 -4
  17. data/config/locales/es.yml +126 -0
  18. data/config/locales/fr.yml +126 -0
  19. data/config/locales/it.yml +126 -0
  20. data/config/locales/pl.yml +18 -4
  21. data/exe/infotable-mcp +13 -0
  22. data/lib/generators/infotable/table/table_generator.rb +23 -1
  23. data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
  24. data/lib/infotable/base.rb +5 -4
  25. data/lib/infotable/formatters/currency_formatter.rb +4 -12
  26. data/lib/infotable/formatters/date_formatter.rb +3 -9
  27. data/lib/infotable/formatters/duration_formatter.rb +51 -0
  28. data/lib/infotable/formatters/filesize_formatter.rb +14 -0
  29. data/lib/infotable/formatters/image_formatter.rb +31 -0
  30. data/lib/infotable/formatters/number_formatter.rb +46 -0
  31. data/lib/infotable/formatters/tags_formatter.rb +44 -0
  32. data/lib/infotable/formatters/truncate_formatter.rb +19 -0
  33. data/lib/infotable/mcp/introspection.rb +94 -0
  34. data/lib/infotable/mcp/server.rb +26 -0
  35. data/lib/infotable/mcp/table_checker.rb +80 -0
  36. data/lib/infotable/mcp/table_writer.rb +66 -0
  37. data/lib/infotable/mcp/tools.rb +122 -0
  38. data/lib/infotable/mcp.rb +30 -0
  39. data/lib/infotable/version.rb +1 -1
  40. data/lib/infotable.rb +22 -0
  41. metadata +51 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1a84d197b721ed57ad46be7d91a870d0f7d0d3c6a7bc471e4e1c306aefa9e1f
4
- data.tar.gz: 19c3fd871c6e5bee6908266fe877774786b8b2e7d7fa289117b05e65684016e2
3
+ metadata.gz: 31096b54bd71fb8799c287dbd43f91c2e05a3a5d5c8a652bd89f84e97c9fa5ed
4
+ data.tar.gz: 4cec84bed5d53f7599753fa30183e667833278aa4ebd89484cc2495ace2b7262
5
5
  SHA512:
6
- metadata.gz: b8bcbd636497943daa6fb58cec7a8550f4258c14a7a65a771d10c1f4e2776bed51fe6b8ad2137126131b353fedd91076e6d017697b0f405b3694d257bd436098
7
- data.tar.gz: c6a4f20ebe7642c1d8d686be6e8fee8226f1076ee93b2daa5de79f0b7a630f17d264eb64cc067d164fb1de5033f1c08c059ca1e46a7b9d5b7666298f7d5b5a47
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 Polish translations included
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 Polish translations.
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 New Language
883
+ ### Add Another Language
788
884
 
789
885
  ```yaml
790
- # config/locales/infotable.es.yml
791
- es:
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
- handleColumnReorder(tableName, list) {
993
- // Get new order from DOM
994
- const columnItems = list.querySelectorAll('.column-item')
995
- const newOrder = Array.from(columnItems).map(item => item.dataset.columnName)
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
- // Unpin all columns when manually reordering
998
- const pinnedKey = `infotable_column_pinned_${tableName}`
999
- const unpinned = {}
1000
- columnItems.forEach(item => { unpinned[item.dataset.columnName] = null })
1001
- this.storeSet(pinnedKey, JSON.stringify(unpinned))
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
- // Update pin button styling for all items
1004
- columnItems.forEach(item => {
1005
- this.updatePinButtons(item, null)
1006
- })
1004
+ this.saveColumnPinned(tableName, dragged.dataset.columnName, pinned)
1005
+ this.updatePinButtons(dragged, pinned)
1006
+ }
1007
1007
 
1008
- // Save to localStorage
1009
- this.saveColumnOrder(tableName, newOrder)
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; nothing is pinned any more
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
- const headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
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', "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]", "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]")
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', "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]", "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]")
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.forEach(header => {
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: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
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: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
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: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
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: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
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
 
@@ -313,7 +313,7 @@ module Infotable
313
313
  when :boolean
314
314
  value ? "true" : "false"
315
315
  else
316
- value.to_s
316
+ value.is_a?(Array) ? value.join(", ") : value.to_s
317
317
  end
318
318
  end
319
319
  end
@@ -120,29 +120,11 @@ module Infotable
120
120
  formatter_name = column[:formatter]
121
121
  formatter_options = column[:formatter_options] || {}
122
122
 
123
- formatter_class = case formatter_name
124
- when :date, :datetime
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
- raise(Infotable::Error, "Unknown formatter :#{formatter_name} for column :#{column[:name]}. " \
144
- "Built-ins: date, currency, boolean, badge, action, link, avatar, percent; custom: define #{formatter_name.to_s.camelize}Formatter.")
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
- def infotable_drawer_attributes(config)
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}: 0; width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
249
- class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-r-xl' : 'border-l rounded-l-xl'} border-gray-200 dark:border-gray-700 shadow-xl transition-all duration-300 ease-in-out z-50",
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-4 py-4 bg-gray-50 dark:bg-gray-950">
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="space-y-4">
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="group p-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm hover:border-indigo-300 dark:hover:border-indigo-700 transition-all duration-150">
33
- <label class="block text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">
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-4 py-3 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
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 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
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
- <%= t('infotable.pagination.showing') %>
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.of') %>
12
- <span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.count %></span>
13
- <%= t('infotable.pagination.results') %>
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-white dark:bg-gray-900 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-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]">
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-white dark:bg-gray-900 pr-3 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] disabled:cursor-not-allowed disabled:bg-neutral-100"
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-white dark:bg-gray-900 pl-3 py-2 pr-8 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:opacity-75"
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-white dark:bg-gray-900 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-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]">
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) %>