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
|
@@ -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>
|
data/config/importmap.rb
ADDED
|
@@ -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
|