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,239 @@
|
|
|
1
|
+
module StimulusGridRails
|
|
2
|
+
# One column on a Grid. Captures everything the server needs to authorise,
|
|
3
|
+
# coerce, validate, and broadcast changes for this field — plus everything
|
|
4
|
+
# the client needs to choose an editor and render a cell.
|
|
5
|
+
#
|
|
6
|
+
# Created via the `column` DSL inside an ApplicationGrid subclass; not
|
|
7
|
+
# instantiated directly. See StimulusGridRails::Grid for usage.
|
|
8
|
+
class Column
|
|
9
|
+
TYPES = %i[string text integer bigint decimal money boolean enum date datetime reference].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :type, :editor, :editor_config, :enum_values, :concurrency,
|
|
12
|
+
:depends_on, :validators, :header, :width, :pinned, :cell_renderer,
|
|
13
|
+
:cell_editor
|
|
14
|
+
|
|
15
|
+
def initialize(name, type:, editable: false, editor: nil, editor_config: {},
|
|
16
|
+
enum_values: nil, concurrency: :last_write_wins,
|
|
17
|
+
computed: false, depends_on: [], validate: nil,
|
|
18
|
+
header: nil, width: nil, pinned: nil, cell_renderer: nil,
|
|
19
|
+
cell_editor: nil, sortable: true, filterable: true, searchable: nil)
|
|
20
|
+
raise ArgumentError, "Unknown column type #{type.inspect}" unless TYPES.include?(type)
|
|
21
|
+
@name = name.to_sym
|
|
22
|
+
@type = type
|
|
23
|
+
@editable = editable # may be `true`, `false`, or a Proc(row, user)
|
|
24
|
+
@editor = editor || default_editor_for(type)
|
|
25
|
+
@editor_config = editor_config
|
|
26
|
+
@enum_values = enum_values
|
|
27
|
+
@concurrency = concurrency
|
|
28
|
+
@computed = computed
|
|
29
|
+
@depends_on = Array(depends_on)
|
|
30
|
+
@validators = Array(validate)
|
|
31
|
+
@header = header || name.to_s.humanize
|
|
32
|
+
@width = width
|
|
33
|
+
@pinned = pinned
|
|
34
|
+
@cell_renderer = cell_renderer
|
|
35
|
+
@cell_editor = cell_editor
|
|
36
|
+
@sortable = sortable
|
|
37
|
+
@filterable = filterable
|
|
38
|
+
# Global search defaults to text-ish columns; numeric/date/boolean opt in.
|
|
39
|
+
@searchable = searchable.nil? ? %i[string text enum reference].include?(type) : searchable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Per-row, per-user editable check. RAILS.md §17 — server re-evaluates on
|
|
43
|
+
# every PATCH (never trust the client's data-editable attribute).
|
|
44
|
+
def editable_for?(row, user)
|
|
45
|
+
case @editable
|
|
46
|
+
when true, false then @editable
|
|
47
|
+
when Proc then !!@editable.call(row, user)
|
|
48
|
+
else !!@editable
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def editable_static?
|
|
53
|
+
@editable == true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def computed? = @computed
|
|
57
|
+
def cascades? = !@depends_on.empty?
|
|
58
|
+
|
|
59
|
+
# Coerce a string value (from a form submission) to this column's Ruby type.
|
|
60
|
+
# Returns [value, error] — error is a string if coercion failed.
|
|
61
|
+
def coerce(raw)
|
|
62
|
+
case @type
|
|
63
|
+
when :string, :text, :enum, :reference then [raw.to_s, nil]
|
|
64
|
+
when :integer, :bigint
|
|
65
|
+
Integer(raw.to_s, 10).then { |i| [i, nil] }
|
|
66
|
+
when :decimal, :money
|
|
67
|
+
[BigDecimal(raw.to_s), nil]
|
|
68
|
+
when :boolean
|
|
69
|
+
[%w[1 true yes on t].include?(raw.to_s.downcase), nil]
|
|
70
|
+
when :date
|
|
71
|
+
[Date.parse(raw.to_s), nil]
|
|
72
|
+
when :datetime
|
|
73
|
+
[Time.zone.parse(raw.to_s), nil]
|
|
74
|
+
else
|
|
75
|
+
[raw, nil]
|
|
76
|
+
end
|
|
77
|
+
rescue ArgumentError, TypeError => e
|
|
78
|
+
[nil, "invalid #{@type}: #{e.message}"]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def searchable? = @searchable && !@computed && !name.to_s.start_with?("_")
|
|
82
|
+
|
|
83
|
+
# Arel predicate for the global search term, or nil if this column doesn't
|
|
84
|
+
# participate. Case-insensitive contains over the column's text. Computed
|
|
85
|
+
# and action columns never match (no DB column to query).
|
|
86
|
+
def search_predicate(arel_table, term)
|
|
87
|
+
return nil unless searchable?
|
|
88
|
+
pattern = "%#{like_escape(term.to_s.downcase)}%"
|
|
89
|
+
arel_table[@name].lower.matches(pattern)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Arel predicate for a per-column filter. `criteria` mirrors the client
|
|
93
|
+
# filterModel shape: { "type" => op, "value" => v, "value2" => v2 }.
|
|
94
|
+
# Returns nil for computed/unknown columns or unparseable values.
|
|
95
|
+
def filter_predicate(arel_table, criteria)
|
|
96
|
+
return nil if @computed || name.to_s.start_with?("_")
|
|
97
|
+
col = arel_table[@name]
|
|
98
|
+
op = (criteria["type"] || criteria[:type]).to_s
|
|
99
|
+
raw = criteria["value"] || criteria[:value]
|
|
100
|
+
raw2 = criteria["value2"] || criteria[:value2]
|
|
101
|
+
|
|
102
|
+
case @type
|
|
103
|
+
when :string, :text, :enum, :reference
|
|
104
|
+
text_predicate(col, op, raw.to_s)
|
|
105
|
+
when :integer, :bigint, :decimal, :money
|
|
106
|
+
numeric_predicate(col, op, raw, raw2)
|
|
107
|
+
when :date, :datetime
|
|
108
|
+
date_predicate(col, op, raw, raw2)
|
|
109
|
+
when :boolean
|
|
110
|
+
v, err = coerce(raw)
|
|
111
|
+
err ? nil : col.eq(v)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Run client-defined validators. Returns Array of error strings.
|
|
116
|
+
def validate(value, row)
|
|
117
|
+
@validators.flat_map do |v|
|
|
118
|
+
result = v.call(value, row)
|
|
119
|
+
case result
|
|
120
|
+
when nil, true then []
|
|
121
|
+
when String then [result]
|
|
122
|
+
when Array then result
|
|
123
|
+
else [result.to_s]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Serialized into the rendered cell's data-* attributes so the client-side
|
|
129
|
+
# editor controller can mount the right editor with the right config.
|
|
130
|
+
def client_data_attrs(row, user)
|
|
131
|
+
attrs = {
|
|
132
|
+
"data-col-id" => name,
|
|
133
|
+
"data-editor" => @editor,
|
|
134
|
+
}
|
|
135
|
+
attrs["data-editable"] = "true" if editable_for?(row, user)
|
|
136
|
+
attrs["data-enum-values"] = JSON.generate(@enum_values) if @enum_values
|
|
137
|
+
attrs["data-editor-config"] = JSON.generate(@editor_config) unless @editor_config.empty?
|
|
138
|
+
attrs
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Serialized into the column's <th> so header_cell_controller picks it up.
|
|
142
|
+
def header_data_attrs
|
|
143
|
+
{
|
|
144
|
+
"data-controller" => "header-cell",
|
|
145
|
+
"data-header-cell-field-value" => name,
|
|
146
|
+
"data-header-cell-type-value" => header_cell_type,
|
|
147
|
+
"data-header-cell-sortable-value" => @sortable.to_s,
|
|
148
|
+
"data-header-cell-filter-value" => (@filterable ? filter_type_for_client : nil),
|
|
149
|
+
"data-header-cell-editable-value" => editable_static?.to_s,
|
|
150
|
+
"data-header-cell-pinned-value" => @pinned,
|
|
151
|
+
"data-header-cell-width-value" => @width,
|
|
152
|
+
"data-header-cell-cell-renderer-value" => @cell_renderer,
|
|
153
|
+
"data-header-cell-cell-editor-value" => @cell_editor,
|
|
154
|
+
}.compact
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Escape LIKE wildcards so a literal % or _ in the search term isn't treated
|
|
160
|
+
# as a pattern. Arel still quotes the value, so this is only about semantics.
|
|
161
|
+
def like_escape(str)
|
|
162
|
+
str.gsub(/[\\%_]/) { |c| "\\#{c}" }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def text_predicate(col, op, val)
|
|
166
|
+
lc = col.lower
|
|
167
|
+
v = val.downcase
|
|
168
|
+
case op
|
|
169
|
+
when "equals" then lc.eq(v)
|
|
170
|
+
when "notEqual" then lc.not_eq(v)
|
|
171
|
+
when "startsWith" then lc.matches("#{like_escape(v)}%")
|
|
172
|
+
when "endsWith" then lc.matches("%#{like_escape(v)}")
|
|
173
|
+
when "blank" then col.eq(nil).or(col.eq(""))
|
|
174
|
+
when "notBlank" then col.not_eq(nil).and(col.not_eq(""))
|
|
175
|
+
else lc.matches("%#{like_escape(v)}%") # contains (default)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def numeric_predicate(col, op, raw, raw2)
|
|
180
|
+
v, err = coerce(raw)
|
|
181
|
+
return nil if err
|
|
182
|
+
case op
|
|
183
|
+
when "greaterThan" then col.gt(v)
|
|
184
|
+
when "greaterThanOrEqual" then col.gteq(v)
|
|
185
|
+
when "lessThan" then col.lt(v)
|
|
186
|
+
when "lessThanOrEqual" then col.lteq(v)
|
|
187
|
+
when "notEqual" then col.not_eq(v)
|
|
188
|
+
when "inRange"
|
|
189
|
+
v2, e2 = coerce(raw2)
|
|
190
|
+
e2 ? col.gteq(v) : col.between(v..v2)
|
|
191
|
+
else col.eq(v)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def date_predicate(col, op, raw, raw2)
|
|
196
|
+
v, err = coerce(raw)
|
|
197
|
+
return nil if err
|
|
198
|
+
case op
|
|
199
|
+
when "greaterThan" then col.gt(v)
|
|
200
|
+
when "lessThan" then col.lt(v)
|
|
201
|
+
when "notEqual" then col.not_eq(v)
|
|
202
|
+
when "inRange"
|
|
203
|
+
v2, e2 = coerce(raw2)
|
|
204
|
+
e2 ? col.gteq(v) : col.between(v..v2)
|
|
205
|
+
else col.eq(v)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def default_editor_for(t)
|
|
210
|
+
case t
|
|
211
|
+
when :string, :text, :reference then "text"
|
|
212
|
+
when :integer, :bigint, :decimal, :money then "number"
|
|
213
|
+
when :boolean then "checkbox"
|
|
214
|
+
when :enum then "select"
|
|
215
|
+
when :date then "date"
|
|
216
|
+
when :datetime then "datetime-local"
|
|
217
|
+
else "text"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def header_cell_type
|
|
222
|
+
case @type
|
|
223
|
+
when :integer, :bigint, :decimal, :money then "number"
|
|
224
|
+
when :date, :datetime then "date"
|
|
225
|
+
when :boolean then "boolean"
|
|
226
|
+
else "text"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def filter_type_for_client
|
|
231
|
+
case @type
|
|
232
|
+
when :integer, :bigint, :decimal, :money then "number"
|
|
233
|
+
when :date, :datetime then "date"
|
|
234
|
+
when :boolean then "boolean"
|
|
235
|
+
else "text"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module StimulusGridRails
|
|
4
|
+
# Mixin for Active Record models backing a grid. Once included and wired with
|
|
5
|
+
# `broadcasts_grid`, every create / update / destroy AUTOMATICALLY broadcasts
|
|
6
|
+
# the right Turbo Stream action to the grid's tenant-scoped stream — no manual
|
|
7
|
+
# broadcast calls anywhere:
|
|
8
|
+
#
|
|
9
|
+
# create → row-insert-sorted (the full row as JSON)
|
|
10
|
+
# update → cell (each changed registered column + any
|
|
11
|
+
# computed column whose deps changed)
|
|
12
|
+
# destroy → row-remove
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
#
|
|
16
|
+
# class Athlete < ApplicationRecord
|
|
17
|
+
# include StimulusGridRails::Broadcastable
|
|
18
|
+
# broadcasts_grid AthleteGrid
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Streams are tenant-scoped via StimulusGridRails.streamables_for, so with
|
|
22
|
+
# ActsAsTenant a tenant's changes never reach another tenant's subscribers.
|
|
23
|
+
module Broadcastable
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
included do
|
|
27
|
+
# Set by the cells controller before a grid-driven save so the broadcast
|
|
28
|
+
# carries the originating client's optimistic id (RAILS.md §4) and that
|
|
29
|
+
# client suppresses its own echo. nil for changes made outside the grid
|
|
30
|
+
# (console, jobs) — those broadcast to everyone with no suppression.
|
|
31
|
+
attr_accessor :_sgr_optimistic_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class_methods do
|
|
35
|
+
def broadcasts_grid(grid_class)
|
|
36
|
+
@stimulus_grid_class = grid_class
|
|
37
|
+
after_create_commit { stimulus_grid_broadcast_insert }
|
|
38
|
+
after_update_commit { stimulus_grid_broadcast_changes }
|
|
39
|
+
after_destroy_commit { stimulus_grid_broadcast_remove }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stimulus_grid_class
|
|
43
|
+
@stimulus_grid_class
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stimulus_grid_streamables
|
|
48
|
+
StimulusGridRails.streamables_for(self.class.stimulus_grid_class.resource_name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stimulus_grid_broadcast_insert
|
|
52
|
+
grid = self.class.stimulus_grid_class.new
|
|
53
|
+
message = StimulusGridRails::TurboStreams.row_insert_sorted(
|
|
54
|
+
grid: grid.class.resource_name, row_id: id, payload: grid.row_to_json(self),
|
|
55
|
+
)
|
|
56
|
+
stimulus_grid_broadcast(message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stimulus_grid_broadcast_changes
|
|
60
|
+
grid_class = self.class.stimulus_grid_class
|
|
61
|
+
grid = grid_class.new
|
|
62
|
+
registry = grid_class.columns_registry || {}
|
|
63
|
+
changed = previous_changes.keys.map(&:to_sym)
|
|
64
|
+
|
|
65
|
+
direct = registry.values.select { |c| !c.computed? && !c.name.to_s.start_with?("_") && changed.include?(c.name) }
|
|
66
|
+
computed = registry.values.select { |c| c.computed? && (c.depends_on & changed).any? }
|
|
67
|
+
|
|
68
|
+
(direct + computed).each do |col|
|
|
69
|
+
message = StimulusGridRails::TurboStreams.cell(
|
|
70
|
+
grid: grid_class.resource_name, row_id: id, column: col.name,
|
|
71
|
+
value: grid.cell_value(self, col), optimistic_id: _sgr_optimistic_id,
|
|
72
|
+
)
|
|
73
|
+
stimulus_grid_broadcast(message)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stimulus_grid_broadcast_remove
|
|
78
|
+
grid_class = self.class.stimulus_grid_class
|
|
79
|
+
message = StimulusGridRails::TurboStreams.row_remove(
|
|
80
|
+
grid: grid_class.resource_name, row_id: id,
|
|
81
|
+
)
|
|
82
|
+
stimulus_grid_broadcast(message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def stimulus_grid_broadcast(message)
|
|
88
|
+
::Turbo::StreamsChannel.broadcast_stream_to(*stimulus_grid_streamables, content: message)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
require "turbo-rails"
|
|
3
|
+
require "stimulus-rails"
|
|
4
|
+
require "importmap-rails"
|
|
5
|
+
|
|
6
|
+
module StimulusGridRails
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace StimulusGridRails
|
|
9
|
+
|
|
10
|
+
# Precompile the gem-shipped JS so the asset pipeline finds it.
|
|
11
|
+
initializer "stimulus_grid_rails.assets" do |app|
|
|
12
|
+
if app.config.respond_to?(:assets)
|
|
13
|
+
app.config.assets.precompile += %w[
|
|
14
|
+
stimulus_grid.js
|
|
15
|
+
stimulus_grid_rails.js
|
|
16
|
+
stimulus_grid.css
|
|
17
|
+
stimulus_grid_rails.css
|
|
18
|
+
]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Make the gem's importmap manifest available to host apps.
|
|
23
|
+
# The host app's bin/importmap.rb is *augmented* with our pins below; users
|
|
24
|
+
# don't need to add `pin "stimulus_grid", ...` themselves.
|
|
25
|
+
initializer "stimulus_grid_rails.importmap", before: "importmap" do |app|
|
|
26
|
+
if app.config.respond_to?(:importmap)
|
|
27
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
28
|
+
app.config.importmap.cache_sweepers << Engine.root.join("app/assets/javascripts")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Make the gem's view partials resolvable from host apps.
|
|
33
|
+
initializer "stimulus_grid_rails.view_paths" do |app|
|
|
34
|
+
ActiveSupport.on_load(:action_controller) do
|
|
35
|
+
append_view_path StimulusGridRails::Engine.root.join("app/views")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module StimulusGridRails
|
|
5
|
+
# Base class for declaring a server-side grid. RAILS.md §7 — one source of
|
|
6
|
+
# truth per resource. All editor selection, auth, coercion, validation,
|
|
7
|
+
# broadcasting flows through here.
|
|
8
|
+
#
|
|
9
|
+
# Subclass and declare:
|
|
10
|
+
#
|
|
11
|
+
# class AthleteGrid < StimulusGridRails::Grid
|
|
12
|
+
# resource :athletes
|
|
13
|
+
# model Athlete
|
|
14
|
+
#
|
|
15
|
+
# column :athlete, type: :string, editable: true, width: 200, pinned: :left
|
|
16
|
+
# column :country, type: :string, editable: ->(row, user) { user.admin? }
|
|
17
|
+
# column :age, type: :integer, editable: true, validate: ->(v, _r) { "must be > 0" if v <= 0 }
|
|
18
|
+
# column :sport, type: :enum, editable: true, enum_values: %w[Swimming Cycling Gymnastics]
|
|
19
|
+
# column :date, type: :date, editable: true
|
|
20
|
+
# column :gold, type: :integer, editable: true
|
|
21
|
+
# column :silver, type: :integer, editable: true
|
|
22
|
+
# column :bronze, type: :integer, editable: true
|
|
23
|
+
# column :total, type: :integer, computed: true, depends_on: %i[gold silver bronze]
|
|
24
|
+
#
|
|
25
|
+
# def compute_total(row) = row.gold + row.silver + row.bronze
|
|
26
|
+
# end
|
|
27
|
+
class Grid
|
|
28
|
+
class << self
|
|
29
|
+
attr_reader :resource_name, :model_class, :columns_registry
|
|
30
|
+
|
|
31
|
+
def resource(name)
|
|
32
|
+
@resource_name = name.to_s
|
|
33
|
+
StimulusGridRails.register_grid(@resource_name, self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model(klass)
|
|
37
|
+
@model_class = klass
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def column(name, **opts)
|
|
41
|
+
@columns_registry ||= {}
|
|
42
|
+
@columns_registry[name.to_sym] = Column.new(name, **opts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Used by the controller after deserializing the URL.
|
|
46
|
+
def resolve_column!(col_id)
|
|
47
|
+
column = columns_registry[col_id.to_sym]
|
|
48
|
+
raise ArgumentError, "Unknown column #{col_id} on #{name}" unless column
|
|
49
|
+
column
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :user
|
|
54
|
+
|
|
55
|
+
def initialize(user: nil)
|
|
56
|
+
@user = user
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def columns
|
|
60
|
+
self.class.columns_registry.values
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visible_columns_for(_row)
|
|
64
|
+
columns
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Called from the controller after a successful coercion + permission check.
|
|
68
|
+
# Returns [success?, mutations_to_broadcast] where mutations is an array of
|
|
69
|
+
# [row_id, col_id, value, opts]. For computed cascade, runs the dependent
|
|
70
|
+
# column's compute_X methods and includes those too.
|
|
71
|
+
def apply_cell!(row, column, value)
|
|
72
|
+
errors = column.validate(value, row)
|
|
73
|
+
return [false, errors, []] if errors.any?
|
|
74
|
+
|
|
75
|
+
old_value = row.send(column.name)
|
|
76
|
+
row.send("#{column.name}=", value)
|
|
77
|
+
mutations = [[row_id(row), column.name.to_s, value, {}]]
|
|
78
|
+
|
|
79
|
+
# Cascade — recompute every column declared as depending on this one.
|
|
80
|
+
self.class.columns_registry.each_value do |c|
|
|
81
|
+
next unless c.computed? && c.depends_on.include?(column.name)
|
|
82
|
+
compute_method = "compute_#{c.name}"
|
|
83
|
+
if respond_to?(compute_method)
|
|
84
|
+
new_val = public_send(compute_method, row)
|
|
85
|
+
row.send("#{c.name}=", new_val) if row.respond_to?("#{c.name}=")
|
|
86
|
+
mutations << [row_id(row), c.name.to_s, new_val, {}]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
saved = row.respond_to?(:save) ? row.save : true
|
|
91
|
+
if saved
|
|
92
|
+
[true, [], mutations]
|
|
93
|
+
else
|
|
94
|
+
# Restore old value so the in-memory row doesn't carry the failed write.
|
|
95
|
+
row.send("#{column.name}=", old_value)
|
|
96
|
+
[false, Array(row.respond_to?(:errors) ? row.errors.full_messages : ["save failed"]), []]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def row_id(row)
|
|
101
|
+
row.respond_to?(:id) ? row.id : row[:id]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ----- Row create/destroy support (RAILS.md §14/§15) -----
|
|
105
|
+
|
|
106
|
+
# Default attributes for a freshly-created row. Override in subclasses.
|
|
107
|
+
def new_row_defaults
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Build (unsaved) a new model instance merging defaults with caller overrides.
|
|
112
|
+
def build_new_row(overrides = {})
|
|
113
|
+
attrs = new_row_defaults.merge((overrides || {}).symbolize_keys)
|
|
114
|
+
self.class.model_class.new(attrs)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Serialize a row to the JSON shape the client grid expects: { id, <col>: <value>, … }
|
|
118
|
+
# including computed columns. Used as the row-insert-sorted payload.
|
|
119
|
+
def row_to_h(row)
|
|
120
|
+
h = { "id" => row_id(row) }
|
|
121
|
+
self.class.columns_registry.each_value do |col|
|
|
122
|
+
next if col.name.to_s.start_with?("_") # skip action/renderer-only columns
|
|
123
|
+
h[col.name.to_s] = serialize_value(cell_value(row, col), col)
|
|
124
|
+
end
|
|
125
|
+
h
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def row_to_json(row)
|
|
129
|
+
JSON.generate(row_to_h(row))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ----- Server-side search / filter (RAILS.md §21) -----
|
|
133
|
+
|
|
134
|
+
# The base relation a request may see. Override for per-user authorization
|
|
135
|
+
# scoping (e.g. `model_class.where(team: user.team)`).
|
|
136
|
+
def scope(_user = user)
|
|
137
|
+
self.class.model_class.all
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Apply a global search term + per-column filters to a relation. `filters`
|
|
141
|
+
# is { col_name => { "type" =>, "value" =>, "value2" => } } (the client
|
|
142
|
+
# filterModel shape). Unknown columns and unparseable values are ignored.
|
|
143
|
+
def search_and_filter(relation, q: nil, filters: {})
|
|
144
|
+
relation = apply_search(relation, q)
|
|
145
|
+
relation = apply_filters(relation, filters)
|
|
146
|
+
relation
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_search(relation, q)
|
|
150
|
+
return relation if q.blank?
|
|
151
|
+
table = self.class.model_class.arel_table
|
|
152
|
+
preds = columns.filter_map { |c| c.search_predicate(table, q) }
|
|
153
|
+
return relation if preds.empty?
|
|
154
|
+
relation.where(preds.reduce(:or))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def apply_filters(relation, filters)
|
|
158
|
+
return relation if filters.blank?
|
|
159
|
+
table = self.class.model_class.arel_table
|
|
160
|
+
filters.each do |col_name, criteria|
|
|
161
|
+
next if criteria.blank?
|
|
162
|
+
col = self.class.columns_registry[col_name.to_sym]
|
|
163
|
+
next unless col
|
|
164
|
+
pred = col.filter_predicate(table, criteria)
|
|
165
|
+
relation = relation.where(pred) if pred
|
|
166
|
+
end
|
|
167
|
+
relation
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Server-side sort (RAILS.md §21). `sort_model` is the client shape:
|
|
171
|
+
# [{ "colId" =>, "sort" => "asc"|"desc" }, …]. Only real (non-computed,
|
|
172
|
+
# non-underscore) columns that exist on the model are honored.
|
|
173
|
+
def apply_sort(relation, sort_model)
|
|
174
|
+
return relation if sort_model.blank?
|
|
175
|
+
table = self.class.model_class.arel_table
|
|
176
|
+
names = self.class.model_class.column_names
|
|
177
|
+
orders = Array(sort_model).filter_map do |entry|
|
|
178
|
+
col_id = (entry["colId"] || entry[:colId]).to_s
|
|
179
|
+
col = self.class.columns_registry[col_id.to_sym]
|
|
180
|
+
next unless col && !col.computed? && !col_id.start_with?("_") && names.include?(col_id)
|
|
181
|
+
dir = (entry["sort"] || entry[:sort]).to_s.downcase == "desc" ? :desc : :asc
|
|
182
|
+
table[col_id.to_sym].public_send(dir)
|
|
183
|
+
end
|
|
184
|
+
orders.empty? ? relation : relation.reorder(*orders)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def cell_value(row, column)
|
|
188
|
+
if column.computed?
|
|
189
|
+
method = "compute_#{column.name}"
|
|
190
|
+
return respond_to?(method) ? public_send(method, row) : nil
|
|
191
|
+
end
|
|
192
|
+
row.respond_to?(column.name) ? row.send(column.name) : row[column.name]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Renders the value into the DOM. Override per-column or per-type in
|
|
196
|
+
# subclasses for richer renderers.
|
|
197
|
+
def format_cell(row, column)
|
|
198
|
+
v = cell_value(row, column)
|
|
199
|
+
case column.type
|
|
200
|
+
when :money then ActiveSupport::NumberHelper.number_to_currency(v) rescue v.to_s
|
|
201
|
+
when :date then v.respond_to?(:to_date) ? v.to_date.iso8601 : v.to_s
|
|
202
|
+
when :datetime then v.respond_to?(:iso8601) ? v.iso8601 : v.to_s
|
|
203
|
+
when :boolean then v ? "✓" : ""
|
|
204
|
+
else v.to_s
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# JSON-friendly value for row_to_h — numbers stay numeric, dates become
|
|
209
|
+
# ISO strings, everything else stringifies sensibly.
|
|
210
|
+
def serialize_value(v, column)
|
|
211
|
+
case column.type
|
|
212
|
+
when :integer, :bigint then v.to_i
|
|
213
|
+
when :decimal, :money then v.to_f
|
|
214
|
+
when :boolean then !!v
|
|
215
|
+
when :date then v.respond_to?(:to_date) ? v.to_date.iso8601 : v
|
|
216
|
+
when :datetime then v.respond_to?(:iso8601) ? v.iso8601 : v
|
|
217
|
+
else v
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module StimulusGridRails
|
|
2
|
+
# Custom Turbo Stream actions (RAILS.md §1) — generates <turbo-stream> tags
|
|
3
|
+
# whose `action=` is one of: cell, cell-attr, cell-confirm, cell-revert,
|
|
4
|
+
# cell-conflict, row-insert-sorted, row-remove, aggregate, bulk.
|
|
5
|
+
#
|
|
6
|
+
# The matching client-side StreamActions are registered by
|
|
7
|
+
# app/assets/javascripts/stimulus_grid_rails.js (registerStreamActions).
|
|
8
|
+
#
|
|
9
|
+
# Use directly:
|
|
10
|
+
# render turbo_stream: StimulusGridRails::TurboStreams.cell(
|
|
11
|
+
# grid: "athletes", row_id: athlete.id, column: :age,
|
|
12
|
+
# value: athlete.age, optimistic_id: params[:optimistic_id],
|
|
13
|
+
# )
|
|
14
|
+
module TurboStreams
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Update one cell — value rendered inline as the cell's textContent.
|
|
18
|
+
# `optimistic_id` is echoed back so the originating client can suppress
|
|
19
|
+
# its own broadcast.
|
|
20
|
+
def cell(grid:, row_id:, column:, value:, optimistic_id: nil)
|
|
21
|
+
tag("cell", grid: grid, row_id: row_id, column: column,
|
|
22
|
+
optimistic_id: optimistic_id) do
|
|
23
|
+
# Plain HTML — the StreamAction handler reads this as the new cell content.
|
|
24
|
+
ERB::Util.html_escape(value.to_s)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Set an attribute on a cell (e.g. data-dirty="false").
|
|
29
|
+
def cell_attr(grid:, row_id:, column:, attr:, value:)
|
|
30
|
+
tag("cell-attr", grid: grid, row_id: row_id, column: column,
|
|
31
|
+
attr: attr, value: value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Clear pending/optimistic state on a cell after a successful save.
|
|
35
|
+
def cell_confirm(grid:, row_id:, column:, value:, optimistic_id:)
|
|
36
|
+
tag("cell-confirm", grid: grid, row_id: row_id, column: column,
|
|
37
|
+
optimistic_id: optimistic_id) do
|
|
38
|
+
ERB::Util.html_escape(value.to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Restore prior server value + render inline error.
|
|
43
|
+
def cell_revert(grid:, row_id:, column:, value:, errors:, optimistic_id:)
|
|
44
|
+
tag("cell-revert", grid: grid, row_id: row_id, column: column,
|
|
45
|
+
optimistic_id: optimistic_id,
|
|
46
|
+
errors: errors.to_json) do
|
|
47
|
+
ERB::Util.html_escape(value.to_s)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Conflict — server value differs from client base value.
|
|
52
|
+
def cell_conflict(grid:, row_id:, column:, server_value:, client_value:, optimistic_id:)
|
|
53
|
+
tag("cell-conflict", grid: grid, row_id: row_id, column: column,
|
|
54
|
+
optimistic_id: optimistic_id,
|
|
55
|
+
server_value: server_value, client_value: client_value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Insert a row, respecting the client's current sort order. `payload` is a
|
|
59
|
+
# JSON row object; it's HTML-escaped here and decoded by the client's
|
|
60
|
+
# textContent read, so names containing & or < stay valid JSON.
|
|
61
|
+
def row_insert_sorted(grid:, row_id:, payload:)
|
|
62
|
+
tag("row-insert-sorted", grid: grid, row_id: row_id) do
|
|
63
|
+
ERB::Util.html_escape(payload)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Remove a row by id.
|
|
68
|
+
def row_remove(grid:, row_id:)
|
|
69
|
+
tag("row-remove", grid: grid, row_id: row_id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Update a footer aggregate (sum/avg/count/etc.).
|
|
73
|
+
def aggregate(grid:, column:, kind:, value:)
|
|
74
|
+
tag("aggregate", grid: grid, column: column, kind: kind) do
|
|
75
|
+
ERB::Util.html_escape(value.to_s)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Atomic batched stream — wraps N inner streams so the client applies them
|
|
80
|
+
# in a single DOM reflow. Pass an array of already-built turbo-stream
|
|
81
|
+
# strings (other helpers return strings).
|
|
82
|
+
def bulk(grid:, streams:)
|
|
83
|
+
inner = streams.join
|
|
84
|
+
tag("bulk", grid: grid) { inner }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Per-user editing indicator on a cell.
|
|
88
|
+
def presence(grid:, row_id:, column:, user_id:, user_label:, active:)
|
|
89
|
+
tag("presence", grid: grid, row_id: row_id, column: column,
|
|
90
|
+
user_id: user_id, user_label: user_label,
|
|
91
|
+
active: active.to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Internal — build a <turbo-stream> element. Keys with nil values are
|
|
95
|
+
# dropped. Block content becomes the <template> payload.
|
|
96
|
+
def tag(action, **attrs)
|
|
97
|
+
attrs[:action] = action
|
|
98
|
+
kept = attrs.compact
|
|
99
|
+
attr_str = kept.map { |k, v| %(#{k.to_s.tr("_", "-")}="#{ERB::Util.html_escape(v)}") }.join(" ")
|
|
100
|
+
payload = block_given? ? yield : nil
|
|
101
|
+
template = payload ? "<template>#{payload}</template>" : ""
|
|
102
|
+
%(<turbo-stream #{attr_str}>#{template}</turbo-stream>)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|