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,47 @@
1
+ /* Visual states for cells managed by stimulus_grid_rails. Loaded on top of
2
+ stimulus_grid.css. */
3
+
4
+ .sgr-cell-pending {
5
+ background: linear-gradient(
6
+ 90deg,
7
+ rgba(59, 130, 246, 0.08) 25%,
8
+ rgba(59, 130, 246, 0.18) 50%,
9
+ rgba(59, 130, 246, 0.08) 75%
10
+ );
11
+ background-size: 200% 100%;
12
+ animation: sgr-cell-pulse 1.2s linear infinite;
13
+ }
14
+
15
+ @keyframes sgr-cell-pulse {
16
+ 0% { background-position: 100% 0; }
17
+ 100% { background-position: 0% 0; }
18
+ }
19
+
20
+ .sgr-cell-just-confirmed {
21
+ background-color: rgba(16, 185, 129, 0.18);
22
+ transition: background-color 0.5s ease-out;
23
+ }
24
+
25
+ .sgr-cell-error {
26
+ background-color: rgba(239, 68, 68, 0.15);
27
+ outline: 1px solid #ef4444;
28
+ }
29
+
30
+ .sgr-cell-conflict {
31
+ background-color: rgba(245, 158, 11, 0.18);
32
+ outline: 1px dashed #f59e0b;
33
+ }
34
+
35
+ .sgr-presence {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ width: 16px; height: 16px;
40
+ border-radius: 8px;
41
+ background: #6366f1;
42
+ color: white;
43
+ font-size: 9px;
44
+ font-weight: 700;
45
+ margin-left: 4px;
46
+ vertical-align: middle;
47
+ }
@@ -0,0 +1,51 @@
1
+ module StimulusGridRails
2
+ # Base for all gem controllers. Inherits the host's configured controller
3
+ # (default ApplicationController) so Devise's authenticate_user! and
4
+ # ActsAsTenant's set_current_tenant_through_filter run for grid endpoints
5
+ # too — without that, these actions would execute unauthenticated and
6
+ # outside any tenant scope, leaking rows across tenants.
7
+ #
8
+ # Configure with `StimulusGridRails.parent_controller = "ApplicationController"`.
9
+ class BaseController < StimulusGridRails.parent_controller.constantize
10
+ # Tokens are sent in the X-CSRF-Token header by the grid-sync controller, so
11
+ # forgery protection stays on (inherited). Nothing is skipped.
12
+
13
+ private
14
+
15
+ def grid_for(resource)
16
+ StimulusGridRails.lookup_grid(resource).new(user: current_grid_user)
17
+ end
18
+
19
+ # Scoped lookup — never a bare Model.find. Goes through grid.scope(user) so
20
+ # ActsAsTenant (or a custom scope override) constrains what this user can
21
+ # reach. A row outside the scope raises RecordNotFound, not a silent leak.
22
+ def find_row!(grid, id)
23
+ grid.scope(current_grid_user).find(id)
24
+ end
25
+
26
+ # Devise's current_user when present; nil otherwise.
27
+ def current_grid_user
28
+ respond_to?(:current_user) ? current_user : nil
29
+ end
30
+
31
+ def turbo_stream_render(body, status: :ok)
32
+ render plain: body, content_type: "text/vnd.turbo-stream.html", status: status
33
+ end
34
+
35
+ # Record one undoable mutation (RAILS.md §16). No-op if the audit table
36
+ # hasn't been installed, so undo/redo is opt-in via the migration.
37
+ def record_audit(resource:, row_id:, column:, prior:, current:)
38
+ return unless StimulusGridRails::Audit.available?
39
+ StimulusGridRails::Audit.create!(
40
+ resource: resource.to_s,
41
+ row_id: row_id.to_s,
42
+ column: column.to_s,
43
+ prior_value: prior.nil? ? nil : prior.to_s,
44
+ new_value: current.nil? ? nil : current.to_s,
45
+ user_id: current_grid_user&.id,
46
+ )
47
+ rescue => e
48
+ Rails.logger.warn("[stimulus_grid_rails] audit insert failed: #{e.message}")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,113 @@
1
+ require_dependency "stimulus_grid_rails/turbo_streams_helper"
2
+
3
+ module StimulusGridRails
4
+ # Single cell-mutation endpoint — RAILS.md §8.
5
+ #
6
+ # PATCH /grids/:resource/:row_id/cells/:column
7
+ # body: { "value": <new value>, "optimistic_id": "...", "lock_version": N? }
8
+ #
9
+ # The save fires the model's after_update_commit, which AUTOMATICALLY
10
+ # broadcasts the changed cell(s) (RAILS.md §1/§4) — this controller does not
11
+ # broadcast. It only returns the optimistic reconcile (cell-confirm or
12
+ # cell-revert) directly to the originator and records an undo audit.
13
+ class CellsController < BaseController
14
+ def update
15
+ grid = grid_for(params[:resource])
16
+ column = grid.class.resolve_column!(params[:column])
17
+ row = find_row!(grid, params[:row_id])
18
+
19
+ head :forbidden and return unless column.editable_for?(row, current_grid_user)
20
+
21
+ raw_value = params[:value]
22
+ optimistic_id = params[:optimistic_id] || request.headers["X-Optimistic-Id"]
23
+ lock_version = params[:lock_version]
24
+
25
+ if stale_version?(column, row, lock_version)
26
+ turbo_stream_render(TurboStreams.cell_conflict(
27
+ grid: params[:resource], row_id: row.id, column: column.name,
28
+ server_value: grid.cell_value(row, column), client_value: raw_value,
29
+ optimistic_id: optimistic_id,
30
+ ))
31
+ return
32
+ end
33
+
34
+ value, coerce_err = column.coerce(raw_value)
35
+ return render_revert(grid, row, column, [coerce_err], optimistic_id) if coerce_err
36
+
37
+ prior = grid.cell_value(row, column)
38
+ row._sgr_optimistic_id = optimistic_id # carried into the auto-broadcast
39
+ ok, errors, mutations = grid.apply_cell!(row, column, value)
40
+
41
+ if ok
42
+ record_audit(resource: params[:resource], row_id: row.id, column: column.name,
43
+ prior: prior, current: value)
44
+ turbo_stream_render(build_response_stream(params[:resource], mutations, optimistic_id))
45
+ else
46
+ render_revert(grid, row, column, errors, optimistic_id)
47
+ end
48
+ end
49
+
50
+ def bulk
51
+ grid = grid_for(params[:resource])
52
+ mutations = []
53
+
54
+ params.require(:mutations).each do |m|
55
+ column = grid.class.resolve_column!(m[:column])
56
+ row = find_row!(grid, m[:row_id])
57
+ next unless column.editable_for?(row, current_grid_user)
58
+
59
+ value, err = column.coerce(m[:value])
60
+ next if err
61
+
62
+ prior = grid.cell_value(row, column)
63
+ row._sgr_optimistic_id = params[:optimistic_id]
64
+ ok, _errs, ms = grid.apply_cell!(row, column, value)
65
+ if ok
66
+ record_audit(resource: params[:resource], row_id: row.id, column: column.name,
67
+ prior: prior, current: value)
68
+ mutations.concat(ms)
69
+ end
70
+ end
71
+
72
+ streams = mutations.map do |row_id, col, val, _opts|
73
+ TurboStreams.cell_confirm(grid: params[:resource], row_id: row_id, column: col,
74
+ value: val, optimistic_id: params[:optimistic_id])
75
+ end
76
+ turbo_stream_render(TurboStreams.bulk(grid: params[:resource], streams: streams))
77
+ end
78
+
79
+ private
80
+
81
+ def stale_version?(column, row, lock_version)
82
+ column.concurrency == :version_checked &&
83
+ lock_version.present? &&
84
+ row.respond_to?(:lock_version) &&
85
+ lock_version.to_i != row.lock_version
86
+ end
87
+
88
+ def render_revert(grid, row, column, errors, optimistic_id)
89
+ turbo_stream_render(
90
+ TurboStreams.cell_revert(
91
+ grid: params[:resource], row_id: row.id, column: column.name,
92
+ value: grid.cell_value(row, column), errors: errors, optimistic_id: optimistic_id,
93
+ ),
94
+ status: :unprocessable_entity,
95
+ )
96
+ end
97
+
98
+ # Originator response: confirm the edited cell, push cascaded computed cells
99
+ # as plain `cell` updates. (Other clients get all of these via broadcast.)
100
+ def build_response_stream(resource, mutations, optimistic_id)
101
+ streams = mutations.map.with_index do |(row_id, col, val, _opts), i|
102
+ if i.zero?
103
+ TurboStreams.cell_confirm(grid: resource, row_id: row_id, column: col,
104
+ value: val, optimistic_id: optimistic_id)
105
+ else
106
+ TurboStreams.cell(grid: resource, row_id: row_id, column: col,
107
+ value: val, optimistic_id: optimistic_id)
108
+ end
109
+ end
110
+ streams.length > 1 ? TurboStreams.bulk(grid: resource, streams: streams) : streams.first
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,53 @@
1
+ require_dependency "stimulus_grid_rails/turbo_streams_helper"
2
+
3
+ module StimulusGridRails
4
+ # Undo / redo — RAILS.md §16.
5
+ #
6
+ # POST /grids/:resource/undo → revert the user's last mutation
7
+ # POST /grids/:resource/redo → re-apply the last undone mutation
8
+ #
9
+ # Each replays the prior/new value through grid.apply_cell!, so it goes
10
+ # through the same save → validation → cascade → auto-broadcast path as a
11
+ # normal edit. Scoped per current_user + resource. Audits whose row has since
12
+ # been deleted are skipped (and marked) rather than failing the request.
13
+ class HistoryController < BaseController
14
+ SCAN_LIMIT = 50
15
+
16
+ def undo
17
+ step(Audit.undoable(params[:resource], current_grid_user&.id),
18
+ value: :prior_value, undone: true)
19
+ end
20
+
21
+ # `redo` is a Ruby keyword, so the action is named redo_change.
22
+ def redo_change
23
+ step(Audit.redoable(params[:resource], current_grid_user&.id),
24
+ value: :new_value, undone: false)
25
+ end
26
+
27
+ private
28
+
29
+ def step(audits, value:, undone:)
30
+ return head(:not_implemented) unless Audit.available?
31
+ audits.limit(SCAN_LIMIT).each do |audit|
32
+ applied = apply(audit, audit.public_send(value))
33
+ # Mark the audit's new state regardless: applied → flip undone flag;
34
+ # skipped (row gone) → also flip so it doesn't block the next one.
35
+ audit.update!(undone: undone, undone_at: undone ? Time.current : nil)
36
+ return head(:ok) if applied
37
+ end
38
+ head :no_content
39
+ end
40
+
41
+ def apply(audit, raw_value)
42
+ grid = grid_for(audit.resource)
43
+ column = grid.class.resolve_column!(audit.column)
44
+ row = find_row!(grid, audit.row_id)
45
+ value, err = column.coerce(raw_value)
46
+ return false if err
47
+ ok, = grid.apply_cell!(row, column, value) # saves → after_update_commit auto-broadcasts
48
+ ok
49
+ rescue ActiveRecord::RecordNotFound, ArgumentError
50
+ false
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,98 @@
1
+ require_dependency "stimulus_grid_rails/turbo_streams_helper"
2
+
3
+ module StimulusGridRails
4
+ # Row search / create / destroy — RAILS.md §14/§15/§21.
5
+ #
6
+ # GET /grids/:resource/rows?q=&filters= → index (server-side search/filter, JSON)
7
+ # POST /grids/:resource/rows → create
8
+ # DELETE /grids/:resource/rows/bulk → destroy_bulk (ids[])
9
+ # DELETE /grids/:resource/rows/:row_id → destroy
10
+ #
11
+ # Create/destroy broadcast AUTOMATICALLY via the model's commit callbacks
12
+ # (Broadcastable) — these actions just persist and return an empty 200; the
13
+ # originating tab applies the change when the broadcast lands.
14
+ class RowsController < BaseController
15
+ MAX_ROWS = 5_000
16
+
17
+ def index
18
+ grid = grid_for(params[:resource])
19
+ relation = grid.search_and_filter(grid.scope(current_grid_user),
20
+ q: params[:q], filters: parse_filters)
21
+ relation = grid.apply_sort(relation, parse_sort)
22
+ total = relation.count
23
+
24
+ if params[:page].present?
25
+ # Server-side row model: return just the requested window.
26
+ page = params[:page].to_i
27
+ page_size = (params[:page_size].presence || 25).to_i.clamp(1, 1000)
28
+ window = relation.offset(page * page_size).limit(page_size)
29
+ rows = window.map { |r| grid.row_to_h(r) }
30
+ render json: { rows: rows, total: total, page: page, page_size: page_size, limited: false }
31
+ else
32
+ # Client-side model: the capped full (filtered) set.
33
+ rows = relation.limit(MAX_ROWS).map { |r| grid.row_to_h(r) }
34
+ render json: { rows: rows, total: total, limited: total > MAX_ROWS }
35
+ end
36
+ end
37
+
38
+ def create
39
+ grid = grid_for(params[:resource])
40
+ row = grid.build_new_row(create_attributes)
41
+ # Stamp the tenant if the host uses ActsAsTenant and the model is scoped —
42
+ # otherwise a created row could escape the tenant. ActsAsTenant normally
43
+ # sets this automatically when current_tenant is present.
44
+ if row.save
45
+ head :ok # after_create_commit broadcasts row-insert-sorted to everyone
46
+ else
47
+ render json: { errors: row.errors.full_messages }, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ def destroy
52
+ grid = grid_for(params[:resource])
53
+ find_row!(grid, params[:row_id]).destroy # after_destroy_commit broadcasts row-remove
54
+ head :ok
55
+ end
56
+
57
+ def destroy_bulk
58
+ grid = grid_for(params[:resource])
59
+ params.require(:ids)
60
+ # Scoped where (not find) so ids outside the user's scope are simply
61
+ # ignored rather than raising — each destroy fires its own broadcast.
62
+ grid.scope(current_grid_user).where(id: params[:ids]).find_each(&:destroy)
63
+ head :ok
64
+ end
65
+
66
+ private
67
+
68
+ def create_attributes
69
+ raw = params[:attributes]
70
+ return {} if raw.blank?
71
+ raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
72
+ end
73
+
74
+ # `filters` arrives as a JSON string (query param) or a nested hash.
75
+ def parse_filters
76
+ raw = params[:filters]
77
+ return {} if raw.blank?
78
+ if raw.is_a?(String)
79
+ JSON.parse(raw)
80
+ elsif raw.respond_to?(:to_unsafe_h)
81
+ raw.to_unsafe_h
82
+ else
83
+ raw.to_h
84
+ end
85
+ rescue JSON::ParserError
86
+ {}
87
+ end
88
+
89
+ # `sort` arrives as a JSON string: [{ "colId":, "sort":"asc"|"desc" }, …].
90
+ def parse_sort
91
+ raw = params[:sort]
92
+ return [] if raw.blank?
93
+ raw.is_a?(String) ? JSON.parse(raw) : raw
94
+ rescue JSON::ParserError
95
+ []
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,32 @@
1
+ module StimulusGridRails
2
+ # One undoable cell mutation (RAILS.md §16). Recorded per successful cell
3
+ # commit; undo/redo replay the inverse/forward value as a normal mutation so
4
+ # validations re-run, cascades cascade, and broadcasts fire. Scoped per
5
+ # user + resource (not global).
6
+ #
7
+ # Install the table with the migration shipped in the gem (or copy it):
8
+ # rails stimulus_grid_rails:install:migrations && rails db:migrate
9
+ class Audit < ActiveRecord::Base
10
+ self.table_name = "stimulus_grid_audits"
11
+
12
+ # True once the table is installed. Auditing (and undo/redo) is a no-op
13
+ # until then, so the rest of the gem works without the migration.
14
+ def self.available?
15
+ table_exists?
16
+ rescue ActiveRecord::ActiveRecordError
17
+ false
18
+ end
19
+
20
+ # Most-recent not-yet-undone mutation for this user + resource.
21
+ def self.undoable(resource, user_id)
22
+ where(resource: resource.to_s, user_id: user_id, undone: false)
23
+ .order(created_at: :desc, id: :desc)
24
+ end
25
+
26
+ # Most-recently-undone mutation, ready to redo.
27
+ def self.redoable(resource, user_id)
28
+ where(resource: resource.to_s, user_id: user_id, undone: true)
29
+ .order(undone_at: :desc, id: :desc)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ <%#
2
+ Renders a server-driven grid for the given resource.
3
+
4
+ Required locals:
5
+ grid: an instance of an ApplicationGrid subclass
6
+ rows: the collection of model instances to render
7
+
8
+ Optional locals:
9
+ id: DOM id for the grid element (default: "<resource>-grid")
10
+ css_class: extra CSS classes on the grid wrapper
11
+ pagination: true|false (default true)
12
+ page_size: default 25
13
+ row_selection: nil | "single" | "multiple"
14
+ %>
15
+ <%
16
+ resource = grid.class.resource_name
17
+ grid_id = local_assigns[:id] || "#{resource}-grid"
18
+ pagination = local_assigns.fetch(:pagination, true)
19
+ page_size = local_assigns.fetch(:page_size, 25)
20
+ row_selection = local_assigns[:row_selection]
21
+
22
+ # Server-side row model (RAILS.md §21). Pass `server_side: true` + `total:`
23
+ # (the full row count) and render only the first page as `rows:`. The grid then
24
+ # fetches windows from rows#index on page/sort/filter changes.
25
+ server_side = local_assigns.fetch(:server_side, false)
26
+ total_rows = local_assigns[:total] || (rows.respond_to?(:size) ? rows.size : 0)
27
+
28
+ # Optional left gutter for row selection:
29
+ # row_gutter: :numbers → 1-based row numbers (click # = select row)
30
+ # row_gutter: :checkbox → a checkbox per row + select-all in the header
31
+ # row_gutter: false → none (default)
32
+ # (true is treated as :numbers for convenience.)
33
+ row_gutter = local_assigns.fetch(:row_gutter, false)
34
+ row_gutter = :numbers if row_gutter == true
35
+
36
+ # Drag selected rows by the gutter to reorder them (ghost preview).
37
+ row_drag = local_assigns.fetch(:row_drag, false)
38
+
39
+ # Client endpoints are built from the configured mount path (default "/grids"),
40
+ # so namespacing the engine (e.g. StimulusGridRails.mount_path = "/admin/grids")
41
+ # is reflected here without depending on the engine's route-helper name. :row_id
42
+ # and :column stay as placeholders the grid-sync controller substitutes.
43
+ base = StimulusGridRails.mount_path
44
+ %>
45
+
46
+ <div id="<%= grid_id %>"
47
+ class="<%= local_assigns[:css_class] %> sgr-panel"
48
+ data-controller="grid grid-sync"
49
+ data-grid-name="<%= resource %>"
50
+ data-grid-row-selection-value="<%= row_selection %>"
51
+ data-grid-pagination-value="<%= pagination %>"
52
+ data-grid-page-size-value="<%= page_size %>"
53
+ data-grid-server-side-value="<%= server_side %>"
54
+ data-grid-row-count-value="<%= total_rows %>"
55
+ data-grid-row-drag-value="<%= row_drag %>"
56
+ data-grid-sync-server-side-value="<%= server_side %>"
57
+ data-grid-sync-resource-value="<%= resource %>"
58
+ data-grid-sync-cells-path-template-value="<%= "#{base}/:resource/:row_id/cells/:column" %>"
59
+ data-grid-sync-rows-path-value="<%= "#{base}/#{resource}/rows" %>"
60
+ data-grid-sync-row-path-template-value="<%= "#{base}/#{resource}/rows/:row_id" %>"
61
+ data-grid-sync-bulk-rows-path-value="<%= "#{base}/#{resource}/rows/bulk" %>"
62
+ data-grid-sync-cells-bulk-path-value="<%= "#{base}/#{resource}/bulk" %>"
63
+ data-grid-sync-undo-path-value="<%= "#{base}/#{resource}/undo" %>"
64
+ data-grid-sync-redo-path-value="<%= "#{base}/#{resource}/redo" %>">
65
+ <table>
66
+ <thead>
67
+ <tr>
68
+ <% if row_gutter == :checkbox %>
69
+ <th data-controller="header-cell" data-header-cell-field-value="_select"
70
+ data-header-cell-checkbox-value="true" data-header-cell-pinned-value="left"
71
+ data-header-cell-width-value="40"></th>
72
+ <% elsif row_gutter == :numbers %>
73
+ <th data-controller="header-cell" data-header-cell-field-value="_rownum"
74
+ data-header-cell-row-number-value="true" data-header-cell-pinned-value="left"
75
+ data-header-cell-width-value="46"></th>
76
+ <% end %>
77
+ <% grid.columns.each do |col| %>
78
+ <th <% col.header_data_attrs.each do |k, v| %><%= k %>="<%= v %>" <% end %>><%= col.header %></th>
79
+ <% end %>
80
+ </tr>
81
+ </thead>
82
+ <tbody>
83
+ <% rows.each do |row| %>
84
+ <%= render partial: "stimulus_grid_rails/grids/row", locals: { grid: grid, row: row } %>
85
+ <% end %>
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+
90
+ <% if pagination %>
91
+ <nav class="sg-pagination"
92
+ data-controller="pagination"
93
+ data-pagination-grid-outlet="#<%= grid_id %>">
94
+ <button data-pagination-target="first" data-action="pagination#first">«</button>
95
+ <button data-pagination-target="prev" data-action="pagination#prev">‹</button>
96
+ <span class="sg-spacer"></span>
97
+ <span data-pagination-target="pageInfo">…</span>
98
+ <span class="sg-spacer"></span>
99
+ <button data-pagination-target="next" data-action="pagination#next">›</button>
100
+ <button data-pagination-target="last" data-action="pagination#last">»</button>
101
+ </nav>
102
+ <% end %>
103
+
104
+ <%# Turbo Stream subscription. Tenant-scoped via streamables_for, so a tenant
105
+ only ever receives its own broadcasts (RAILS.md §2). The broadcaster (model
106
+ commit callbacks) derives the same streamables, so they match. %>
107
+ <%= turbo_stream_from(*StimulusGridRails.streamables_for(resource)) %>
@@ -0,0 +1,15 @@
1
+ <%#
2
+ One row. Cell editability and editor metadata flow from the column
3
+ registry. We emit data-editable only for columns the user can actually
4
+ edit — Stimulus refuses to enter edit mode otherwise (RAILS.md §17).
5
+ %>
6
+ <tr data-row-id="<%= grid.row_id(row) %>"
7
+ <% if row.respond_to?(:lock_version) %>data-lock-version="<%= row.lock_version %>"<% end %>>
8
+ <% grid.columns.each do |col| %>
9
+ <%
10
+ attrs = col.client_data_attrs(row, local_assigns[:current_user])
11
+ attrs["data-pinned"] = col.pinned if col.pinned
12
+ %>
13
+ <td <% attrs.each do |k, v| %><%= k %>="<%= v %>" <% end %>><%= grid.format_cell(row, col) %></td>
14
+ <% end %>
15
+ </tr>
@@ -0,0 +1,6 @@
1
+ # Importmap pins exposed to host apps. Loaded by the engine initializer.
2
+ #
3
+ # The host app's bin/importmap.rb pins are merged on top of these, so a host
4
+ # can override any pin by re-declaring it.
5
+ pin "stimulus_grid", to: "stimulus_grid.js", preload: true
6
+ pin "stimulus_grid_rails", to: "stimulus_grid_rails.js", preload: true
data/config/routes.rb ADDED
@@ -0,0 +1,24 @@
1
+ StimulusGridRails::Engine.routes.draw do
2
+ # Single cell mutation — RAILS.md §8.
3
+ patch "/:resource/:row_id/cells/:column",
4
+ to: "cells#update",
5
+ as: :cell,
6
+ constraints: { row_id: /[^\/]+/, column: /[^\/]+/ }
7
+
8
+ # Bulk cell paste — RAILS.md §9 fill-down / bulk paste.
9
+ post "/:resource/bulk", to: "cells#bulk", as: :bulk
10
+
11
+ # Undo / redo — RAILS.md §16.
12
+ post "/:resource/undo", to: "history#undo", as: :undo
13
+ post "/:resource/redo", to: "history#redo_change", as: :redo
14
+
15
+ # Server-side search/filter — RAILS.md §21. Returns matching rows as JSON.
16
+ get "/:resource/rows", to: "rows#index", as: :index_rows
17
+
18
+ # Row create/destroy — RAILS.md §14/§15. `rows/bulk` must precede the
19
+ # `:row_id` route so "bulk" isn't captured as an id.
20
+ post "/:resource/rows", to: "rows#create", as: :rows
21
+ delete "/:resource/rows/bulk", to: "rows#destroy_bulk", as: :bulk_rows
22
+ delete "/:resource/rows/:row_id", to: "rows#destroy", as: :row,
23
+ constraints: { row_id: /[^\/]+/ }
24
+ end
@@ -0,0 +1,18 @@
1
+ class CreateStimulusGridAudits < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :stimulus_grid_audits do |t|
4
+ t.string :resource, null: false
5
+ t.string :row_id, null: false
6
+ t.string :column, null: false
7
+ t.text :prior_value
8
+ t.text :new_value
9
+ t.string :user_id
10
+ t.boolean :undone, null: false, default: false
11
+ t.datetime :undone_at
12
+
13
+ t.timestamps
14
+ end
15
+ add_index :stimulus_grid_audits, %i[resource user_id undone created_at],
16
+ name: "idx_sgr_audits_undo"
17
+ end
18
+ end