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.
@@ -0,0 +1,630 @@
1
+ /* stimulus_grid_rails — Rails + Hotwire bindings for stimulus_grid.
2
+ *
3
+ * Loaded via importmap. Exports `start()` which:
4
+ * - registers Stimulus controllers: `grid-sync`, `cell-editor`
5
+ * - registers Turbo Stream actions: cell, cell-attr, cell-confirm,
6
+ * cell-revert, cell-conflict, row-insert-sorted, row-remove, bulk,
7
+ * aggregate, presence
8
+ *
9
+ * Usage in app/javascript/application.js:
10
+ *
11
+ * import "@hotwired/turbo-rails"
12
+ * import { Application } from "@hotwired/stimulus"
13
+ * import StimulusGrid from "stimulus_grid"
14
+ * import StimulusGridRails from "stimulus_grid_rails"
15
+ *
16
+ * const app = Application.start()
17
+ * StimulusGrid.start(app)
18
+ * StimulusGridRails.start(app)
19
+ */
20
+
21
+ import { Controller } from "@hotwired/stimulus"
22
+
23
+ // ---------- helpers ----------
24
+
25
+ function gridById(name) {
26
+ const el = document.querySelector(`[data-grid-name="${name}"]`)
27
+ if (!el) console.warn(`[stimulus_grid_rails] no grid element found for grid="${name}"`)
28
+ return el
29
+ }
30
+
31
+ function findCell(gridEl, rowId, column) {
32
+ return gridEl?.querySelector(
33
+ `tr[data-row-id="${CSS.escape(String(rowId))}"] td[data-col-id="${CSS.escape(String(column))}"]`
34
+ )
35
+ }
36
+
37
+ function csrfToken() {
38
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
39
+ }
40
+
41
+ function debounce(fn, ms) {
42
+ let t = null
43
+ return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
44
+ }
45
+
46
+ // ---------- Stimulus: GridSyncController ----------
47
+ // Mount on the same element as data-controller="grid". Listens for
48
+ // `grid:cellValueChanged` from the base grid controller and converts edits
49
+ // into a PATCH /grids/:resource/:row_id/cells/:column with an optimistic id.
50
+ // Marks the cell `data-pending` until the server confirms.
51
+
52
+ class GridSyncController extends Controller {
53
+ static values = {
54
+ resource: String,
55
+ cellsPathTemplate: String, // "/grids/:resource/:row_id/cells/:column"
56
+ rowsPath: String, // "/grids/:resource/rows"
57
+ rowPathTemplate: String, // "/grids/:resource/rows/:row_id"
58
+ bulkRowsPath: String, // "/grids/:resource/rows/bulk"
59
+ cellsBulkPath: String, // "/grids/:resource/bulk"
60
+ undoPath: String, // "/grids/:resource/undo"
61
+ redoPath: String, // "/grids/:resource/redo"
62
+ serverSide: Boolean, // server-side row model (windowed fetch)
63
+ optimisticIdPrefix: { type: String, default: "" },
64
+ }
65
+
66
+ connect() {
67
+ this._gridEl = this.element
68
+ this._gridEl.dataset.gridName = this.resourceValue
69
+ this._onCellChange = this._onCellChange.bind(this)
70
+ this._gridEl.addEventListener("grid:cellValueChanged", this._onCellChange)
71
+
72
+ // Row add/delete are driven by events dispatched on the grid element, so a
73
+ // toolbar living anywhere in the page can trigger them without being inside
74
+ // this controller's Stimulus scope:
75
+ // gridEl.dispatchEvent(new CustomEvent("grid-sync:add-row", { detail: { attributes } }))
76
+ // gridEl.dispatchEvent(new CustomEvent("grid-sync:delete-selected"))
77
+ this._onAddRow = (e) => this.addRow(e?.detail?.attributes || {})
78
+ this._onDeleteSelected = () => this.deleteSelected()
79
+ this._gridEl.addEventListener("grid-sync:add-row", this._onAddRow)
80
+ this._gridEl.addEventListener("grid-sync:delete-selected", this._onDeleteSelected)
81
+
82
+ // Per-row delete buttons rendered by a cell renderer live inside the grid,
83
+ // so a single delegated listener handles them.
84
+ this._onDelegatedClick = (e) => {
85
+ const btn = e.target.closest('[data-sgr-action="delete-row"]')
86
+ if (!btn) return
87
+ e.preventDefault()
88
+ const tr = btn.closest("tr[data-row-id]")
89
+ if (tr) this.removeRow(tr.dataset.rowId)
90
+ }
91
+ this._gridEl.addEventListener("click", this._onDelegatedClick)
92
+
93
+ // Bulk paste (RAILS.md §9). Track the last-clicked cell as the paste anchor;
94
+ // a multi-cell clipboard paste fills from there and POSTs to /bulk.
95
+ this._onCellClicked = (e) => { this._anchor = { rowId: e.detail.rowId, colId: e.detail.colId } }
96
+ this._gridEl.addEventListener("grid:cellClicked", this._onCellClicked)
97
+ this._onPaste = (e) => this._handlePaste(e)
98
+ document.addEventListener("paste", this._onPaste)
99
+
100
+ // Server-side search / filtering. The grid fetches matching rows from the
101
+ // index endpoint and swaps the dataset via setRowData. Driven by events:
102
+ // gridEl.dispatchEvent(new CustomEvent("grid-sync:search", { detail: { q } }))
103
+ // gridEl.dispatchEvent(new CustomEvent("grid-sync:filter", { detail: { column, criteria } })) // criteria=null clears
104
+ // gridEl.dispatchEvent(new CustomEvent("grid-sync:clear-filters"))
105
+ this._query = { q: "", filters: {}, sort: [] }
106
+ // In server-side mode, a query change resets to page 0 (debounced); the
107
+ // resulting paginationChanged is the single trigger that fetches a window.
108
+ // In client mode, a query change refetches the (capped) full set directly.
109
+ this._afterQueryChange = this.serverSideValue
110
+ ? debounce(() => this._gridEl.gridApi?.paginationGoToFirstPage(), 200)
111
+ : debounce(() => this._fetchRows(), 200)
112
+ this._onSearch = (e) => { this._query.q = e?.detail?.q ?? ""; this._afterQueryChange() }
113
+ this._onFilter = (e) => {
114
+ const { column, criteria } = e?.detail || {}
115
+ if (!column) return
116
+ if (criteria == null || criteria === "") delete this._query.filters[column]
117
+ else this._query.filters[column] = criteria
118
+ this._afterQueryChange()
119
+ }
120
+ this._onClearFilters = () => {
121
+ this._query = { q: "", filters: {}, sort: this._query.sort }
122
+ this.serverSideValue ? this._gridEl.gridApi?.paginationGoToFirstPage() : this._fetchRows()
123
+ }
124
+ this._gridEl.addEventListener("grid-sync:search", this._onSearch)
125
+ this._gridEl.addEventListener("grid-sync:filter", this._onFilter)
126
+ this._gridEl.addEventListener("grid-sync:clear-filters", this._onClearFilters)
127
+
128
+ // Server-side row model: paginationChanged (page click / size change / a
129
+ // reset from above) fetches that window; sortChanged sorts on the server.
130
+ if (this.serverSideValue) {
131
+ this._onSrvPage = () => this._fetchRows()
132
+ this._onSrvSort = (e) => {
133
+ this._query.sort = e?.detail?.sortModel || []
134
+ this._gridEl.gridApi?.paginationGoToFirstPage() // → paginationChanged → fetch
135
+ }
136
+ this._gridEl.addEventListener("grid:paginationChanged", this._onSrvPage)
137
+ this._gridEl.addEventListener("grid:sortChanged", this._onSrvSort)
138
+ }
139
+
140
+ // Undo / redo keyboard shortcuts (RAILS.md §16). Cmd/Ctrl+Z undoes,
141
+ // Cmd/Ctrl+Shift+Z (or Cmd/Ctrl+Y) redoes. Skipped while a cell editor or
142
+ // any text field is focused, so native text undo still works there.
143
+ this._onKeydown = (e) => {
144
+ const mod = e.metaKey || e.ctrlKey
145
+ const key = e.key.toLowerCase()
146
+ if (!mod || (key !== "z" && key !== "y")) return
147
+ const ae = document.activeElement
148
+ if (ae && /^(input|textarea|select)$/i.test(ae.tagName)) return
149
+ if (this._gridEl.querySelector('td[data-editing="true"]')) return
150
+ const isRedo = key === "y" || (key === "z" && e.shiftKey)
151
+ e.preventDefault()
152
+ isRedo ? this.redo() : this.undo()
153
+ }
154
+ document.addEventListener("keydown", this._onKeydown)
155
+
156
+ this._opCounter = 0
157
+ }
158
+
159
+ disconnect() {
160
+ this._gridEl.removeEventListener("grid:cellValueChanged", this._onCellChange)
161
+ this._gridEl.removeEventListener("grid-sync:add-row", this._onAddRow)
162
+ this._gridEl.removeEventListener("grid-sync:delete-selected", this._onDeleteSelected)
163
+ this._gridEl.removeEventListener("click", this._onDelegatedClick)
164
+ this._gridEl.removeEventListener("grid-sync:search", this._onSearch)
165
+ this._gridEl.removeEventListener("grid-sync:filter", this._onFilter)
166
+ this._gridEl.removeEventListener("grid-sync:clear-filters", this._onClearFilters)
167
+ if (this._onSrvPage) this._gridEl.removeEventListener("grid:paginationChanged", this._onSrvPage)
168
+ if (this._onSrvSort) this._gridEl.removeEventListener("grid:sortChanged", this._onSrvSort)
169
+ this._gridEl.removeEventListener("grid:cellClicked", this._onCellClicked)
170
+ document.removeEventListener("keydown", this._onKeydown)
171
+ document.removeEventListener("paste", this._onPaste)
172
+ }
173
+
174
+ // Bulk paste: parse TSV from the clipboard and fill cells from the anchor
175
+ // (last-clicked cell) rightward + downward across editable columns and the
176
+ // loaded rows, then POST one /bulk request. The server validates + coerces +
177
+ // saves each mutation and returns cell-confirms (which fill the cells).
178
+ _handlePaste(e) {
179
+ if (!this._anchor || !this.hasCellsBulkPathValue) return
180
+ if (this._gridEl.querySelector('td[data-editing="true"]')) return // editing → native paste
181
+ const ae = document.activeElement
182
+ if (ae && /^(input|textarea|select)$/i.test(ae.tagName) && !this._gridEl.contains(ae)) return
183
+
184
+ const text = e.clipboardData?.getData("text/plain")
185
+ if (!text) return
186
+ const grid = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
187
+ .split("\n").map((line) => line.split("\t"))
188
+ if (!grid.length) return
189
+
190
+ const api = this._gridEl.gridApi
191
+ const cols = api.getColumnDefs().filter((c) =>
192
+ c.editable && !c.hidden && !c._isCheckbox && !String(c.field).startsWith("_"))
193
+ const rows = api.getRowData()
194
+ const colStart = cols.findIndex((c) => c.field === this._anchor.colId)
195
+ const rowStart = rows.findIndex((r) => String(r.id) === String(this._anchor.rowId))
196
+ if (colStart < 0 || rowStart < 0) return // anchor must be an editable cell
197
+
198
+ e.preventDefault()
199
+ const mutations = []
200
+ grid.forEach((line, r) => {
201
+ const row = rows[rowStart + r]
202
+ if (!row) return
203
+ line.forEach((value, c) => {
204
+ const col = cols[colStart + c]
205
+ if (col) mutations.push({ row_id: row.id, column: col.field, value })
206
+ })
207
+ })
208
+ if (!mutations.length) return
209
+
210
+ const optimisticId = this._nextOptimisticId()
211
+ ;(this._gridEl.__sgrOwnOps ||= new Set()).add(optimisticId) // suppress broadcast echo
212
+ fetch(this.cellsBulkPathValue, {
213
+ method: "POST", credentials: "same-origin", headers: this._headers(),
214
+ body: JSON.stringify({ mutations, optimistic_id: optimisticId }),
215
+ })
216
+ .then((res) => res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`)))
217
+ .then((html) => { if (html.trim()) window.Turbo?.renderStreamMessage(html) })
218
+ .catch((err) => console.error("[stimulus_grid_rails] bulk paste failed:", err))
219
+ }
220
+
221
+ // POST /grids/:resource/undo (or /redo). The server replays the inverse /
222
+ // forward value as a normal mutation, which auto-broadcasts back to this tab.
223
+ async undo() { return this._history(this.hasUndoPathValue && this.undoPathValue) }
224
+ async redo() { return this._history(this.hasRedoPathValue && this.redoPathValue) }
225
+
226
+ async _history(path) {
227
+ if (!path) return
228
+ try {
229
+ const res = await fetch(path, {
230
+ method: "POST", credentials: "same-origin", headers: this._headers(),
231
+ })
232
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
233
+ const html = await res.text()
234
+ if (html.trim()) window.Turbo?.renderStreamMessage(html)
235
+ } catch (err) {
236
+ console.error("[stimulus_grid_rails] history failed:", err)
237
+ }
238
+ }
239
+
240
+ // GET /grids/:resource/rows?q=&filters= — server applies the global search +
241
+ // per-column filters via the column registry, returns matching rows, and we
242
+ // swap the grid's dataset. This is the scalable path for large tables.
243
+ async fetchRows() { return this._fetchRows() }
244
+
245
+ async _fetchRows() {
246
+ if (!this.hasRowsPathValue) return
247
+ const api = this._gridEl.gridApi
248
+ if (!api) return
249
+ const url = new URL(this.rowsPathValue, window.location.origin)
250
+ if (this._query.q) url.searchParams.set("q", this._query.q)
251
+ if (Object.keys(this._query.filters).length) {
252
+ url.searchParams.set("filters", JSON.stringify(this._query.filters))
253
+ }
254
+ if (this.serverSideValue) {
255
+ url.searchParams.set("page", api.paginationGetCurrentPage())
256
+ url.searchParams.set("page_size", api.paginationGetPageSize())
257
+ if (this._query.sort.length) url.searchParams.set("sort", JSON.stringify(this._query.sort))
258
+ }
259
+ try {
260
+ const res = await fetch(url, {
261
+ method: "GET", credentials: "same-origin",
262
+ headers: { "Accept": "application/json", "X-CSRF-Token": csrfToken() },
263
+ })
264
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
265
+ const data = await res.json()
266
+ // setRowCount first so the pagination refresh (fired by setRowData's
267
+ // rowDataChanged) reads the new total.
268
+ if (this.serverSideValue) api.setRowCount(data.total)
269
+ api.setRowData(data.rows)
270
+ this._gridEl.dispatchEvent(new CustomEvent("grid-sync:rows-fetched", {
271
+ detail: { total: data.total, shown: data.rows.length, limited: data.limited },
272
+ bubbles: true,
273
+ }))
274
+ } catch (err) {
275
+ console.error("[stimulus_grid_rails] fetchRows failed:", err)
276
+ }
277
+ }
278
+
279
+ _headers(extra = {}) {
280
+ return {
281
+ "Content-Type": "application/json",
282
+ "Accept": "text/vnd.turbo-stream.html",
283
+ "X-CSRF-Token": csrfToken(),
284
+ ...extra,
285
+ }
286
+ }
287
+
288
+ // POST /grids/:resource/rows — create a row with the grid's server defaults
289
+ // (optionally merged with `attributes`). Server returns + broadcasts a
290
+ // row-insert-sorted, which adds the persisted row (real id) to the grid.
291
+ async addRow(attributes = {}) {
292
+ if (!this.hasRowsPathValue) return
293
+ try {
294
+ const res = await fetch(this.rowsPathValue, {
295
+ method: "POST", credentials: "same-origin",
296
+ headers: this._headers(),
297
+ body: JSON.stringify({ attributes }),
298
+ })
299
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
300
+ const html = await res.text()
301
+ window.Turbo?.renderStreamMessage(html)
302
+ } catch (err) {
303
+ console.error("[stimulus_grid_rails] addRow failed:", err)
304
+ }
305
+ }
306
+
307
+ // DELETE a single row by id.
308
+ async removeRow(rowId) {
309
+ if (!this.hasRowPathTemplateValue) return
310
+ const path = this.rowPathTemplateValue.replace(":row_id", encodeURIComponent(String(rowId)))
311
+ try {
312
+ const res = await fetch(path, {
313
+ method: "DELETE", credentials: "same-origin", headers: this._headers(),
314
+ })
315
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
316
+ const html = await res.text()
317
+ if (html.trim()) window.Turbo?.renderStreamMessage(html)
318
+ } catch (err) {
319
+ console.error("[stimulus_grid_rails] removeRow failed:", err)
320
+ }
321
+ }
322
+
323
+ // DELETE every selected row in one bulk request.
324
+ async deleteSelected() {
325
+ const api = this._gridEl.gridApi
326
+ if (!api) return
327
+ // Prefer explicit row selection (checkbox / Cmd-click); otherwise fall back
328
+ // to the rows covered by the current cell selection.
329
+ let ids = api.getSelectedRowIds ? api.getSelectedRowIds() : []
330
+ if (!ids.length && api.getCellSelectionRowIds) ids = api.getCellSelectionRowIds()
331
+ if (!ids.length) return
332
+ if (this.hasBulkRowsPathValue) {
333
+ try {
334
+ const res = await fetch(this.bulkRowsPathValue, {
335
+ method: "DELETE", credentials: "same-origin",
336
+ headers: this._headers(),
337
+ body: JSON.stringify({ ids }),
338
+ })
339
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
340
+ const html = await res.text()
341
+ if (html.trim()) window.Turbo?.renderStreamMessage(html)
342
+ } catch (err) {
343
+ console.error("[stimulus_grid_rails] deleteSelected failed:", err)
344
+ }
345
+ } else {
346
+ for (const id of ids) await this.removeRow(id)
347
+ }
348
+ }
349
+
350
+ _nextOptimisticId() {
351
+ this._opCounter += 1
352
+ return `${this.optimisticIdPrefixValue || "op"}-${Date.now()}-${this._opCounter}`
353
+ }
354
+
355
+ async _onCellChange(e) {
356
+ const { rowId, colId, newValue, oldValue } = e.detail
357
+ const td = findCell(this._gridEl, rowId, colId)
358
+ if (!td) return
359
+ const optimisticId = this._nextOptimisticId()
360
+ // Remember our own optimistic ids so the broadcast echo of this very edit
361
+ // can be suppressed on this client (RAILS.md §4). Other clients don't have
362
+ // the id in their set, so they apply the broadcast normally.
363
+ ;(this._gridEl.__sgrOwnOps ||= new Set()).add(optimisticId)
364
+ td.dataset.pending = optimisticId
365
+ td.dataset.priorValue = String(oldValue ?? "")
366
+ td.classList.add("sgr-cell-pending")
367
+
368
+ const path = this.cellsPathTemplateValue
369
+ .replace(":resource", encodeURIComponent(this.resourceValue))
370
+ .replace(":row_id", encodeURIComponent(String(rowId)))
371
+ .replace(":column", encodeURIComponent(String(colId)))
372
+
373
+ try {
374
+ const res = await fetch(path, {
375
+ method: "PATCH",
376
+ credentials: "same-origin",
377
+ headers: {
378
+ "Content-Type": "application/json",
379
+ "Accept": "text/vnd.turbo-stream.html",
380
+ "X-CSRF-Token": csrfToken(),
381
+ "X-Optimistic-Id": optimisticId,
382
+ },
383
+ body: JSON.stringify({
384
+ value: newValue,
385
+ optimistic_id: optimisticId,
386
+ lock_version: td.dataset.lockVersion ? Number(td.dataset.lockVersion) : null,
387
+ }),
388
+ })
389
+ if (!res.ok && res.status !== 422) {
390
+ throw new Error(`HTTP ${res.status}`)
391
+ }
392
+ const html = await res.text()
393
+ // Hand the response off to Turbo so any <turbo-stream> tags in the
394
+ // body run through the registered StreamActions below.
395
+ if (window.Turbo?.renderStreamMessage) {
396
+ window.Turbo.renderStreamMessage(html)
397
+ }
398
+ } catch (err) {
399
+ console.error("[stimulus_grid_rails] PATCH failed:", err)
400
+ // Network failure → roll back locally to prior value.
401
+ this._gridEl.gridApi?.applyTransaction({
402
+ update: [{ id: rowId, [colId]: oldValue }],
403
+ })
404
+ td.classList.remove("sgr-cell-pending")
405
+ td.removeAttribute("data-pending")
406
+ }
407
+ }
408
+ }
409
+
410
+ // ---------- Stimulus: CellEditorController ----------
411
+ // Currently a placeholder — the base grid uses its own editor. This controller
412
+ // is reserved for custom column editors (autocomplete, picker, etc.) that
413
+ // hosts can register via column(editor: "sku-autocomplete").
414
+
415
+ class CellEditorController extends Controller {
416
+ static values = {
417
+ column: String,
418
+ editor: String,
419
+ }
420
+ }
421
+
422
+ // ---------- Turbo StreamActions ----------
423
+
424
+ function registerStreamActions() {
425
+ const Turbo = window.Turbo
426
+ if (!Turbo?.StreamActions) {
427
+ console.warn("[stimulus_grid_rails] Turbo not loaded — Stream actions skipped")
428
+ return
429
+ }
430
+
431
+ // cell: replace cell content + update underlying gridApi row.
432
+ Turbo.StreamActions.cell = function () {
433
+ const gridEl = gridById(this.getAttribute("grid"))
434
+ const rowId = this.getAttribute("row-id")
435
+ const column = this.getAttribute("column")
436
+ const value = this.templateContent.textContent
437
+ const opId = this.getAttribute("optimistic-id")
438
+ // Suppress the originating client's own echo — it already applied this
439
+ // edit optimistically and gets a cell-confirm via the PATCH response.
440
+ if (opId && gridEl?.__sgrOwnOps?.has(opId)) return
441
+ applyCellUpdate(gridEl, rowId, column, value, { confirm: false })
442
+ }
443
+
444
+ // cell-attr: set a single attribute on the cell.
445
+ Turbo.StreamActions["cell-attr"] = function () {
446
+ const gridEl = gridById(this.getAttribute("grid"))
447
+ const td = findCell(gridEl, this.getAttribute("row-id"), this.getAttribute("column"))
448
+ if (!td) return
449
+ td.setAttribute(this.getAttribute("attr"), this.getAttribute("value"))
450
+ }
451
+
452
+ // cell-confirm: own-echo of an optimistic update — clear pending state.
453
+ Turbo.StreamActions["cell-confirm"] = function () {
454
+ const gridEl = gridById(this.getAttribute("grid"))
455
+ const rowId = this.getAttribute("row-id")
456
+ const column = this.getAttribute("column")
457
+ const value = this.templateContent.textContent
458
+ const opId = this.getAttribute("optimistic-id")
459
+ const td = findCell(gridEl, rowId, column)
460
+ if (!td) return
461
+ if (opId && td.dataset.pending && td.dataset.pending !== opId) {
462
+ // A newer optimistic edit superseded this one — leave the cell alone.
463
+ return
464
+ }
465
+ if (opId) gridEl?.__sgrOwnOps?.delete(opId)
466
+ applyCellUpdate(gridEl, rowId, column, value, { confirm: true })
467
+ }
468
+
469
+ // cell-revert: restore prior value + show error.
470
+ Turbo.StreamActions["cell-revert"] = function () {
471
+ const gridEl = gridById(this.getAttribute("grid"))
472
+ const rowId = this.getAttribute("row-id")
473
+ const column = this.getAttribute("column")
474
+ const serverValue = this.templateContent.textContent
475
+ const errors = JSON.parse(this.getAttribute("errors") || "[]")
476
+ applyCellUpdate(gridEl, rowId, column, serverValue, { confirm: true })
477
+ // applyCellUpdate schedules an async re-render that rebuilds the <td>, so
478
+ // style the cell after the render settles — otherwise the class lands on
479
+ // the node about to be replaced and is lost.
480
+ requestAnimationFrame(() => requestAnimationFrame(() => {
481
+ const td = findCell(gridEl, rowId, column)
482
+ if (!td) return
483
+ td.classList.add("sgr-cell-error")
484
+ td.title = errors.join("\n")
485
+ setTimeout(() => {
486
+ td.classList.remove("sgr-cell-error")
487
+ td.removeAttribute("title")
488
+ }, 4000)
489
+ }))
490
+ // Toast-style event for the host app to handle however it likes.
491
+ gridEl?.dispatchEvent(new CustomEvent("grid:cellError", {
492
+ detail: { rowId, column, errors }, bubbles: true,
493
+ }))
494
+ }
495
+
496
+ // cell-conflict: server-vs-client value mismatch.
497
+ Turbo.StreamActions["cell-conflict"] = function () {
498
+ const gridEl = gridById(this.getAttribute("grid"))
499
+ const detail = {
500
+ rowId: this.getAttribute("row-id"),
501
+ column: this.getAttribute("column"),
502
+ serverValue: this.getAttribute("server-value"),
503
+ clientValue: this.getAttribute("client-value"),
504
+ optimisticId: this.getAttribute("optimistic-id"),
505
+ }
506
+ const td = findCell(gridEl, detail.rowId, detail.column)
507
+ td?.classList.add("sgr-cell-conflict")
508
+ gridEl?.dispatchEvent(new CustomEvent("grid:cellConflict", {
509
+ detail, bubbles: true,
510
+ }))
511
+ }
512
+
513
+ // row-insert-sorted: insert respecting client's current sort.
514
+ Turbo.StreamActions["row-insert-sorted"] = function () {
515
+ const gridEl = gridById(this.getAttribute("grid"))
516
+ if (!gridEl?.gridApi) return
517
+ // The <template> payload may be either rendered <tr>...</tr> HTML or a
518
+ // JSON-encoded row object. We support both — JSON is preferred.
519
+ const raw = this.templateContent.textContent.trim()
520
+ let rowObj = null
521
+ try { rowObj = JSON.parse(raw) } catch { /* not JSON */ }
522
+ if (rowObj) {
523
+ gridEl.gridApi.applyTransaction({ add: [rowObj] })
524
+ } else {
525
+ console.warn("[stimulus_grid_rails] row-insert-sorted: HTML payload not yet supported")
526
+ }
527
+ }
528
+
529
+ // row-remove: drop a row by id.
530
+ Turbo.StreamActions["row-remove"] = function () {
531
+ const gridEl = gridById(this.getAttribute("grid"))
532
+ const rowId = this.getAttribute("row-id")
533
+ if (!gridEl?.gridApi) return
534
+ const rows = gridEl.gridApi.getRowData()
535
+ const victim = rows.find(r => String(r.id) === String(rowId))
536
+ if (victim) gridEl.gridApi.applyTransaction({ remove: [victim] })
537
+ }
538
+
539
+ // bulk: atomic batch. The inner <turbo-stream> nodes live inside a
540
+ // <template>, so they are NOT upgraded custom elements and lack
541
+ // `templateContent`. Re-serialize the fragment and feed it back through
542
+ // Turbo, which parses + upgrades + executes each inner stream.
543
+ Turbo.StreamActions.bulk = function () {
544
+ const tmp = document.createElement("div")
545
+ tmp.appendChild(this.templateContent.cloneNode(true))
546
+ window.Turbo.renderStreamMessage(tmp.innerHTML)
547
+ }
548
+
549
+ // aggregate: update a footer cell. Target element matches
550
+ // [data-grid-aggregate="<grid>:<column>:<kind>"].
551
+ Turbo.StreamActions.aggregate = function () {
552
+ const grid = this.getAttribute("grid")
553
+ const column = this.getAttribute("column")
554
+ const kind = this.getAttribute("kind")
555
+ const value = this.templateContent.textContent
556
+ document
557
+ .querySelectorAll(`[data-grid-aggregate="${grid}:${column}:${kind}"]`)
558
+ .forEach(el => { el.textContent = value })
559
+ }
560
+
561
+ // presence: per-user editing indicator badge.
562
+ Turbo.StreamActions.presence = function () {
563
+ const gridEl = gridById(this.getAttribute("grid"))
564
+ const td = findCell(gridEl, this.getAttribute("row-id"), this.getAttribute("column"))
565
+ if (!td) return
566
+ const userId = this.getAttribute("user-id")
567
+ const label = this.getAttribute("user-label")
568
+ const active = this.getAttribute("active") === "true"
569
+ const existing = td.querySelector(`.sgr-presence[data-user-id="${CSS.escape(userId)}"]`)
570
+ if (active && !existing) {
571
+ const badge = document.createElement("span")
572
+ badge.className = "sgr-presence"
573
+ badge.dataset.userId = userId
574
+ badge.title = `${label} is editing`
575
+ badge.textContent = label.slice(0, 2).toUpperCase()
576
+ td.appendChild(badge)
577
+ } else if (!active && existing) {
578
+ existing.remove()
579
+ }
580
+ }
581
+ }
582
+
583
+ // Apply a server-pushed cell value into both the DOM and the underlying
584
+ // gridApi's row data so future renders show the right value.
585
+ function applyCellUpdate(gridEl, rowId, column, value, { confirm }) {
586
+ if (!gridEl?.gridApi) return
587
+ const rows = gridEl.gridApi.getRowData()
588
+ const row = rows.find(r => String(r.id) === String(rowId))
589
+ if (!row) return
590
+ const coerced = coerceFromString(value, typeof row[column])
591
+ if (row[column] !== coerced) {
592
+ // Spread the found row so the transaction carries the row's own id type
593
+ // (number vs string) — applyTransaction matches rows by their id, and a
594
+ // raw attribute string won't match a numerically-keyed row.
595
+ gridEl.gridApi.applyTransaction({ update: [{ ...row, [column]: coerced }] })
596
+ }
597
+ const td = findCell(gridEl, rowId, column)
598
+ if (!td) return
599
+ if (confirm) {
600
+ td.classList.remove("sgr-cell-pending")
601
+ td.removeAttribute("data-pending")
602
+ td.removeAttribute("data-prior-value")
603
+ td.classList.add("sgr-cell-just-confirmed")
604
+ setTimeout(() => td.classList.remove("sgr-cell-just-confirmed"), 500)
605
+ }
606
+ }
607
+
608
+ function coerceFromString(s, existingType) {
609
+ if (existingType === "number") {
610
+ const n = Number(s)
611
+ return Number.isFinite(n) ? n : s
612
+ }
613
+ if (existingType === "boolean") return s === "true"
614
+ return s
615
+ }
616
+
617
+ // ---------- entry ----------
618
+
619
+ function start(application) {
620
+ if (!application) {
621
+ throw new Error("[stimulus_grid_rails] start(application) requires a Stimulus Application")
622
+ }
623
+ application.register("grid-sync", GridSyncController)
624
+ application.register("cell-editor", CellEditorController)
625
+ registerStreamActions()
626
+ return application
627
+ }
628
+
629
+ export { start, GridSyncController, CellEditorController, registerStreamActions }
630
+ export default { start }
@@ -0,0 +1 @@
1
+ .sg-grid{--sg-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--sg-fg: #1f2937;--sg-fg-muted: #6b7280;--sg-bg: #ffffff;--sg-bg-alt: #f9fafb;--sg-bg-header: #f3f4f6;--sg-bg-hover: #eef2ff;--sg-bg-selected: #dbeafe;--sg-bg-selected-hover: #bfdbfe;--sg-bg-row-selected: #c7f0d5;--sg-bg-row-selected-hover: #aee7c4;--sg-border: #e5e7eb;--sg-border-strong: #d1d5db;--sg-accent: #2563eb;--sg-row-height: 32px;--sg-header-height: 36px;--sg-cell-pad-x: 10px;font-family:var(--sg-font);color:var(--sg-fg);background:var(--sg-bg);border:1px solid var(--sg-border);border-radius:6px;font-size:13px;position:relative;overflow:hidden;display:flex;flex-direction:column}.sg-grid table{width:100%;border-collapse:separate;border-spacing:0;table-layout:fixed}.sg-grid thead{background:var(--sg-bg-header);position:sticky;top:0;z-index:2}.sg-grid thead th{height:var(--sg-header-height);padding:0 var(--sg-cell-pad-x);text-align:left;font-weight:600;font-size:12px;color:var(--sg-fg);border-bottom:1px solid var(--sg-border-strong);border-right:1px solid var(--sg-border);position:relative;user-select:none;box-sizing:border-box;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.sg-grid thead th:last-child{border-right:none}.sg-grid thead th[data-sortable=true]{cursor:pointer}.sg-grid thead th[data-sortable=true]:hover{background:var(--sg-bg-hover)}.sg-grid .sg-header-content{display:flex;align-items:center;gap:6px;height:100%;overflow:hidden}.sg-grid .sg-header-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sg-grid .sg-sort-icon{font-size:10px;color:var(--sg-fg-muted);width:12px;display:inline-block;text-align:right;visibility:hidden}.sg-grid thead th[data-sort=asc] .sg-sort-icon:before{content:"▲";visibility:visible}.sg-grid thead th[data-sort=desc] .sg-sort-icon:before{content:"▼";visibility:visible}.sg-grid thead th[data-sort=asc] .sg-sort-icon,.sg-grid thead th[data-sort=desc] .sg-sort-icon{visibility:visible;color:var(--sg-accent)}.sg-grid .sg-sort-index{font-size:9px;color:var(--sg-accent);vertical-align:super}.sg-grid .sg-filter-icon{font-size:12px;color:var(--sg-fg-muted);cursor:pointer;opacity:.45;padding:2px 4px;border-radius:2px}.sg-grid thead th[data-filterable=true]:hover .sg-filter-icon{opacity:1}.sg-grid .sg-filter-icon:hover{background:#0000000f}.sg-grid thead th[data-filter-active=true] .sg-filter-icon{opacity:1;color:var(--sg-accent)}.sg-grid .sg-filter-icon:before{content:"≡"}.sg-grid .sg-resize-handle{position:absolute;top:0;right:-3px;width:6px;height:100%;cursor:col-resize;z-index:3}.sg-grid .sg-resize-handle:hover{background:var(--sg-accent);opacity:.4}.sg-grid .sg-body-viewport{flex:1;overflow:auto;position:relative}.sg-grid .sg-body-canvas{position:relative;width:100%}.sg-grid tbody tr{background:var(--sg-bg)}.sg-grid tbody tr:nth-child(2n){background:var(--sg-bg-alt)}.sg-grid tbody tr:hover{background:var(--sg-bg-hover)}.sg-grid tbody tr[data-selected=true]{background:var(--sg-bg-row-selected)}.sg-grid tbody tr[data-selected=true]:hover{background:var(--sg-bg-row-selected-hover)}.sg-grid tbody td{height:var(--sg-row-height);padding:0 var(--sg-cell-pad-x);border-bottom:1px solid var(--sg-border);border-right:1px solid var(--sg-border);box-sizing:border-box;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;vertical-align:middle;user-select:none;-webkit-user-select:none}.sg-grid tbody td:last-child{border-right:none}.sg-grid tbody td[data-editing=true] input,.sg-grid tbody td[data-editing=true] textarea{user-select:text;-webkit-user-select:text}.sg-grid:focus,.sg-grid:focus-visible{outline:none}.sg-drag-ghost{position:fixed;z-index:10000;pointer-events:none;opacity:.9;background:var(--sg-bg);border:1px solid var(--sg-accent);border-radius:5px;box-shadow:0 10px 28px #00000047;overflow:hidden}.sg-drag-ghost table{border-collapse:collapse;width:100%;table-layout:fixed}.sg-drag-ghost td{height:30px;padding:0 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--sg-fg);border-bottom:1px solid var(--sg-border);position:static!important;background:var(--sg-bg)!important}.sg-drag-ghost-more{padding:4px 10px;font-size:11px;color:var(--sg-fg-muted);background:var(--sg-bg-alt);border-top:1px solid var(--sg-border)}.sg-drop-indicator{position:fixed;height:2px;background:var(--sg-accent);z-index:9999;pointer-events:none}body.sg-row-dragging,body.sg-row-dragging *{cursor:grabbing!important}.sg-grid[data-row-drag-value=true] .sg-gutter-cell{cursor:grab}.sg-grid .sg-gutter-cell,.sg-grid th.sg-gutter-header{background:var(--sg-bg-header);color:var(--sg-fg-muted);text-align:center;font-size:11px;font-variant-numeric:tabular-nums;user-select:none;cursor:pointer}.sg-grid tbody tr[data-selected=true] .sg-gutter-cell{background:var(--sg-bg-row-selected);color:var(--sg-fg);font-weight:600}.sg-grid tbody td[data-cell-range=true]{background:var(--sg-bg-selected)}.sg-grid tbody tr[data-selected=true] td[data-cell-range=true]{background:var(--sg-bg-selected-hover)}.sg-grid tbody td[data-cell-active=true]{outline:2px solid var(--sg-accent);outline-offset:-2px;z-index:3}.sg-grid tbody td[data-editing=true]{padding:0}.sg-grid tbody td[data-editing=true] input,.sg-grid tbody td[data-editing=true] select{width:100%;height:100%;border:2px solid var(--sg-accent);padding:0 var(--sg-cell-pad-x);font:inherit;background:var(--sg-bg);box-sizing:border-box;outline:none}.sg-grid tbody td[data-focused=true]{outline:2px solid var(--sg-accent);outline-offset:-2px}.sg-grid .sg-checkbox-cell,.sg-grid .sg-checkbox-header{width:36px;text-align:center;padding:0}.sg-grid .sg-pagination{display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:1px solid var(--sg-border);background:var(--sg-bg-header);font-size:12px;color:var(--sg-fg-muted)}.sg-grid .sg-pagination button{background:var(--sg-bg);border:1px solid var(--sg-border-strong);border-radius:3px;width:24px;height:24px;cursor:pointer;font-size:12px;display:inline-flex;align-items:center;justify-content:center}.sg-grid .sg-pagination button:disabled{opacity:.4;cursor:default}.sg-grid .sg-pagination button:not(:disabled):hover{background:var(--sg-bg-hover)}.sg-grid .sg-pagination select{background:var(--sg-bg);border:1px solid var(--sg-border-strong);border-radius:3px;height:24px;padding:0 4px;font:inherit}.sg-grid .sg-pagination .sg-spacer{flex:1}.sg-filter-popover{position:absolute;z-index:10;background:var(--sg-bg, #fff);border:1px solid var(--sg-border-strong, #d1d5db);border-radius:4px;box-shadow:0 4px 12px #0000001a;padding:8px;min-width:200px;font-family:var(--sg-font, system-ui, sans-serif);font-size:12px;display:flex;flex-direction:column;gap:6px}.sg-filter-popover label{color:var(--sg-fg-muted, #6b7280)}.sg-filter-popover select,.sg-filter-popover input{width:100%;border:1px solid var(--sg-border-strong, #d1d5db);border-radius:3px;padding:4px 6px;font:inherit;background:#fff;box-sizing:border-box}.sg-filter-popover .sg-filter-actions{display:flex;justify-content:flex-end;gap:4px;margin-top:4px}.sg-filter-popover button{background:var(--sg-bg, #fff);border:1px solid var(--sg-border-strong, #d1d5db);border-radius:3px;padding:3px 8px;cursor:pointer;font:inherit}.sg-filter-popover button.primary{background:var(--sg-accent, #2563eb);border-color:var(--sg-accent, #2563eb);color:#fff}.sg-grid th[data-pinned=left],.sg-grid td[data-pinned=left]{position:sticky;left:0;z-index:1;background:var(--sg-bg);border-right:2px solid var(--sg-border-strong)}.sg-grid thead th[data-pinned=left]{background:var(--sg-bg-header);z-index:3}.sg-grid tbody tr:nth-child(2n) td[data-pinned=left]{background:var(--sg-bg-alt)}.sg-grid tbody tr[data-selected=true] td[data-pinned=left]{background:var(--sg-bg-row-selected)}.sg-grid th[data-pinned=right],.sg-grid td[data-pinned=right]{position:sticky;right:0;z-index:1;background:var(--sg-bg);border-left:2px solid var(--sg-border-strong)}.sg-grid thead th[data-pinned=right]{background:var(--sg-bg-header);z-index:3}.sg-grid tbody tr:nth-child(2n) td[data-pinned=right]{background:var(--sg-bg-alt)}.sg-grid tbody tr[data-selected=true] td[data-pinned=right]{background:var(--sg-bg-row-selected)}.sg-grid tbody td[data-pinned][data-cell-range=true],.sg-grid tbody tr:nth-child(2n) td[data-pinned][data-cell-range=true],.sg-grid tbody tr[data-selected=true] td[data-pinned][data-cell-range=true]{background:var(--sg-bg-selected)}