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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +70 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/app/assets/stylesheets/backstage/backstage.css +5 -0
- data/app/controllers/backstage/actions_controller.rb +27 -0
- data/app/controllers/backstage/application_controller.rb +21 -0
- data/app/controllers/backstage/dashboards_controller.rb +15 -0
- data/app/controllers/backstage/home_controller.rb +14 -0
- data/app/controllers/backstage/resources_controller.rb +94 -0
- data/app/views/backstage/dashboards/show.html.erb +36 -0
- data/app/views/backstage/fields/_belongs_to.html.erb +7 -0
- data/app/views/backstage/fields/_boolean.html.erb +1 -0
- data/app/views/backstage/fields/_date.html.erb +1 -0
- data/app/views/backstage/fields/_datetime.html.erb +1 -0
- data/app/views/backstage/fields/_enum.html.erb +4 -0
- data/app/views/backstage/fields/_has_many.html.erb +19 -0
- data/app/views/backstage/fields/_image_url.html.erb +5 -0
- data/app/views/backstage/fields/_integer.html.erb +1 -0
- data/app/views/backstage/fields/_string.html.erb +1 -0
- data/app/views/backstage/fields/_text.html.erb +1 -0
- data/app/views/backstage/fields/_thumbnails.html.erb +13 -0
- data/app/views/backstage/home/index.html.erb +24 -0
- data/app/views/backstage/resources/edit.html.erb +18 -0
- data/app/views/backstage/resources/index.html.erb +54 -0
- data/app/views/backstage/resources/new.html.erb +13 -0
- data/app/views/layouts/backstage/backstage.html.erb +59 -0
- data/config/routes.rb +15 -0
- data/lib/backstage/association_config.rb +31 -0
- data/lib/backstage/auto_discovery.rb +58 -0
- data/lib/backstage/configuration.rb +31 -0
- data/lib/backstage/dashboard_config.rb +15 -0
- data/lib/backstage/engine.rb +11 -0
- data/lib/backstage/field.rb +39 -0
- data/lib/backstage/registry.rb +32 -0
- data/lib/backstage/resource_config.rb +91 -0
- data/lib/backstage/sidebar_config.rb +15 -0
- data/lib/backstage/version.rb +3 -0
- data/lib/backstage.rb +56 -0
- data/lib/generators/backstage/install/install_generator.rb +29 -0
- data/lib/generators/backstage/install/templates/SKILL.md +221 -0
- data/lib/generators/backstage/install/templates/backstage.yml +16 -0
- 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,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 @@
|
|
|
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,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 @@
|
|
|
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}?" } %>
|