stimulus_grid_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +359 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/stimulus_grid.js +1547 -0
- data/app/assets/javascripts/stimulus_grid_rails.js +630 -0
- data/app/assets/stylesheets/stimulus_grid.css +1 -0
- data/app/assets/stylesheets/stimulus_grid_rails.css +47 -0
- data/app/controllers/stimulus_grid_rails/base_controller.rb +51 -0
- data/app/controllers/stimulus_grid_rails/cells_controller.rb +113 -0
- data/app/controllers/stimulus_grid_rails/history_controller.rb +53 -0
- data/app/controllers/stimulus_grid_rails/rows_controller.rb +98 -0
- data/app/models/stimulus_grid_rails/audit.rb +32 -0
- data/app/views/stimulus_grid_rails/grids/_grid.html.erb +107 -0
- data/app/views/stimulus_grid_rails/grids/_row.html.erb +15 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20260520000001_create_stimulus_grid_audits.rb +18 -0
- data/lib/stimulus_grid_rails/column.rb +239 -0
- data/lib/stimulus_grid_rails/concerns/broadcastable.rb +91 -0
- data/lib/stimulus_grid_rails/engine.rb +39 -0
- data/lib/stimulus_grid_rails/grid.rb +221 -0
- data/lib/stimulus_grid_rails/turbo_streams_helper.rb +105 -0
- data/lib/stimulus_grid_rails/version.rb +3 -0
- data/lib/stimulus_grid_rails.rb +79 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 418fa2a09b084f0fcb354ac740a688e60f627f89243aa1c58320bd16ca66df2a
|
|
4
|
+
data.tar.gz: 6ecdef581b5e7353d352f02de7f193116584e8467d77eefe71551231ef62ace9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e2391a0714ef82bcd6a60b2aa9b11994f3ce967a242246ff9d59fe981e97f1d43cd1dab5de89b2f55af0586456decabcab03e851495b81dbe28f86dbab5b5f44
|
|
7
|
+
data.tar.gz: d82a56e4832bacc9ca5fbe0e853816232aa6baf2b731c52fe2596a7a97ab0644a9d38aeeca6f7d9d686a8da7062a8c360ac8aab45a63463ccfe68b25fb8f3ece
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
Initial MVP slice of the Rails + Hotwire bindings for stimulus_grid.
|
|
6
|
+
|
|
7
|
+
- `StimulusGridRails::Grid` — server-side column definition registry (RAILS.md §7)
|
|
8
|
+
with per-column `type`, `editable` (bool or `(row, user)` lambda), `editor`,
|
|
9
|
+
`editor_config`, `validate`, `concurrency`, and `computed`/`depends_on`.
|
|
10
|
+
- `StimulusGridRails::Column` — coercion, validation, editor selection, and the
|
|
11
|
+
data-attributes emitted into headers/cells.
|
|
12
|
+
- Custom Turbo Stream actions (RAILS.md §1), server helpers + client handlers:
|
|
13
|
+
`cell`, `cell-attr`, `cell-confirm`, `cell-revert`, `cell-conflict`,
|
|
14
|
+
`row-insert-sorted`, `row-remove`, `aggregate`, `bulk`, `presence`.
|
|
15
|
+
- `CellsController#update` — single PATCH cell-mutation endpoint (§8) with
|
|
16
|
+
optimistic-id reconciliation (§4), server-side `editable?` re-check (§17),
|
|
17
|
+
version-checked concurrency (§13), and computed-column cascade replayed as a
|
|
18
|
+
`bulk` stream (§12).
|
|
19
|
+
- `StimulusGridRails::Broadcastable` — model concern for cell + row-remove
|
|
20
|
+
broadcasts over Turbo::StreamsChannel.
|
|
21
|
+
- Importmap-pinnable JS (`stimulus_grid`, `stimulus_grid_rails`) + shipped CSS;
|
|
22
|
+
no JS build step required in host apps.
|
|
23
|
+
- `grid-sync` Stimulus controller — turns base-grid `cellValueChanged` events
|
|
24
|
+
into optimistic PATCHes and reconciles the response; suppresses the
|
|
25
|
+
originating client's own broadcast echo.
|
|
26
|
+
- Row create/destroy (RAILS.md §14/§15): `RowsController` with create,
|
|
27
|
+
destroy, and bulk-destroy; `Grid#new_row_defaults`/`build_new_row`/`row_to_json`;
|
|
28
|
+
`Column` `sortable:`/`filterable:` options for action columns; grid-sync
|
|
29
|
+
`addRow`/`removeRow`/`deleteSelected` via `grid-sync:add-row` /
|
|
30
|
+
`grid-sync:delete-selected` events + delegated per-row delete buttons.
|
|
31
|
+
- Editor cell navigation: Tab / Shift+Tab move the open editor to the next /
|
|
32
|
+
previous editable cell (wraps within the page), committing as they go.
|
|
33
|
+
- Server-side global search + per-column filtering (RAILS.md §21): Column
|
|
34
|
+
searchable:/search_predicate/filter_predicate (Arel), Grid scope/search_and_filter,
|
|
35
|
+
GET /grids/:resource/rows JSON endpoint, grid-sync debounced fetch → setRowData.
|
|
36
|
+
- New :bigint column type (alongside :integer).
|
|
37
|
+
- Automatic broadcasts (RAILS.md §1/§4): Broadcastable auto-broadcasts
|
|
38
|
+
create/update/destroy (incl. computed cascade) from commit callbacks;
|
|
39
|
+
broadcasts_grid now takes only the grid class.
|
|
40
|
+
- Tenant/auth safety (RAILS.md §2/§17): controllers inherit
|
|
41
|
+
StimulusGridRails.parent_controller (Devise + ActsAsTenant before_actions);
|
|
42
|
+
scoped row lookups (grid.scope(user).find); tenant-scoped stream names.
|
|
43
|
+
- Undo/redo (RAILS.md §16): StimulusGridRails::Audit + migration; per cell-commit
|
|
44
|
+
recording; POST /undo and /redo replay via apply_cell!; Cmd/Ctrl+Z and
|
|
45
|
+
Cmd/Ctrl+Shift+Z (Ctrl+Y) shortcuts in grid-sync.
|
|
46
|
+
- Editable custom cells: a column can declare cell_editor: (a <template>) so a
|
|
47
|
+
custom-rendered cell is fully editable.
|
|
48
|
+
- Configurable mount path: StimulusGridRails.mount_path (default "/grids"). The
|
|
49
|
+
grid builds its client endpoints from it, so the engine can be namespaced
|
|
50
|
+
(e.g. "/admin/grids") without depending on the engine's route-helper name.
|
|
51
|
+
- Removed the unused stream_name/stream_name_for Grid API (streams are derived
|
|
52
|
+
from streamables_for + tenant token since the automatic-broadcast refactor).
|
|
53
|
+
- Added docs/REFERENCE.md — complete API reference (Ruby API, endpoints, Turbo
|
|
54
|
+
Stream protocol, client contract, config, tenancy).
|
|
55
|
+
- Server-side row model (RAILS.md §21) for large tables (50-100K+ rows): only
|
|
56
|
+
one page is loaded client-side; rows#index returns a window (page/page_size)
|
|
57
|
+
+ the full total; Grid#apply_sort sorts server-side; grid-sync fetches windows
|
|
58
|
+
on page/sort/filter/search; base grid gains serverSide/rowCount + setRowCount.
|
|
59
|
+
Render with `server_side: true, total:` partial locals.
|
|
60
|
+
- Bulk paste (RAILS.md §9): paste tab/newline-separated data from an anchor cell;
|
|
61
|
+
grid-sync fills the range and POSTs to /bulk.
|
|
62
|
+
- Full Minitest suite for the Rails side (62 examples) under demo/test.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Marcus Schappi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# stimulus_grid_rails
|
|
2
|
+
|
|
3
|
+
Rails + Hotwire bindings for [`stimulus_grid`](../../). It turns the HTML-first
|
|
4
|
+
stimulus_grid into a **server-driven, multi-user editable data grid** using
|
|
5
|
+
Turbo Streams over Action Cable — no React, no client-side grid framework, no JS
|
|
6
|
+
build step (importmap-pinnable).
|
|
7
|
+
|
|
8
|
+
This is the Rails-native realisation of the scope in [`RAILS.md`](../../RAILS.md):
|
|
9
|
+
the **server column definition does 80% of what a generic client-side grid
|
|
10
|
+
pushes onto the client** (auth, coercion, validation, editor selection, cascade,
|
|
11
|
+
broadcast), because a Rails app *knows its schema*.
|
|
12
|
+
|
|
13
|
+
> Status: **v0.1 / MVP slice.** Implemented: single-cell commit with optimistic
|
|
14
|
+
> update + server reconcile, the `ApplicationGrid` column registry, standard
|
|
15
|
+
> editors, the cell-grained Turbo Stream actions, computed-column cascade, and
|
|
16
|
+
> version-checked concurrency. See [Roadmap](#roadmap) for what's deferred.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What you get
|
|
21
|
+
|
|
22
|
+
| RAILS.md § | Feature | Status |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| §1 | Custom Turbo Stream actions (`cell`, `cell-confirm`, `cell-revert`, `cell-conflict`, `row-insert-sorted`, `row-remove`, `aggregate`, `bulk`, `presence`), **broadcast automatically** on every create/update/destroy | ✅ |
|
|
25
|
+
| §2 | Tenant-scoped streams — broadcasts isolated per `ActsAsTenant.current_tenant`; scoped row lookups (no bare `Model.find`) | ✅ |
|
|
26
|
+
| §4 | Optimistic updates: cell marked `data-pending`, `X-Optimistic-Id` header, server `cell-confirm`/`cell-revert`, originator suppresses its own broadcast echo | ✅ |
|
|
27
|
+
| §7 | Server-side column registry: per-column `type`, `editable` (bool or lambda), `editor`, `editor_config`, `validate`, `concurrency`, `computed`/`depends_on` | ✅ |
|
|
28
|
+
| §8 | One cell-mutation endpoint `PATCH /grids/:resource/:row_id/cells/:column` | ✅ |
|
|
29
|
+
| §9 | Single-cell commit edit mode + Tab/Shift+Tab cell navigation + Enter/Esc | ✅ |
|
|
30
|
+
| §10 | Standard editors: string/text/integer/decimal/money/boolean/enum/date/datetime | ✅ (via base grid) |
|
|
31
|
+
| §11 | Server-side validation → `cell-revert` with `errors` payload | ✅ |
|
|
32
|
+
| §12 | Computed columns + cascade replayed server-side as a `bulk` stream | ✅ |
|
|
33
|
+
| §13 | Version-checked concurrency (`lock_version` → `cell-conflict`) | ✅ |
|
|
34
|
+
| §14 | Create rows: `POST /grids/:resource/rows` → `row-insert-sorted` broadcast | ✅ |
|
|
35
|
+
| §15 | Delete rows: per-row + multi-select bulk → `row-remove` broadcast | ✅ |
|
|
36
|
+
| §16 | Undo / redo: server-side audit log; `Cmd/Ctrl+Z` undo, `Cmd/Ctrl+Shift+Z` (or `Ctrl+Y`) redo; replayed as normal mutations | ✅ |
|
|
37
|
+
| §17 | `editable:` lambda re-checked on every PATCH; auth/tenant via inherited `parent_controller` | ✅ |
|
|
38
|
+
| §9 (bulk paste) | Paste tab/newline-separated data from an anchor cell → `/bulk` | ✅ |
|
|
39
|
+
| §21 | Server-side global search + per-column filtering, **and a server-side row model** (windowed fetch) for 50-100K+ rows | ✅ |
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why Action Cable + version-checking, and not Yjs?
|
|
44
|
+
|
|
45
|
+
You asked whether collaboration should use [Yjs / `Y.Doc`](https://docs.yjs.dev/api/y.doc).
|
|
46
|
+
Short answer: **not for cell-grained edits — Action Cable is the right primitive
|
|
47
|
+
here, and Yjs is reserved as an opt-in for long-form text cells.** Reasoning:
|
|
48
|
+
|
|
49
|
+
- **CRDTs solve a problem this grid mostly doesn't have.** Yjs shines when many
|
|
50
|
+
users concurrently edit the *same unstructured value* (a paragraph, a drawing)
|
|
51
|
+
and you need automatic, intention-preserving merge without a server referee.
|
|
52
|
+
A grid cell holding a number, enum, date, or short string has a **natural
|
|
53
|
+
authority — the server** — and a dead-simple conflict story: last-write-wins,
|
|
54
|
+
or `lock_version` for the few columns that need it (RAILS.md §13). You don't
|
|
55
|
+
need an RGA/YATA sequence CRDT to set `age = 31`.
|
|
56
|
+
- **The server already owns correctness.** Permissions (`editable:` lambdas),
|
|
57
|
+
type coercion, validation, computed-column cascade, and audit all live in the
|
|
58
|
+
column registry and run on every PATCH. A pure-Yjs model pushes deltas
|
|
59
|
+
peer-to-peer (or through a dumb relay) and **bypasses** that authority — you'd
|
|
60
|
+
have to rebuild auth/validation/cascade on top of document observers anyway.
|
|
61
|
+
- **It's lighter.** No `y-websocket` server, no document GC tuning, no awareness
|
|
62
|
+
protocol, no client-side CRDT bundle. The transport is the Action Cable you
|
|
63
|
+
already run; the wire format is a tiny `<turbo-stream>` tag.
|
|
64
|
+
|
|
65
|
+
**Where Yjs *is* the right tool** is a single cell that holds **collaborative
|
|
66
|
+
long-form text** (a Notion-style `notes`/`description` field where two people
|
|
67
|
+
type in the same paragraph at once). That's a genuine sequence-merge problem.
|
|
68
|
+
The intended path (not yet built — see roadmap) is to make it **per-column,
|
|
69
|
+
opt-in**:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
column :notes, type: :text, editable: true,
|
|
73
|
+
collaborative: :yjs # mounts a Y.Text editor + y-websocket for THIS cell only
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
So the architecture is: **Action Cable + version-checking for the structured
|
|
77
|
+
grid (the 95% case), Yjs surgically inside text cells that need true co-editing.**
|
|
78
|
+
You don't pay CRDT complexity for the whole grid to get collaboration on a few
|
|
79
|
+
fields.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Gemfile
|
|
87
|
+
gem "stimulus_grid_rails" # from the repo: gem "stimulus_grid_rails", path: "gem/stimulus_grid_rails"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
bundle install
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The engine auto-registers two importmap pins (`stimulus_grid`,
|
|
95
|
+
`stimulus_grid_rails`) and ships the CSS, so no `bin/importmap pin` is needed.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
// app/javascript/application.js
|
|
99
|
+
import "@hotwired/turbo-rails"
|
|
100
|
+
import { Application } from "@hotwired/stimulus"
|
|
101
|
+
import StimulusGrid from "stimulus_grid"
|
|
102
|
+
import StimulusGridRails from "stimulus_grid_rails"
|
|
103
|
+
|
|
104
|
+
const application = Application.start()
|
|
105
|
+
StimulusGrid.start(application) // grid, header-cell, pagination, …
|
|
106
|
+
StimulusGridRails.start(application) // grid-sync, cell-editor + Turbo Stream actions
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```erb
|
|
110
|
+
<%# app/views/layouts/application.html.erb (head) %>
|
|
111
|
+
<%= stylesheet_link_tag "stimulus_grid", "stimulus_grid_rails" %>
|
|
112
|
+
<%= javascript_importmap_tags %>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# config/routes.rb
|
|
117
|
+
mount ActionCable.server => "/cable"
|
|
118
|
+
mount StimulusGridRails::Engine => StimulusGridRails.mount_path # default "/grids"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Choosing the path (e.g. namespacing under `/admin`)
|
|
122
|
+
|
|
123
|
+
The endpoints live wherever you mount the engine. Set `mount_path` and mount at
|
|
124
|
+
the same value — the grid builds its browser requests from `mount_path`, so they
|
|
125
|
+
follow automatically:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# config/initializers/stimulus_grid_rails.rb
|
|
129
|
+
StimulusGridRails.mount_path = "/admin/grids"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Usage
|
|
133
|
+
|
|
134
|
+
**1. Declare the grid** (one source of truth — RAILS.md §7):
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# app/grids/athlete_grid.rb
|
|
138
|
+
class AthleteGrid < StimulusGridRails::Grid
|
|
139
|
+
resource :athletes
|
|
140
|
+
model Athlete
|
|
141
|
+
stream_name { |_user| "athletes" }
|
|
142
|
+
|
|
143
|
+
column :athlete, type: :string, editable: true, pinned: :left, width: 220
|
|
144
|
+
column :country, type: :string, editable: ->(row, user) { user&.admin? } # per-row/user
|
|
145
|
+
column :sport, type: :enum, editable: true, enum_values: %w[Swimming Cycling Gymnastics]
|
|
146
|
+
column :age, type: :integer, editable: true, concurrency: :version_checked,
|
|
147
|
+
validate: ->(v, _r) { "must be 10–80" unless (10..80).cover?(v.to_i) }
|
|
148
|
+
column :gold, type: :integer, editable: true
|
|
149
|
+
column :silver, type: :integer, editable: true
|
|
150
|
+
column :bronze, type: :integer, editable: true
|
|
151
|
+
column :total, type: :integer, computed: true, depends_on: %i[gold silver bronze]
|
|
152
|
+
|
|
153
|
+
def compute_total(row) = row.gold.to_i + row.silver.to_i + row.bronze.to_i
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**2. Make the model broadcastable:**
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class Athlete < ApplicationRecord
|
|
161
|
+
include StimulusGridRails::Broadcastable
|
|
162
|
+
broadcasts_grid AthleteGrid, stream: ->(_a) { "athletes" }
|
|
163
|
+
self.locking_column = :lock_version # needed for version-checked columns
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**3. Render it:**
|
|
168
|
+
|
|
169
|
+
```erb
|
|
170
|
+
<%= render partial: "stimulus_grid_rails/grids/grid",
|
|
171
|
+
locals: { grid: AthleteGrid.new(user: current_user),
|
|
172
|
+
rows: Athlete.order(:id),
|
|
173
|
+
row_selection: "multiple", page_size: 25 } %>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
That's it. Double-click a cell → edit → Enter commits → optimistic pending
|
|
177
|
+
(blue pulse) → server reconciles (green flash) or reverts (red + tooltip) →
|
|
178
|
+
every other connected tab updates live.
|
|
179
|
+
|
|
180
|
+
## Try the demo
|
|
181
|
+
|
|
182
|
+
A complete, runnable Rails app lives in [`../demo`](../demo):
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
cd gem/demo
|
|
186
|
+
bundle install
|
|
187
|
+
bin/rails db:create db:migrate db:seed
|
|
188
|
+
bin/rails server
|
|
189
|
+
# open http://localhost:3000 in two windows side by side
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Edit a cell in one window and watch the other update. Edit gold/silver/bronze
|
|
193
|
+
and watch **total** cascade. Set age to `999` and watch the server reject it.
|
|
194
|
+
|
|
195
|
+
## Architecture
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
┌─ browser tab A ─────────────┐ ┌─ browser tab B ─────────────┐
|
|
199
|
+
│ grid + grid-sync controllers│ │ grid + grid-sync controllers│
|
|
200
|
+
│ dblclick→edit→Enter │ │ │
|
|
201
|
+
│ optimistic: cell pending │ │ │
|
|
202
|
+
└──────────┬──────────────────┘ └─────────────▲───────────────┘
|
|
203
|
+
│ PATCH /grids/athletes/1/cells/age │ turbo-stream "cell"
|
|
204
|
+
│ {value, optimistic_id, lock_version} │ (Action Cable)
|
|
205
|
+
▼ │
|
|
206
|
+
┌─ CellsController#update (≈30 lines) ─────────────────┴──────────────┐
|
|
207
|
+
│ grid = AthleteGrid.new(user:) │
|
|
208
|
+
│ column.editable_for?(row, user) ← re-checked server-side (§17) │
|
|
209
|
+
│ value, err = column.coerce(raw) │
|
|
210
|
+
│ ok, errors, mutations = grid.apply_cell!(row, column, value) │
|
|
211
|
+
│ └─ validate → save → cascade compute_total → [confirm + cascade] │
|
|
212
|
+
│ response : turbo-stream cell-confirm (+ bulk cascade) to originator │
|
|
213
|
+
│ broadcast : turbo-stream cell (w/ optimistic_id) to "athletes" │
|
|
214
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The originating client carries its own optimistic-id set and **suppresses the
|
|
218
|
+
broadcast echo of its own edit**, so it doesn't double-apply.
|
|
219
|
+
|
|
220
|
+
### Adding & removing rows (§14/§15)
|
|
221
|
+
|
|
222
|
+
Create and delete flow through the gem and broadcast live to every tab. Wire a
|
|
223
|
+
toolbar by dispatching events on the grid element:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
gridEl.dispatchEvent(new CustomEvent("grid-sync:add-row")) // optional { detail: { attributes } }
|
|
227
|
+
gridEl.dispatchEvent(new CustomEvent("grid-sync:delete-selected")) // deletes gridApi.getSelectedRowIds()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Per-row delete buttons work via a cell renderer + delegated click — declare an
|
|
231
|
+
action column and provide the template:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
column :_actions, type: :string, editable: false, sortable: false,
|
|
235
|
+
filterable: false, pinned: :right, cell_renderer: "sgr-row-actions"
|
|
236
|
+
def new_row_defaults = { athlete: "New athlete", sport: "Swimming", age: 20, gold: 0, silver: 0, bronze: 0 }
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```erb
|
|
240
|
+
<template id="sgr-row-actions">
|
|
241
|
+
<button data-sgr-action="delete-row" title="Delete row">×</button>
|
|
242
|
+
</template>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Create/delete broadcast automatically (see below) — the gem's controllers just
|
|
246
|
+
persist; the model's commit callbacks broadcast.
|
|
247
|
+
|
|
248
|
+
## Automatic broadcasts
|
|
249
|
+
|
|
250
|
+
`include StimulusGridRails::Broadcastable; broadcasts_grid YourGrid` is all the
|
|
251
|
+
wiring. From then on **every** create / update / destroy broadcasts the right
|
|
252
|
+
Turbo Stream action — no manual `broadcast_*` calls, even for changes made from
|
|
253
|
+
the console, a job, or another controller:
|
|
254
|
+
|
|
255
|
+
| Model event | Broadcast |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `create` | `row-insert-sorted` (full row as JSON) |
|
|
258
|
+
| `update` | `cell` per changed registered column **+ computed cascade** |
|
|
259
|
+
| `destroy` | `row-remove` |
|
|
260
|
+
|
|
261
|
+
Grid edits made through the cells endpoint stash the originator's
|
|
262
|
+
`optimistic_id` on the record before save, so the auto-broadcast still carries
|
|
263
|
+
it and the originating tab suppresses its own echo (RAILS.md §4).
|
|
264
|
+
|
|
265
|
+
## Multi-tenancy & auth (Devise + ActsAsTenant)
|
|
266
|
+
|
|
267
|
+
Two things keep tenants isolated:
|
|
268
|
+
|
|
269
|
+
1. **Inherited controller.** Gem controllers inherit
|
|
270
|
+
`StimulusGridRails.parent_controller` (default `"ApplicationController"`), so
|
|
271
|
+
your `authenticate_user!` and `set_current_tenant_through_filter`
|
|
272
|
+
before_actions run for the grid's cell/row endpoints too:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
# config/initializers/stimulus_grid_rails.rb
|
|
276
|
+
StimulusGridRails.parent_controller = "ApplicationController"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
2. **Scoped lookups + scoped streams.** Every row is fetched through
|
|
280
|
+
`grid.scope(current_user).find(...)` — never a bare `Model.find` — so a row
|
|
281
|
+
outside the tenant raises `RecordNotFound` instead of leaking. Override
|
|
282
|
+
`scope` for custom authorization:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
class InvoiceGrid < StimulusGridRails::Grid
|
|
286
|
+
def scope(user) = model_class.where(account: user.account)
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Stream names are tenant-scoped automatically via `ActsAsTenant.current_tenant`
|
|
291
|
+
(`StimulusGridRails.streamables_for`), so a broadcast for one tenant never
|
|
292
|
+
reaches another's subscribers — even when grids share a logical stream.
|
|
293
|
+
|
|
294
|
+
## Undo / redo (RAILS.md §16)
|
|
295
|
+
|
|
296
|
+
Install the audit table, then `Cmd/Ctrl+Z` undoes and `Cmd/Ctrl+Shift+Z` (or
|
|
297
|
+
`Ctrl+Y`) redoes the current user's last cell mutation. Undo/redo replay the
|
|
298
|
+
prior/new value through `apply_cell!`, so validations re-run, computed columns
|
|
299
|
+
cascade, and the change broadcasts to every tab. Shortcuts are ignored while a
|
|
300
|
+
cell editor or text field is focused (native text undo still works there).
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
bin/rails stimulus_grid_rails:install:migrations # copies the audit migration
|
|
304
|
+
bin/rails db:migrate
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Until the table exists, auditing and undo/redo are a quiet no-op.
|
|
308
|
+
|
|
309
|
+
## Large tables — server-side row model (RAILS.md §21)
|
|
310
|
+
|
|
311
|
+
For 50-100K+ rows, render only the first page and let the grid fetch windows:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
def index
|
|
315
|
+
@grid = ThingGrid.new(user: current_user)
|
|
316
|
+
@total = @grid.scope(current_user).count
|
|
317
|
+
@rows = @grid.scope(current_user).order(:id).limit(50) # just page 1
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
```erb
|
|
322
|
+
<%= render partial: "stimulus_grid_rails/grids/grid",
|
|
323
|
+
locals: { grid: @grid, rows: @rows, total: @total,
|
|
324
|
+
server_side: true, page_size: 50 } %>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Only one page is ever in the DOM. Paging, **server-side sorting**, and
|
|
328
|
+
search/filter all fetch a window from `GET …/rows?page=&page_size=&sort=&q=&filters=`;
|
|
329
|
+
the grid swaps it with `setRowData` and tracks the total via `setRowCount`. Edits
|
|
330
|
+
still broadcast live. See `gem/demo` (the `/big_rows` page seeds 50k rows).
|
|
331
|
+
|
|
332
|
+
## Cells: selection, copy, paste
|
|
333
|
+
|
|
334
|
+
- **Numbers/Sheets-style selection** (no browser text highlight): click = active
|
|
335
|
+
cell; shift+click/drag = cell range; **Cmd/Ctrl+click = a non-contiguous cell
|
|
336
|
+
range**. Keyboard: arrows move, Shift+arrows extend, **Cmd/Ctrl+A selects all
|
|
337
|
+
rows**, Enter/type-to-edit, Delete clears, Esc clears, Cmd/Ctrl+C copies.
|
|
338
|
+
Colors are distinct — cell range blue, active cell outlined, row selection green.
|
|
339
|
+
- **Row selection gutter** (optional): pass `row_gutter: :numbers` (1-based row
|
|
340
|
+
numbers) or `row_gutter: :checkbox` (per-row checkbox + select-all header) to
|
|
341
|
+
the grid partial. Click = select row, Shift+click = range, Cmd/Ctrl+click = add.
|
|
342
|
+
(`data-grid-cell-selection-value="false"` restores legacy plain-click row select.)
|
|
343
|
+
- **Copy** the range with `Cmd/Ctrl+C` (TSV).
|
|
344
|
+
- **Bulk paste** (§9): click an editable anchor cell, then paste tab/newline data
|
|
345
|
+
(e.g. from a spreadsheet). The grid fills the range and POSTs one request to
|
|
346
|
+
`/bulk`; the server validates/coerces/saves each cell and returns confirms.
|
|
347
|
+
|
|
348
|
+
## Roadmap
|
|
349
|
+
|
|
350
|
+
Deferred (PRs welcome):
|
|
351
|
+
|
|
352
|
+
- **Field-locking & presence** (§13 field-locked, §1 `presence`): the `presence`
|
|
353
|
+
Turbo Stream action is wired client-side; the lock lifecycle is not.
|
|
354
|
+
- **Yjs text cells**: `collaborative: :yjs` per-column (see above).
|
|
355
|
+
- **Server-side infinite scroll** (current server-side mode is page-based).
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
task default: :build
|
|
4
|
+
|
|
5
|
+
desc "Build the gem"
|
|
6
|
+
task :build do
|
|
7
|
+
sh "gem build stimulus_grid_rails.gemspec"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Sync the JS bundle from the parent stimulus_grid dist/ into the gem assets"
|
|
11
|
+
task :sync_js do
|
|
12
|
+
root = File.expand_path("../..", __dir__)
|
|
13
|
+
cp "#{root}/dist/stimulus_grid.esm.js", "app/assets/javascripts/stimulus_grid.js"
|
|
14
|
+
cp "#{root}/dist/stimulus_grid.css", "app/assets/stylesheets/stimulus_grid.css"
|
|
15
|
+
puts "Synced stimulus_grid.js + stimulus_grid.css from dist/"
|
|
16
|
+
end
|