backstage 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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/CONTRIBUTING.md +70 -0
  4. data/LICENSE +21 -0
  5. data/README.md +195 -0
  6. data/app/assets/stylesheets/backstage/backstage.css +5 -0
  7. data/app/controllers/backstage/actions_controller.rb +27 -0
  8. data/app/controllers/backstage/application_controller.rb +21 -0
  9. data/app/controllers/backstage/dashboards_controller.rb +15 -0
  10. data/app/controllers/backstage/home_controller.rb +14 -0
  11. data/app/controllers/backstage/resources_controller.rb +94 -0
  12. data/app/views/backstage/dashboards/show.html.erb +36 -0
  13. data/app/views/backstage/fields/_belongs_to.html.erb +7 -0
  14. data/app/views/backstage/fields/_boolean.html.erb +1 -0
  15. data/app/views/backstage/fields/_date.html.erb +1 -0
  16. data/app/views/backstage/fields/_datetime.html.erb +1 -0
  17. data/app/views/backstage/fields/_enum.html.erb +4 -0
  18. data/app/views/backstage/fields/_has_many.html.erb +19 -0
  19. data/app/views/backstage/fields/_image_url.html.erb +5 -0
  20. data/app/views/backstage/fields/_integer.html.erb +1 -0
  21. data/app/views/backstage/fields/_string.html.erb +1 -0
  22. data/app/views/backstage/fields/_text.html.erb +1 -0
  23. data/app/views/backstage/fields/_thumbnails.html.erb +13 -0
  24. data/app/views/backstage/home/index.html.erb +24 -0
  25. data/app/views/backstage/resources/edit.html.erb +18 -0
  26. data/app/views/backstage/resources/index.html.erb +54 -0
  27. data/app/views/backstage/resources/new.html.erb +13 -0
  28. data/app/views/layouts/backstage/backstage.html.erb +59 -0
  29. data/config/routes.rb +15 -0
  30. data/lib/backstage/association_config.rb +31 -0
  31. data/lib/backstage/auto_discovery.rb +58 -0
  32. data/lib/backstage/configuration.rb +31 -0
  33. data/lib/backstage/dashboard_config.rb +15 -0
  34. data/lib/backstage/engine.rb +11 -0
  35. data/lib/backstage/field.rb +39 -0
  36. data/lib/backstage/registry.rb +32 -0
  37. data/lib/backstage/resource_config.rb +91 -0
  38. data/lib/backstage/sidebar_config.rb +15 -0
  39. data/lib/backstage/version.rb +3 -0
  40. data/lib/backstage.rb +56 -0
  41. data/lib/generators/backstage/install/install_generator.rb +29 -0
  42. data/lib/generators/backstage/install/templates/SKILL.md +221 -0
  43. data/lib/generators/backstage/install/templates/backstage.yml +16 -0
  44. metadata +95 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6015a3159530a5a3a2319474610d382b0238c1662774f72f35133bf083278ac4
4
+ data.tar.gz: f3e53c0e0fdddc1544c2de746e94e667f817a7a396a35d0b21786a5af5dded2e
5
+ SHA512:
6
+ metadata.gz: 8238910100ea500d1b86152eb241d389c62419544a8d500fc077133531890890abb1ad27c4a631f45b1bdab227d84ffca9505f13c2858a626d121fe0ab3cc94b
7
+ data.tar.gz: 36b2780705264ec14a6cdaf5c6e9d0b8ca6fab5458f8cf355a88d1655af24f773903f050aac7216c6d7ac874f079fbc0dceb9b2260de134d4b938b04706f176d
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] — 2026-05-18
11
+
12
+ ### Added
13
+
14
+ - Mountable Rails 8 engine with a single dynamic `ResourcesController` handling all CRUD
15
+ - YAML-based model registration (`config/backstage.yml`) with auto-discovery of column types
16
+ - Per-resource Ruby DSL (`config/backstage/*.rb`) for field overrides, associations, sidebars, and custom actions
17
+ - Field types: `string`, `integer`, `text`, `boolean`, `date`, `datetime`, `enum`, `belongs_to`, `has_many`, `thumbnails`, `image_url`
18
+ - Searchable, sortable, paginated index tables with enum filter tabs
19
+ - Named dashboards with custom model scopes
20
+ - Sidebar links with static URLs or dynamic proc-based URL generation
21
+ - Custom action routing: POST `/admin/:resource/:id/:action_name` dispatches to host-app controller subclass
22
+ - Turbo Stream responses for destroy and custom actions (`respond_with_row_removed`, `respond_with_success`)
23
+ - Confirm dialog for destructive delete actions (vanilla JS, no Stimulus dependency)
24
+ - Searchable checkbox multi-select for `has_many` associations (vanilla JS)
25
+ - Pico CSS vendored for zero-config styling
26
+ - `backstage:install` generator to create config template and mount the engine
27
+ - 139 tests (unit, integration, system) against Rails 8 + SQLite3
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,70 @@
1
+ # Contributing to Backstage
2
+
3
+ ## Development setup
4
+
5
+ ```bash
6
+ git clone https://github.com/your-org/backstage
7
+ cd backstage
8
+ bundle install
9
+ ```
10
+
11
+ The dummy app at `test/dummy/` is a minimal Rails 8 app used for all tests. It is already wired up — no additional setup is needed.
12
+
13
+ ## Running tests
14
+
15
+ ```bash
16
+ bundle exec rake test # all tests
17
+ bundle exec ruby -I test test/path/to/test_file.rb # single file
18
+ bundle exec ruby -I test test/path/to/test_file.rb -n test_name # single test
19
+ bundle exec rake test:system # system tests only
20
+ ```
21
+
22
+ Tests use SQLite3 in-memory (unit/integration) and headless Chrome (system). Chrome must be installed for system tests.
23
+
24
+ Linting:
25
+
26
+ ```bash
27
+ standardrb # StandardRB (not RuboCop)
28
+ ```
29
+
30
+ ## Adding a field type
31
+
32
+ 1. Add a partial at `app/views/backstage/fields/_your_type.html.erb`. The partial receives `f` (form builder), `field` (a `Backstage::Field` instance), and `record` (the ActiveRecord object).
33
+ 2. If the type should be auto-detected from a column type, add the mapping in `Backstage::AutoDiscovery#column_type`.
34
+ 3. Add a unit test in `test/unit/auto_discovery_test.rb` (if auto-detected) and an integration test for the rendered output.
35
+
36
+ ## Adding a DSL method
37
+
38
+ DSL methods live in `lib/backstage/resource_config.rb`. Each method mutates the config object; the public API section in `README.md` documents which methods are considered stable.
39
+
40
+ 1. Add the method to `ResourceConfig`.
41
+ 2. Write a unit test in `test/unit/resource_config_test.rb`.
42
+ 3. If the method affects rendering, add an integration test verifying the generated HTML.
43
+ 4. Document the method in `README.md` under "Per-resource DSL".
44
+
45
+ ## Code style
46
+
47
+ - StandardRB (run `standardrb --fix` to auto-correct)
48
+ - No comments unless the WHY is non-obvious
49
+ - No `permit!` — always enumerate permitted params explicitly
50
+ - `YAML.safe_load` — never `YAML.load`
51
+
52
+ ## Pull requests
53
+
54
+ - One feature or fix per PR
55
+ - All tests must pass (`bundle exec rake test`)
56
+ - Update `CHANGELOG.md` under `## [Unreleased]`
57
+ - If the PR changes the public API, update `README.md`
58
+
59
+ ## Releasing
60
+
61
+ Releases are made by pushing a version tag:
62
+
63
+ ```bash
64
+ # Bump lib/backstage/version.rb
65
+ git commit -am "Bump to v0.2.0"
66
+ git tag v0.2.0
67
+ git push origin main --tags
68
+ ```
69
+
70
+ The `publish` GitHub Actions workflow runs tests and pushes the gem to RubyGems automatically. You must have a `RUBYGEMS_API_KEY` secret set in the repository settings.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # Backstage
2
+
3
+ A lightweight, mountable admin interface for Rails 8. Drop it into any app with a single YAML file — no code generation, no Devise dependency, no Node pipeline.
4
+
5
+ ## Features
6
+
7
+ - Auto-discovers columns and builds index/edit pages from ActiveRecord reflection
8
+ - YAML model registration — adding a model takes one line
9
+ - Optional per-resource Ruby DSL for field overrides, associations, sidebars, and custom actions
10
+ - Named dashboards with custom scopes
11
+ - Searchable, sortable, paginated index tables
12
+ - Enum filter tabs, belongs-to dropdowns, has-many checkbox lists with search
13
+ - Turbo Stream responses for delete/custom actions (no full-page reload)
14
+ - Pico CSS vendored — zero asset pipeline setup required
15
+
16
+ ## Installation
17
+
18
+ Add to your Gemfile:
19
+
20
+ ```ruby
21
+ gem "backstage"
22
+ ```
23
+
24
+ Run the installer:
25
+
26
+ ```bash
27
+ bundle install
28
+ bin/rails generate backstage:install
29
+ ```
30
+
31
+ The generator creates `config/backstage.yml` and mounts the engine in `config/routes.rb`:
32
+
33
+ ```ruby
34
+ mount Backstage::Engine, at: "/admin"
35
+ ```
36
+
37
+ ## Authentication
38
+
39
+ Backstage does not handle authentication itself. It calls a method on `current_user` to decide whether to allow access. You must define `current_user` in your application controller and ensure it is accessible from Backstage's controllers.
40
+
41
+ Add to `config/initializers/backstage.rb` (or any initializer):
42
+
43
+ ```ruby
44
+ Rails.application.config.to_prepare do
45
+ Backstage::ApplicationController.class_eval do
46
+ def current_user
47
+ # return the current user object from your auth system
48
+ # e.g. User.find_by(id: session[:user_id])
49
+ end
50
+ end
51
+ end
52
+ ```
53
+
54
+ Configure the admin check in `config/backstage.yml`:
55
+
56
+ ```yaml
57
+ admin_user_method: is_admin? # method called on current_user (default: is_admin?)
58
+ redirect_on_failure: /login # where to redirect non-admins (default: /)
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ ### `config/backstage.yml`
64
+
65
+ ```yaml
66
+ # Models to manage (required)
67
+ models:
68
+ - Article
69
+ - User
70
+ - Tag
71
+
72
+ # Method called on current_user to check admin access
73
+ admin_user_method: is_admin?
74
+
75
+ # Where to redirect non-admin users
76
+ redirect_on_failure: /
77
+
78
+ # Records per page on index
79
+ per_page: 25
80
+
81
+ # Named dashboards
82
+ dashboards:
83
+ - name: "Recent Drafts"
84
+ model: Article
85
+ scope: draft
86
+ ```
87
+
88
+ Dashboards reference a named scope on the model. The scope must exist on the model class.
89
+
90
+ ### Per-resource DSL (`config/backstage/*.rb`)
91
+
92
+ Create a file named after the model (e.g. `config/backstage/article.rb`):
93
+
94
+ ```ruby
95
+ Backstage.resource(:Article) do |c|
96
+ # Control which columns appear on the index table
97
+ c.fields :title, :status, :published_at
98
+
99
+ # Remove columns from both index and edit
100
+ c.exclude :legacy_column
101
+
102
+ # Override a field's display type
103
+ c.field :body, as: :text
104
+ c.field :cover_image_url, as: :image_url
105
+
106
+ # Set the column used as the display name in belongs-to dropdowns
107
+ c.display_column :title
108
+
109
+ # Belongs-to association (replaces the foreign key field with a dropdown)
110
+ c.belongs_to :author, display_column: :name, class_name: "User"
111
+
112
+ # Has-many checkbox list with search
113
+ c.has_many :tags, display_column: :name
114
+
115
+ # Has-many as thumbnail grid (read-only)
116
+ c.has_many :images, as: :thumbnails, image_col: :url
117
+
118
+ # Sidebar links (appear next to the edit form)
119
+ c.sidebar do |s|
120
+ s.link "View on site", ->(record) { "/posts/#{record.id}" }
121
+ s.link "All articles", "/admin/articles"
122
+ end
123
+ end
124
+ ```
125
+
126
+ ### Field types
127
+
128
+ | Type | Auto-detected from | Notes |
129
+ |---|---|---|
130
+ | `:string` | `string`, `varchar` columns | Text input |
131
+ | `:integer` | `integer` columns | Number input |
132
+ | `:text` | `text` columns | Textarea |
133
+ | `:boolean` | `boolean` columns | Checkbox |
134
+ | `:date` | `date` columns | Date input |
135
+ | `:datetime` | `datetime` columns | Datetime-local input |
136
+ | `:enum` | `enum` columns | Select with filter tabs on index |
137
+ | `:belongs_to` | Set via DSL | Dropdown of associated records |
138
+ | `:has_many` | Set via DSL | Searchable checkbox list |
139
+ | `:thumbnails` | Set via DSL (`as: :thumbnails`) | Read-only image grid |
140
+ | `:image_url` | Set via DSL | Inline `<img>` rendered from a URL string |
141
+
142
+ ### Custom field partials
143
+
144
+ Override any field type for a specific resource by pointing to your own partial:
145
+
146
+ ```ruby
147
+ c.field :status, partial: "my_app/fields/status_badge"
148
+ ```
149
+
150
+ Or place a partial at `app/views/backstage/fields/_my_type.html.erb` to override for all resources.
151
+
152
+ ## Custom Actions
153
+
154
+ For actions beyond standard CRUD, create a controller that inherits from `Backstage::ResourcesController`:
155
+
156
+ ```ruby
157
+ # app/controllers/backstage/articles_controller.rb
158
+ class Backstage::ArticlesController < Backstage::ResourcesController
159
+ def publish
160
+ @record.update!(status: :published)
161
+ respond_with_success("Article published")
162
+ end
163
+ end
164
+ ```
165
+
166
+ Add a button to the edit form using a custom view override or sidebar link, and post to the action route:
167
+
168
+ ```erb
169
+ <%= button_to "Publish", admin_action_path(resource: "articles", id: @record.id, action_name: "publish"), method: :post %>
170
+ ```
171
+
172
+ The `respond_with_success` and `respond_with_row_removed` helpers render Turbo Stream responses that update the page without a full reload.
173
+
174
+ ## Bookmarklet
175
+
176
+ Add a bookmarklet to your browser to jump from any record's show page directly to its Backstage edit page. Save this as a bookmark with the URL field set to:
177
+
178
+ ```javascript
179
+ javascript:(function(){var m=location.pathname.match(/\/(\w+)\/(\d+)/);if(m){location.href='/admin/'+m[1]+'/'+m[2]+'/edit';}})();
180
+ ```
181
+
182
+ This matches URL patterns like `/articles/42` and navigates to `/admin/articles/42/edit`. Adjust the path prefix if your engine is not mounted at `/admin`.
183
+
184
+ ## Versioning
185
+
186
+ The public API consists of:
187
+ - `config/backstage.yml` keys: `models`, `admin_user_method`, `redirect_on_failure`, `per_page`, `dashboards`
188
+ - Ruby DSL methods on `ResourceConfig`: `fields`, `exclude`, `field`, `display_column`, `has_many`, `belongs_to`, `sidebar`
189
+ - Turbo Stream helper methods on `ResourcesController`: `respond_with_success`, `respond_with_row_removed`
190
+
191
+ Changes to this surface follow semantic versioning. Internal classes (`AutoDiscovery`, `Registry`, `Field`, `AssociationConfig`) are not part of the public API and may change between minor versions.
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,5 @@
1
+ /* Backstage admin layout */
2
+ body { display: grid; grid-template-columns: 200px 1fr; grid-template-rows: auto 1fr; min-height: 100vh; }
3
+ header { grid-column: 1 / -1; }
4
+ nav { padding: 1rem; }
5
+ main { padding: 1rem; }
@@ -0,0 +1,27 @@
1
+ module Backstage
2
+ class ActionsController < ApplicationController
3
+ def create
4
+ begin
5
+ Backstage.registry.resource_for(params[:resource].classify)
6
+ rescue KeyError
7
+ return render plain: "Not Found", status: :not_found
8
+ end
9
+
10
+ resource_name = params[:resource].classify.pluralize
11
+ controller_class = "Backstage::#{resource_name}Controller".safe_constantize ||
12
+ Backstage::ResourcesController
13
+ action_name = params[:action_name]
14
+
15
+ unless controller_class.method_defined?(action_name)
16
+ raise NotImplementedError,
17
+ "Backstage: no action '#{action_name}' on #{controller_class}. " \
18
+ "Define it in a Backstage::#{resource_name}Controller subclass."
19
+ end
20
+
21
+ status, headers, body = controller_class.action(action_name).call(request.env)
22
+ self.status = status
23
+ self.headers.merge!(headers)
24
+ self.response_body = body
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ module Backstage
2
+ class ApplicationController < ActionController::Base
3
+ layout "backstage/backstage"
4
+ helper_method :nav_resources
5
+
6
+ before_action :verify_admin!
7
+
8
+ private
9
+
10
+ def verify_admin!
11
+ method = Backstage.configuration.admin_user_method
12
+ unless current_user&.public_send(method)
13
+ redirect_to Backstage.configuration.redirect_on_failure
14
+ end
15
+ end
16
+
17
+ def nav_resources
18
+ Backstage.registry.all_resources
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ module Backstage
2
+ class DashboardsController < ApplicationController
3
+ def show
4
+ @dashboard = Backstage.registry.dashboard_for(params[:name])
5
+ @resource_config = @dashboard.resource_config
6
+ per_page = Backstage.configuration.per_page
7
+ @page = (params[:page] || 1).to_i
8
+ scope = @resource_config.model_class.where(@dashboard.scope)
9
+ @total_pages = [(scope.count.to_f / per_page).ceil, 1].max
10
+ @records = scope.offset((@page - 1) * per_page).limit(per_page)
11
+ rescue KeyError
12
+ render plain: "Not Found", status: :not_found
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Backstage
2
+ class HomeController < ApplicationController
3
+ def index
4
+ @resources = Backstage.registry.all_resources.map do |config|
5
+ {config: config, count: config.model_class.count}
6
+ end
7
+ @dashboards = Backstage.registry.all_dashboards.map do |dash|
8
+ rc = dash.resource_config
9
+ count = rc.model_class.where(dash.scope).count
10
+ {dashboard: dash, count: count}
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,94 @@
1
+ module Backstage
2
+ class ResourcesController < ApplicationController
3
+ before_action :find_resource_config
4
+ before_action :find_record, only: %i[edit update destroy]
5
+
6
+ def index
7
+ @page = (params[:page] || 1).to_i
8
+ per_page = Backstage.configuration.per_page
9
+ scope = @resource_config.model_class.all
10
+
11
+ if params[:q].present?
12
+ col = @resource_config.display_column
13
+ scope = scope.where("#{col} LIKE ?", "%#{params[:q]}%")
14
+ end
15
+
16
+ @resource_config.index_fields.select(&:enum?).each do |field|
17
+ next unless params[field.name].present?
18
+ scope = scope.where(field.name => params[field.name])
19
+ end
20
+
21
+ valid_columns = @resource_config.index_fields.map { |f| f.name.to_s }
22
+ if params[:sort].present? && valid_columns.include?(params[:sort])
23
+ @sort = params[:sort]
24
+ @dir = (params[:dir] == "desc") ? "desc" : "asc"
25
+ scope = scope.order("#{@sort} #{@dir}")
26
+ end
27
+
28
+ @total_pages = [(scope.count.to_f / per_page).ceil, 1].max
29
+ @records = scope.offset((@page - 1) * per_page).limit(per_page)
30
+ end
31
+
32
+ def new
33
+ @record = @resource_config.model_class.new
34
+ end
35
+
36
+ def edit
37
+ end
38
+
39
+ def create
40
+ @record = @resource_config.model_class.new(record_params)
41
+ if @record.save
42
+ redirect_to edit_resource_path(resource: params[:resource], id: @record.id)
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ def update
49
+ if @record.update(record_params)
50
+ redirect_to resources_path(resource: params[:resource])
51
+ else
52
+ render :edit, status: :unprocessable_entity
53
+ end
54
+ end
55
+
56
+ def destroy
57
+ @record.destroy
58
+ redirect_to resources_path(resource: params[:resource])
59
+ end
60
+
61
+ private
62
+
63
+ def find_resource_config
64
+ @resource_config = Backstage.registry.resource_for(params[:resource].classify)
65
+ rescue KeyError
66
+ render plain: "Not Found", status: :not_found
67
+ end
68
+
69
+ def find_record
70
+ @record = @resource_config.model_class.find_by(id: params[:id])
71
+ render plain: "Not Found", status: :not_found unless @record
72
+ end
73
+
74
+ def respond_with_row_removed
75
+ row_id = "#{@resource_config.model_name_param}_#{@record.id}_row"
76
+ render html: %(<turbo-stream action="remove" target="#{row_id}"></turbo-stream>).html_safe,
77
+ content_type: "text/vnd.turbo-stream.html"
78
+ end
79
+
80
+ def respond_with_success(message)
81
+ render html: %(<turbo-stream action="prepend" target="flash">
82
+ <template><p class="notice">#{message}</p></template>
83
+ </turbo-stream>).html_safe,
84
+ content_type: "text/vnd.turbo-stream.html"
85
+ end
86
+
87
+ def record_params
88
+ permitted = @resource_config.edit_fields.reject(&:readonly?).map do |field|
89
+ field.has_many? ? {field.name => []} : field.name
90
+ end
91
+ params.require(params[:resource].singularize).permit(permitted)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,36 @@
1
+ <h1><%= @dashboard.name.to_s.humanize %></h1>
2
+
3
+ <table>
4
+ <thead>
5
+ <tr>
6
+ <% @resource_config.index_fields.each do |field| %>
7
+ <th><%= field.name.to_s.humanize %></th>
8
+ <% end %>
9
+ <th>Actions</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <% @records.each do |record| %>
14
+ <tr id="<%= @resource_config.model_name_param %>_<%= record.id %>_row">
15
+ <% @resource_config.index_fields.each do |field| %>
16
+ <td>
17
+ <% if field.enum? %>
18
+ <%= record.public_send(field.name).to_s.humanize %>
19
+ <% else %>
20
+ <%= record.public_send(field.name) %>
21
+ <% end %>
22
+ </td>
23
+ <% end %>
24
+ <td><%= link_to "Edit", edit_resource_path(resource: @resource_config.model_name_param, id: record.id) %></td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+
30
+ <% if @total_pages > 1 %>
31
+ <nav>
32
+ <% (1..@total_pages).each do |p| %>
33
+ <%= link_to p, url_for(page: p), class: (p == @page ? "current" : nil) %>
34
+ <% end %>
35
+ </nav>
36
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <% assoc = field.association
2
+ records = assoc.associated_class.all
3
+ col = assoc.display_column %>
4
+ <%= f.select field.name,
5
+ records.map { |r| [r.public_send(col), r.id] },
6
+ { include_blank: true },
7
+ disabled: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.check_box field.name, disabled: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.date_field field.name, readonly: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.datetime_local_field field.name, readonly: field.readonly? %>
@@ -0,0 +1,4 @@
1
+ <%= f.select field.name,
2
+ field.enum_values.map { |label, value| [label, value] },
3
+ { include_blank: true },
4
+ disabled: field.readonly? %>
@@ -0,0 +1,19 @@
1
+ <% assoc = field.association
2
+ records = assoc.associated_class.all
3
+ col = assoc.display_column
4
+ selected = record.public_send(assoc.name).map(&:id) %>
5
+ <div data-multi-select>
6
+ <input type="search"
7
+ placeholder="Search <%= assoc.name.to_s.humanize.downcase %>…"
8
+ data-multi-select-search>
9
+ <% records.each do |r| %>
10
+ <label data-multi-select-item>
11
+ <%= check_box_tag "#{f.object_name}[#{field.name}][]",
12
+ r.id,
13
+ selected.include?(r.id),
14
+ disabled: field.readonly? %>
15
+ <%= r.public_send(col) %>
16
+ </label>
17
+ <% end %>
18
+ <input type="hidden" name="<%= "#{f.object_name}[#{field.name}][]" %>" value="">
19
+ </div>
@@ -0,0 +1,5 @@
1
+ <% url = record.public_send(field.name) %>
2
+ <% if url.present? %>
3
+ <img src="<%= url %>" alt="" style="max-width: 200px; display: block; margin-bottom: 0.5rem;">
4
+ <% end %>
5
+ <%= f.text_field field.name, readonly: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.number_field field.name, readonly: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.text_field field.name, readonly: field.readonly? %>
@@ -0,0 +1 @@
1
+ <%= f.text_area field.name, readonly: field.readonly? %>
@@ -0,0 +1,13 @@
1
+ <% assoc = field.association
2
+ records = record.public_send(assoc.name)
3
+ img_col = assoc.image_col
4
+ resource_param = assoc.associated_class.model_name.plural %>
5
+ <div class="backstage-thumbnails">
6
+ <% records.each do |r| %>
7
+ <figure>
8
+ <a href="<%= edit_resource_path(resource: resource_param, id: r.id) %>">
9
+ <img src="<%= r.public_send(img_col) %>" alt="">
10
+ </a>
11
+ </figure>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,24 @@
1
+ <h1>Backstage</h1>
2
+
3
+ <ul>
4
+ <% @resources.each do |r| %>
5
+ <li>
6
+ <%= link_to r[:config].model_class.model_name.human.pluralize,
7
+ resources_path(resource: r[:config].model_name_param) %>
8
+ (<%= r[:count] %>)
9
+ </li>
10
+ <% end %>
11
+ </ul>
12
+
13
+ <% if @dashboards.any? %>
14
+ <h2>Dashboards</h2>
15
+ <ul>
16
+ <% @dashboards.each do |d| %>
17
+ <li>
18
+ <%= link_to d[:dashboard].name.to_s.humanize,
19
+ dashboard_path(name: d[:dashboard].name) %>
20
+ (<%= d[:count] %>)
21
+ </li>
22
+ <% end %>
23
+ </ul>
24
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <h1>Edit <%= @resource_config.model_class.model_name.human %></h1>
2
+
3
+ <%= form_with model: @record, url: resource_path(resource: params[:resource], id: @record.id), method: :patch do |f| %>
4
+ <% @resource_config.edit_fields.each do |field| %>
5
+ <div>
6
+ <%= f.label field.name %>
7
+ <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
8
+ </div>
9
+ <% end %>
10
+ <%= f.submit "Save" %>
11
+ <% end %>
12
+
13
+ <%= link_to "Cancel", resources_path(resource: params[:resource]) %>
14
+
15
+ <%= button_to "Delete",
16
+ resource_path(resource: params[:resource], id: @record.id),
17
+ method: :delete,
18
+ data: { confirm_message: "Delete this #{@resource_config.model_class.model_name.human}?" } %>