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 +4 -4
- data/CHANGELOG.md +27 -0
- data/app/controllers/backstage/resources_controller.rb +21 -5
- data/app/views/backstage/fields/_decimal.html.erb +1 -0
- data/app/views/backstage/fields/_nested.html.erb +30 -0
- data/app/views/backstage/fields/_row.html.erb +8 -0
- data/app/views/backstage/fields/_section.html.erb +13 -0
- data/app/views/backstage/resources/edit.html.erb +8 -4
- data/app/views/backstage/resources/index.html.erb +3 -2
- data/app/views/backstage/resources/new.html.erb +7 -3
- data/lib/backstage/auto_discovery.rb +2 -2
- data/lib/backstage/dashboard_config.rb +2 -2
- data/lib/backstage/field.rb +36 -0
- data/lib/backstage/resource_config.rb +42 -8
- data/lib/backstage/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9de1bd6823d300ceef1f45b44ff97b635b6497ed1e58df6d814ec5ce45efc56e
|
|
4
|
+
data.tar.gz: d2863d1096ece91857ab403a4fa874f015f4065b5d9922553eaccc09d2245061
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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">#{
|
|
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
|
-
|
|
89
|
-
|
|
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,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
|
|
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
|
-
|
|
9
|
-
<%= f.label field.name %>
|
|
8
|
+
<% if field.container? %>
|
|
10
9
|
<%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
|
|
11
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
6
|
-
<%= f.label field.name %>
|
|
5
|
+
<% if field.container? %>
|
|
7
6
|
<%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
|
|
8
|
-
|
|
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 %>
|
|
@@ -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
|
|
data/lib/backstage/field.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/backstage/version.rb
CHANGED
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.
|
|
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-
|
|
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
|