backstage 0.1.11 → 0.1.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da942f19c113814d2f7a5e738b3b7f7ce1cc1e8b23beeaabe8e9ef7e1f0d1b50
4
- data.tar.gz: 805dedf4003e77e48d6e9bf5eb426d3f24e91fe61297a97eebd3c1f8a858e8f1
3
+ metadata.gz: 9de1bd6823d300ceef1f45b44ff97b635b6497ed1e58df6d814ec5ce45efc56e
4
+ data.tar.gz: d2863d1096ece91857ab403a4fa874f015f4065b5d9922553eaccc09d2245061
5
5
  SHA512:
6
- metadata.gz: 76d4dd10a000b8aeb880f7df124140c351553e44fd98ee3ab42b980cb7abb315297fc959c50a2f3770c48d289db77b28326d4073677046c43dc83cb12022d29d
7
- data.tar.gz: 800c78a96cc7e9289edff896f5264d0aa73399c4ae30f1ad6557e100213a1c4975f6da1c823fc0f4842744952320f9fe18f3f6a5f1ebe7a3de9b964742c1999a
6
+ metadata.gz: f2799b494c57a749cc0c29595121fda2a90bf5c99704138bf70597fe14b1480c96c49984528b29f95fee0b1714ce0376e351be31057ea67b0fd6fb261675cb5d
7
+ data.tar.gz: d95bbf7d40f238529b320ed0194f8c4fd6ecfc7799630e1274145423d869f1e36aeeb581a9abeb8a17761bc85464ca4618f705ad1e776ddf91f3cdb9bb1dcbef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.13] — 2026-05-26
11
+
12
+ ### Added
13
+
14
+ - `c.nested :assoc, fields: [...]` DSL for `accepts_nested_attributes_for` associations — renders existing nested records as editable rows
15
+
16
+ ### Fixed
17
+
18
+ - SQL injection: sort column now uses Arel table quoting instead of raw string interpolation
19
+ - XSS: `respond_with_success` now HTML-escapes the message argument before rendering
20
+ - `new.html.erb` now respects container fields (row/section) — previously rendered a spurious wrapper `<div>` and label around each
21
+ - `decimal` and `float` column types now map to `:decimal` instead of `:integer`; a new `_decimal` partial renders them with `step: "any"`
22
+ - `section` block now uses `ensure` to reset `@current_target`, preventing a stale target if the block raises
23
+ - `find_field` now searches inside container `sub_fields`, preventing duplicate fields when re-specifying a field already moved into a section
24
+ - `DashboardConfig` now raises `ArgumentError` at initialisation if `name` or `model` is missing from the YAML hash
25
+ - `params.permit!` in `index.html.erb` replaced with explicit param slice
26
+ - Edit page no longer renders an empty `class=""` attribute when no sidebar is present
27
+ - Removed unused `sidebar_links`, `custom_actions`, and `excluded_columns` attributes from `ResourceConfig`
28
+
29
+ ## [0.1.12] — 2026-05-25
30
+
31
+ ### Added
32
+
33
+ - `c.row :field1, :field2` groups fields horizontally in a Pico CSS grid on the edit page
34
+ - `c.section "Label" [, collapsed: true]` wraps fields in a native `<details>`/`<summary>` collapsible block (no JavaScript required); rows and individual fields can be nested inside sections
35
+ - `c.field` called inside a `section` block moves an existing auto-discovered field into the section rather than leaving it at the top level
36
+
10
37
  ## [0.1.11] — 2026-05-25
11
38
 
12
39
  ### Fixed
@@ -22,7 +22,8 @@ module Backstage
22
22
  if params[:sort].present? && valid_columns.include?(params[:sort])
23
23
  @sort = params[:sort]
24
24
  @dir = (params[:dir] == "desc") ? "desc" : "asc"
25
- scope = scope.order("#{@sort} #{@dir}")
25
+ arel_col = @resource_config.model_class.arel_table[@sort]
26
+ scope = scope.order((@dir == "desc") ? arel_col.desc : arel_col.asc)
26
27
  end
27
28
 
28
29
  @total_pages = [(scope.count.to_f / per_page).ceil, 1].max
@@ -78,17 +79,32 @@ module Backstage
78
79
  end
79
80
 
80
81
  def respond_with_success(message)
82
+ safe_message = ERB::Util.html_escape(message)
81
83
  render html: %(<turbo-stream action="prepend" target="flash">
82
- <template><p class="notice">#{message}</p></template>
84
+ <template><p class="notice">#{safe_message}</p></template>
83
85
  </turbo-stream>).html_safe,
84
86
  content_type: "text/vnd.turbo-stream.html"
85
87
  end
86
88
 
87
89
  def record_params
88
- permitted = @resource_config.edit_fields.reject(&:readonly?).map do |field|
89
- field.has_many? ? {field.name => []} : field.name
90
+ params
91
+ .require(@resource_config.model_class.model_name.param_key)
92
+ .permit(permitted_field_names(@resource_config.edit_fields))
93
+ end
94
+
95
+ def permitted_field_names(fields)
96
+ fields.reject(&:readonly?).flat_map do |field|
97
+ if field.container?
98
+ permitted_field_names(field.sub_fields)
99
+ elsif field.has_many?
100
+ [{field.name => []}]
101
+ elsif field.nested?
102
+ writable = field.nested_fields - field.nested_readonly_fields
103
+ [{"#{field.name}_attributes": [:id, *writable]}]
104
+ else
105
+ [field.name]
106
+ end
90
107
  end
91
- params.require(@resource_config.model_class.model_name.param_key).permit(permitted)
92
108
  end
93
109
  end
94
110
  end
@@ -0,0 +1 @@
1
+ <%= f.number_field field.name, step: "any", readonly: field.readonly? %>
@@ -0,0 +1,30 @@
1
+ <% records = record.public_send(field.name) %>
2
+ <% if records.any? %>
3
+ <table>
4
+ <thead>
5
+ <tr>
6
+ <% field.nested_fields.each do |col| %>
7
+ <th><%= col.to_s.humanize %></th>
8
+ <% end %>
9
+ </tr>
10
+ </thead>
11
+ <tbody>
12
+ <%= f.fields_for field.name do |nf| %>
13
+ <%= nf.hidden_field :id %>
14
+ <tr>
15
+ <% field.nested_fields.each do |col| %>
16
+ <td>
17
+ <% if field.nested_readonly_fields.include?(col) %>
18
+ <%= nf.object.public_send(col) %>
19
+ <% else %>
20
+ <%= nf.text_field col %>
21
+ <% end %>
22
+ </td>
23
+ <% end %>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ <% else %>
29
+ <em>None</em>
30
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <div class="grid">
2
+ <% field.sub_fields.each do |sub| %>
3
+ <div>
4
+ <%= f.label sub.name %>
5
+ <%= render partial: sub.partial_path, locals: { f: f, field: sub, record: @record } %>
6
+ </div>
7
+ <% end %>
8
+ </div>
@@ -0,0 +1,13 @@
1
+ <details <%= "open" unless field.collapsed? %>>
2
+ <summary><strong><%= field.heading %></strong></summary>
3
+ <% field.sub_fields.each do |sub| %>
4
+ <% if sub.container? %>
5
+ <%= render partial: sub.partial_path, locals: { f: f, field: sub, record: @record } %>
6
+ <% else %>
7
+ <div>
8
+ <%= f.label sub.name %>
9
+ <%= render partial: sub.partial_path, locals: { f: f, field: sub, record: @record } %>
10
+ </div>
11
+ <% end %>
12
+ <% end %>
13
+ </details>
@@ -1,14 +1,18 @@
1
1
  <h1>Edit <%= @resource_config.model_class.model_name.human %></h1>
2
2
 
3
3
  <% sidebar = @resource_config.sidebar_config %>
4
- <div class="<%= sidebar&.links&.any? ? "edit-layout" : "" %>">
4
+ <div<% if sidebar&.links&.any? %> class="edit-layout"<% end %>>
5
5
  <div>
6
6
  <%= form_with model: @record, url: resource_path(resource: params[:resource], id: @record.id), method: :patch do |f| %>
7
7
  <% @resource_config.edit_fields.each do |field| %>
8
- <div>
9
- <%= f.label field.name %>
8
+ <% if field.container? %>
10
9
  <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
11
- </div>
10
+ <% else %>
11
+ <div>
12
+ <%= f.label field.name %>
13
+ <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
14
+ </div>
15
+ <% end %>
12
16
  <% end %>
13
17
  <%= f.submit "Save" %>
14
18
  <% end %>
@@ -6,10 +6,11 @@
6
6
  </form>
7
7
 
8
8
  <% @resource_config.index_fields.select(&:enum?).each do |field| %>
9
+ <% safe_params = params.permit(:q, :sort, :dir, :page, field.name) %>
9
10
  <nav class="enum-filters">
10
- <%= link_to "All", url_for(params.permit!.except(:status, :page)) %>
11
+ <%= link_to "All", url_for(safe_params.except(field.name.to_s, "page")) %>
11
12
  <% field.enum_values.each do |label, value| %>
12
- <%= link_to label, url_for(params.permit!.merge(field.name => value, page: nil)) %>
13
+ <%= link_to label, url_for(safe_params.merge(field.name => value, page: nil)) %>
13
14
  <% end %>
14
15
  </nav>
15
16
  <% end %>
@@ -2,10 +2,14 @@
2
2
 
3
3
  <%= form_with model: @record, url: resources_path(resource: params[:resource]), method: :post do |f| %>
4
4
  <% @resource_config.edit_fields.each do |field| %>
5
- <div>
6
- <%= f.label field.name %>
5
+ <% if field.container? %>
7
6
  <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
8
- </div>
7
+ <% else %>
8
+ <div>
9
+ <%= f.label field.name %>
10
+ <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
11
+ </div>
12
+ <% end %>
9
13
  <% end %>
10
14
  <%= f.submit "Create" %>
11
15
  <% end %>
@@ -6,8 +6,8 @@ module Backstage
6
6
  string: :string,
7
7
  text: :text,
8
8
  integer: :integer,
9
- decimal: :integer,
10
- float: :integer,
9
+ decimal: :decimal,
10
+ float: :decimal,
11
11
  boolean: :boolean,
12
12
  date: :date,
13
13
  datetime: :datetime
@@ -3,8 +3,8 @@ module Backstage
3
3
  attr_reader :name, :model_name, :scope
4
4
 
5
5
  def initialize(hash)
6
- @name = hash["name"]
7
- @model_name = hash["model"]
6
+ @name = hash["name"] or raise ArgumentError, "Dashboard config missing 'name'"
7
+ @model_name = hash["model"] or raise ArgumentError, "Dashboard config missing 'model'"
8
8
  @scope = hash["scope"] || {}
9
9
  end
10
10
 
@@ -32,6 +32,42 @@ module Backstage
32
32
  type == :has_many
33
33
  end
34
34
 
35
+ def nested?
36
+ type == :nested
37
+ end
38
+
39
+ def nested_fields
40
+ options[:nested_fields] || []
41
+ end
42
+
43
+ def nested_readonly_fields
44
+ options[:nested_readonly_fields] || []
45
+ end
46
+
47
+ def row?
48
+ type == :row
49
+ end
50
+
51
+ def section?
52
+ type == :section
53
+ end
54
+
55
+ def container?
56
+ row? || section?
57
+ end
58
+
59
+ def sub_fields
60
+ options[:sub_fields] || []
61
+ end
62
+
63
+ def heading
64
+ options[:heading]
65
+ end
66
+
67
+ def collapsed?
68
+ options.fetch(:collapsed, false)
69
+ end
70
+
35
71
  def association
36
72
  options[:association]
37
73
  end
@@ -1,7 +1,6 @@
1
1
  module Backstage
2
2
  class ResourceConfig
3
- attr_accessor :model_class, :index_fields, :edit_fields,
4
- :associations, :sidebar_links, :custom_actions, :excluded_columns
3
+ attr_accessor :model_class, :index_fields, :edit_fields, :associations
5
4
  attr_writer :display_column
6
5
 
7
6
  def initialize(model_class)
@@ -9,9 +8,6 @@ module Backstage
9
8
  @index_fields = []
10
9
  @edit_fields = []
11
10
  @associations = []
12
- @sidebar_links = []
13
- @custom_actions = []
14
- @excluded_columns = []
15
11
  end
16
12
 
17
13
  def display_column(value = nil)
@@ -56,6 +52,14 @@ module Backstage
56
52
  end
57
53
  end
58
54
 
55
+ def nested(name, fields:, readonly_fields: [])
56
+ field_obj = Field.new(name.to_sym, :nested,
57
+ nested_fields: Array(fields).map(&:to_sym),
58
+ nested_readonly_fields: Array(readonly_fields).map(&:to_sym))
59
+ @edit_fields.reject! { |f| f.name == field_obj.name }
60
+ (@current_target || @edit_fields) << field_obj
61
+ end
62
+
59
63
  def belongs_to(name, **opts)
60
64
  assoc = AssociationConfig.new(name, :belongs_to, opts)
61
65
  @associations << assoc
@@ -74,17 +78,47 @@ module Backstage
74
78
  if existing
75
79
  existing.options.merge!(opts)
76
80
  existing.instance_variable_set(:@type, type.to_sym) if type
81
+ if @current_target
82
+ @edit_fields.reject! { |f| f.name == sym }
83
+ @current_target << existing
84
+ end
77
85
  else
78
86
  new_field = Field.new(sym, type || :string, opts)
79
- @edit_fields << new_field
80
- @index_fields << new_field unless @index_fields_explicit
87
+ (@current_target || @edit_fields) << new_field
88
+ @index_fields << new_field unless @index_fields_explicit || @current_target
81
89
  end
82
90
  end
83
91
 
92
+ def row(*names)
93
+ syms = names.map(&:to_sym)
94
+ sub = syms.map { |n| find_field(n) || Field.new(n, :string) }
95
+ @edit_fields.reject! { |f| syms.include?(f.name) }
96
+ row_field = Field.new(:"row_#{names.first}", :row, sub_fields: sub)
97
+ (@current_target || @edit_fields) << row_field
98
+ end
99
+
100
+ def section(heading, **opts)
101
+ section_field = Field.new(:"section_#{heading.parameterize}", :section,
102
+ heading: heading, sub_fields: [], **opts)
103
+ @current_target = section_field.sub_fields
104
+ yield if block_given?
105
+ @edit_fields << section_field
106
+ ensure
107
+ @current_target = nil
108
+ end
109
+
84
110
  private
85
111
 
86
112
  def find_field(name)
87
- (@index_fields + @edit_fields).uniq.find { |f| f.name == name }
113
+ all_fields = @index_fields + @edit_fields
114
+ all_fields.uniq.each do |f|
115
+ return f if f.name == name
116
+ if f.container?
117
+ found = f.sub_fields.find { |sf| sf.name == name }
118
+ return found if found
119
+ end
120
+ end
121
+ nil
88
122
  end
89
123
 
90
124
  def find_or_build_field(name)
@@ -1,3 +1,3 @@
1
1
  module Backstage
2
- VERSION = "0.1.11"
2
+ VERSION = "0.1.13"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backstage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gareth James
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -47,10 +47,14 @@ files:
47
47
  - app/views/backstage/fields/_boolean.html.erb
48
48
  - app/views/backstage/fields/_date.html.erb
49
49
  - app/views/backstage/fields/_datetime.html.erb
50
+ - app/views/backstage/fields/_decimal.html.erb
50
51
  - app/views/backstage/fields/_enum.html.erb
51
52
  - app/views/backstage/fields/_has_many.html.erb
52
53
  - app/views/backstage/fields/_image_url.html.erb
53
54
  - app/views/backstage/fields/_integer.html.erb
55
+ - app/views/backstage/fields/_nested.html.erb
56
+ - app/views/backstage/fields/_row.html.erb
57
+ - app/views/backstage/fields/_section.html.erb
54
58
  - app/views/backstage/fields/_string.html.erb
55
59
  - app/views/backstage/fields/_text.html.erb
56
60
  - app/views/backstage/fields/_thumbnails.html.erb