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 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