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