backstage 0.1.12 → 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: e1dc01f215a31bcb5c84237cdfb95a7622ab7c8298e3b822eb476a9363e2f35c
4
- data.tar.gz: 2a83eb127cfe5da24e409bf9f1ef69019a4d11be122402b5ca712f66ddfd1b3d
3
+ metadata.gz: 9de1bd6823d300ceef1f45b44ff97b635b6497ed1e58df6d814ec5ce45efc56e
4
+ data.tar.gz: d2863d1096ece91857ab403a4fa874f015f4065b5d9922553eaccc09d2245061
5
5
  SHA512:
6
- metadata.gz: 46f7f8d377fa8c1fa57f3113b224df3428cdc99d21282ea1812a3e4d6b5f617c1f051adcc303217df1da8f4c2edfa1c800fa32dc3ae91b4f77aca0f3deb90a82
7
- data.tar.gz: 3600c7c9e27c410a887fa6e63458eee543eb2d23271ee24adb99d1a8785272c7e3625498a86ae6ee54f0d68da3b6a6ed826c858a764394dcaea0b30536195d81
6
+ metadata.gz: f2799b494c57a749cc0c29595121fda2a90bf5c99704138bf70597fe14b1480c96c49984528b29f95fee0b1714ce0376e351be31057ea67b0fd6fb261675cb5d
7
+ data.tar.gz: d95bbf7d40f238529b320ed0194f8c4fd6ecfc7799630e1274145423d869f1e36aeeb581a9abeb8a17761bc85464ca4618f705ad1e776ddf91f3cdb9bb1dcbef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ 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
+
10
29
  ## [0.1.12] — 2026-05-25
11
30
 
12
31
  ### Added
@@ -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,8 +79,9 @@ 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
@@ -96,6 +98,9 @@ module Backstage
96
98
  permitted_field_names(field.sub_fields)
97
99
  elsif field.has_many?
98
100
  [{field.name => []}]
101
+ elsif field.nested?
102
+ writable = field.nested_fields - field.nested_readonly_fields
103
+ [{"#{field.name}_attributes": [:id, *writable]}]
99
104
  else
100
105
  [field.name]
101
106
  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 %>
@@ -1,7 +1,7 @@
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| %>
@@ -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,18 @@ 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
+
35
47
  def row?
36
48
  type == :row
37
49
  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
@@ -98,14 +102,23 @@ module Backstage
98
102
  heading: heading, sub_fields: [], **opts)
99
103
  @current_target = section_field.sub_fields
100
104
  yield if block_given?
101
- @current_target = nil
102
105
  @edit_fields << section_field
106
+ ensure
107
+ @current_target = nil
103
108
  end
104
109
 
105
110
  private
106
111
 
107
112
  def find_field(name)
108
- (@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
109
122
  end
110
123
 
111
124
  def find_or_build_field(name)
@@ -1,3 +1,3 @@
1
1
  module Backstage
2
- VERSION = "0.1.12"
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.12
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,12 @@ 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
54
56
  - app/views/backstage/fields/_row.html.erb
55
57
  - app/views/backstage/fields/_section.html.erb
56
58
  - app/views/backstage/fields/_string.html.erb