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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<h1><%= @resource_config.model_class.model_name.human.pluralize %></h1>
|
|
2
|
+
|
|
3
|
+
<form method="get" action="">
|
|
4
|
+
<input type="search" name="q" value="<%= params[:q] %>" placeholder="Search…">
|
|
5
|
+
<button type="submit">Search</button>
|
|
6
|
+
</form>
|
|
7
|
+
|
|
8
|
+
<% @resource_config.index_fields.select(&:enum?).each do |field| %>
|
|
9
|
+
<nav class="enum-filters">
|
|
10
|
+
<%= link_to "All", url_for(params.permit!.except(:status, :page)) %>
|
|
11
|
+
<% field.enum_values.each do |label, value| %>
|
|
12
|
+
<%= link_to label, url_for(params.permit!.merge(field.name => value, page: nil)) %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</nav>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<%= link_to "New #{@resource_config.model_class.model_name.human}", new_resource_path(resource: params[:resource]) %>
|
|
18
|
+
|
|
19
|
+
<table>
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<% @resource_config.index_fields.each do |field| %>
|
|
23
|
+
<% col = field.name.to_s
|
|
24
|
+
next_dir = (@sort == col && @dir == "asc") ? "desc" : "asc" %>
|
|
25
|
+
<th><%= link_to field.name.to_s.humanize, url_for(sort: col, dir: next_dir) %></th>
|
|
26
|
+
<% end %>
|
|
27
|
+
<th>Actions</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<% @records.each do |record| %>
|
|
32
|
+
<tr>
|
|
33
|
+
<% @resource_config.index_fields.each do |field| %>
|
|
34
|
+
<td>
|
|
35
|
+
<% if field.enum? %>
|
|
36
|
+
<%= record.public_send(field.name).to_s.humanize %>
|
|
37
|
+
<% else %>
|
|
38
|
+
<%= record.public_send(field.name) %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
41
|
+
<% end %>
|
|
42
|
+
<td><%= link_to "Edit", edit_resource_path(resource: params[:resource], id: record.id) %></td>
|
|
43
|
+
</tr>
|
|
44
|
+
<% end %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
|
|
48
|
+
<% if @total_pages > 1 %>
|
|
49
|
+
<nav>
|
|
50
|
+
<% (1..@total_pages).each do |p| %>
|
|
51
|
+
<%= link_to p, url_for(page: p), class: (p == @page ? "current" : nil) %>
|
|
52
|
+
<% end %>
|
|
53
|
+
</nav>
|
|
54
|
+
<% end %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<h1>New <%= @resource_config.model_class.model_name.human %></h1>
|
|
2
|
+
|
|
3
|
+
<%= form_with model: @record, url: resources_path(resource: params[:resource]), method: :post 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 "Create" %>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<%= link_to "Cancel", resources_path(resource: params[:resource]) %>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Backstage</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= stylesheet_link_tag "backstage/backstage", media: "all" %>
|
|
9
|
+
<script>
|
|
10
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
11
|
+
document.querySelectorAll("[data-confirm-message]").forEach(function(btn) {
|
|
12
|
+
btn.addEventListener("click", function(e) {
|
|
13
|
+
if (!window.confirm(btn.dataset.confirmMessage)) {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
document.querySelectorAll("[data-multi-select-search]").forEach(function(input) {
|
|
19
|
+
input.addEventListener("input", function() {
|
|
20
|
+
var q = input.value.toLowerCase()
|
|
21
|
+
input.closest("[data-multi-select]")
|
|
22
|
+
.querySelectorAll("[data-multi-select-item]")
|
|
23
|
+
.forEach(function(item) {
|
|
24
|
+
item.hidden = q.length > 0 && !item.textContent.toLowerCase().includes(q)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<header>
|
|
33
|
+
<%= link_to "Backstage", root_path %>
|
|
34
|
+
</header>
|
|
35
|
+
<nav>
|
|
36
|
+
<ul>
|
|
37
|
+
<% nav_resources.each do |config| %>
|
|
38
|
+
<li><%= link_to config.model_class.model_name.human.pluralize,
|
|
39
|
+
resources_path(resource: config.model_name_param) %></li>
|
|
40
|
+
<% end %>
|
|
41
|
+
</ul>
|
|
42
|
+
</nav>
|
|
43
|
+
<main>
|
|
44
|
+
<%= yield %>
|
|
45
|
+
</main>
|
|
46
|
+
<% sidebar = defined?(@resource_config) && @resource_config&.sidebar_config
|
|
47
|
+
record = defined?(@record) ? @record : nil %>
|
|
48
|
+
<% if sidebar&.links&.any? %>
|
|
49
|
+
<aside>
|
|
50
|
+
<ul>
|
|
51
|
+
<% sidebar.links.each do |link| %>
|
|
52
|
+
<% url = link.url_or_proc.respond_to?(:call) ? link.url_or_proc.call(record) : link.url_or_proc %>
|
|
53
|
+
<li><%= link_to link.label, url %></li>
|
|
54
|
+
<% end %>
|
|
55
|
+
</ul>
|
|
56
|
+
</aside>
|
|
57
|
+
<% end %>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Backstage::Engine.routes.draw do
|
|
2
|
+
root to: "home#index"
|
|
3
|
+
|
|
4
|
+
get "dashboards/:name", to: "dashboards#show", as: :dashboard
|
|
5
|
+
|
|
6
|
+
scope ":resource" do
|
|
7
|
+
get "/", to: "resources#index", as: :resources
|
|
8
|
+
get "/new", to: "resources#new", as: :new_resource
|
|
9
|
+
post "/", to: "resources#create"
|
|
10
|
+
get "/:id/edit", to: "resources#edit", as: :edit_resource
|
|
11
|
+
patch "/:id", to: "resources#update", as: :resource
|
|
12
|
+
delete "/:id", to: "resources#destroy"
|
|
13
|
+
post "/:id/:action_name", to: "actions#create", as: :resource_action
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class AssociationConfig
|
|
3
|
+
attr_reader :name, :kind, :options
|
|
4
|
+
|
|
5
|
+
def initialize(name, kind, options = {})
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@kind = kind.to_sym
|
|
8
|
+
@options = options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def display_column
|
|
12
|
+
options[:display_column] || :id
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def foreign_key
|
|
16
|
+
options[:foreign_key] || :"#{name}_id"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def class_name
|
|
20
|
+
options[:class_name] || name.to_s.classify
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def associated_class
|
|
24
|
+
class_name.constantize
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def image_col
|
|
28
|
+
options[:image_col] || :url
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class AutoDiscovery
|
|
3
|
+
SYSTEM_COLUMNS = %i[id created_at updated_at].freeze
|
|
4
|
+
|
|
5
|
+
COLUMN_TYPE_MAP = {
|
|
6
|
+
string: :string,
|
|
7
|
+
text: :text,
|
|
8
|
+
integer: :integer,
|
|
9
|
+
decimal: :integer,
|
|
10
|
+
float: :integer,
|
|
11
|
+
boolean: :boolean,
|
|
12
|
+
date: :date,
|
|
13
|
+
datetime: :datetime
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.build(model_class)
|
|
17
|
+
new(model_class).build
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(model_class)
|
|
21
|
+
@model_class = model_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build
|
|
25
|
+
config = ResourceConfig.new(@model_class)
|
|
26
|
+
config.display_column = detect_display_column
|
|
27
|
+
fields = column_fields + enum_fields
|
|
28
|
+
config.index_fields = fields
|
|
29
|
+
config.edit_fields = fields
|
|
30
|
+
config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def column_fields
|
|
36
|
+
@model_class.columns
|
|
37
|
+
.reject { |col| SYSTEM_COLUMNS.include?(col.name.to_sym) }
|
|
38
|
+
.reject { |col| enum_column_names.include?(col.name.to_sym) }
|
|
39
|
+
.map { |col| Field.new(col.name, COLUMN_TYPE_MAP.fetch(col.type, :string)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enum_fields
|
|
43
|
+
@model_class.defined_enums.map do |name, values|
|
|
44
|
+
enum_values = values.keys.map { |k| [k.humanize, k] }
|
|
45
|
+
Field.new(name, :enum, enum_values: enum_values)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def enum_column_names
|
|
50
|
+
@enum_column_names ||= @model_class.defined_enums.keys.map(&:to_sym)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def detect_display_column
|
|
54
|
+
column_names = @model_class.column_names.map(&:to_sym)
|
|
55
|
+
%i[name title email id].find { |col| column_names.include?(col) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Backstage
|
|
4
|
+
class ConfigurationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_reader :admin_user_method, :redirect_on_failure, :per_page,
|
|
8
|
+
:model_names, :dashboard_configs
|
|
9
|
+
|
|
10
|
+
def initialize(hash)
|
|
11
|
+
@admin_user_method = (hash["admin_user_method"] || "is_admin?").to_sym
|
|
12
|
+
@redirect_on_failure = hash["redirect_on_failure"] || "/"
|
|
13
|
+
@per_page = hash["per_page"] || 25
|
|
14
|
+
@model_names = hash["models"] || []
|
|
15
|
+
@dashboard_configs = hash["dashboards"] || []
|
|
16
|
+
|
|
17
|
+
validate!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate!
|
|
23
|
+
unless @model_names.is_a?(Array)
|
|
24
|
+
raise ConfigurationError, "config/backstage.yml: 'models' must be a list, got #{@model_names.inspect}"
|
|
25
|
+
end
|
|
26
|
+
unless @per_page.is_a?(Integer)
|
|
27
|
+
raise ConfigurationError, "config/backstage.yml: 'per_page' must be an integer, got #{@per_page.inspect}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class DashboardConfig
|
|
3
|
+
attr_reader :name, :model_name, :scope
|
|
4
|
+
|
|
5
|
+
def initialize(hash)
|
|
6
|
+
@name = hash["name"]
|
|
7
|
+
@model_name = hash["model"]
|
|
8
|
+
@scope = hash["scope"] || {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def resource_config
|
|
12
|
+
Backstage.registry.resource_for(model_name)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class Field
|
|
3
|
+
attr_reader :name, :type, :options
|
|
4
|
+
|
|
5
|
+
def initialize(name, type, options = {})
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@type = type.to_sym
|
|
8
|
+
@options = options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def partial_path
|
|
12
|
+
options[:partial] || "backstage/fields/#{type}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def readonly?
|
|
16
|
+
options.fetch(:readonly, false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enum?
|
|
20
|
+
type == :enum
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enum_values
|
|
24
|
+
options[:enum_values] || []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def belongs_to?
|
|
28
|
+
type == :belongs_to
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def has_many?
|
|
32
|
+
type == :has_many
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def association
|
|
36
|
+
options[:association]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class Registry
|
|
3
|
+
def initialize
|
|
4
|
+
@resources = {}
|
|
5
|
+
@dashboards = {}
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def register(model_name, resource_config)
|
|
9
|
+
@resources[model_name] = resource_config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resource_for(model_name)
|
|
13
|
+
@resources.fetch(model_name) { raise KeyError, "Backstage: no resource registered for '#{model_name}'" }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all_resources
|
|
17
|
+
@resources.values
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def register_dashboard(dashboard_config)
|
|
21
|
+
@dashboards[dashboard_config.name] = dashboard_config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dashboard_for(name)
|
|
25
|
+
@dashboards.fetch(name) { raise KeyError, "Backstage: no dashboard registered for '#{name}'" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def all_dashboards
|
|
29
|
+
@dashboards.values
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class ResourceConfig
|
|
3
|
+
attr_accessor :model_class, :index_fields, :edit_fields,
|
|
4
|
+
:associations, :sidebar_links, :custom_actions, :excluded_columns
|
|
5
|
+
attr_writer :display_column
|
|
6
|
+
|
|
7
|
+
def initialize(model_class)
|
|
8
|
+
@model_class = model_class
|
|
9
|
+
@index_fields = []
|
|
10
|
+
@edit_fields = []
|
|
11
|
+
@associations = []
|
|
12
|
+
@sidebar_links = []
|
|
13
|
+
@custom_actions = []
|
|
14
|
+
@excluded_columns = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def display_column(value = nil)
|
|
18
|
+
value ? @display_column = value.to_sym : @display_column
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def model_name_param
|
|
22
|
+
model_class.model_name.plural
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sidebar(&block)
|
|
26
|
+
@sidebar_config ||= SidebarConfig.new
|
|
27
|
+
block&.call(@sidebar_config)
|
|
28
|
+
@sidebar_config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :sidebar_config
|
|
32
|
+
|
|
33
|
+
def fields(*names)
|
|
34
|
+
@index_fields = names.map { |n| find_or_build_field(n) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def exclude(*names)
|
|
38
|
+
names.map!(&:to_sym)
|
|
39
|
+
@index_fields = @index_fields.reject { |f| names.include?(f.name) }
|
|
40
|
+
@edit_fields = @edit_fields.reject { |f| names.include?(f.name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def has_many(name, **opts)
|
|
44
|
+
display_as = opts.delete(:as) || :has_many
|
|
45
|
+
assoc = AssociationConfig.new(name, :has_many, opts)
|
|
46
|
+
@associations << assoc
|
|
47
|
+
if display_as == :thumbnails
|
|
48
|
+
field_obj = Field.new(name.to_sym, :thumbnails, association: assoc, readonly: true)
|
|
49
|
+
@edit_fields.reject! { |f| f.name == field_obj.name }
|
|
50
|
+
@edit_fields << field_obj
|
|
51
|
+
else
|
|
52
|
+
ids_field = Field.new(:"#{name.to_s.singularize}_ids", :has_many, association: assoc)
|
|
53
|
+
@edit_fields.reject! { |f| f.name == ids_field.name }
|
|
54
|
+
@edit_fields << ids_field
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def belongs_to(name, **opts)
|
|
59
|
+
assoc = AssociationConfig.new(name, :belongs_to, opts)
|
|
60
|
+
@associations << assoc
|
|
61
|
+
fk_field = Field.new(assoc.foreign_key, :belongs_to, association: assoc)
|
|
62
|
+
@edit_fields.reject! { |f| f.name == fk_field.name }
|
|
63
|
+
@index_fields.reject! { |f| f.name == fk_field.name }
|
|
64
|
+
@edit_fields << fk_field
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def field(name, **opts)
|
|
68
|
+
type = opts.delete(:as) || opts.delete(:type)
|
|
69
|
+
sym = name.to_sym
|
|
70
|
+
existing = find_field(sym)
|
|
71
|
+
if existing
|
|
72
|
+
existing.options.merge!(opts)
|
|
73
|
+
existing.instance_variable_set(:@type, type.to_sym) if type
|
|
74
|
+
else
|
|
75
|
+
new_field = Field.new(sym, type || :string, opts)
|
|
76
|
+
@edit_fields << new_field
|
|
77
|
+
@index_fields << new_field
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def find_field(name)
|
|
84
|
+
(@index_fields + @edit_fields).uniq.find { |f| f.name == name }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find_or_build_field(name)
|
|
88
|
+
find_field(name.to_sym) || Field.new(name.to_sym, :string)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Backstage
|
|
2
|
+
class SidebarConfig
|
|
3
|
+
SidebarLink = Struct.new(:label, :url_or_proc)
|
|
4
|
+
|
|
5
|
+
attr_reader :links
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@links = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def link(label, url_or_proc)
|
|
12
|
+
@links << SidebarLink.new(label, url_or_proc)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/backstage.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "backstage/version"
|
|
2
|
+
require "backstage/configuration"
|
|
3
|
+
require "backstage/field"
|
|
4
|
+
require "backstage/association_config"
|
|
5
|
+
require "backstage/resource_config"
|
|
6
|
+
require "backstage/auto_discovery"
|
|
7
|
+
require "backstage/dashboard_config"
|
|
8
|
+
require "backstage/sidebar_config"
|
|
9
|
+
require "backstage/registry"
|
|
10
|
+
require "backstage/engine"
|
|
11
|
+
|
|
12
|
+
module Backstage
|
|
13
|
+
class << self
|
|
14
|
+
attr_accessor :configuration, :registry
|
|
15
|
+
|
|
16
|
+
def load_configuration!(root)
|
|
17
|
+
path = File.join(root, "config", "backstage.yml")
|
|
18
|
+
hash = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
|
|
19
|
+
self.configuration = Configuration.new(hash)
|
|
20
|
+
|
|
21
|
+
self.registry = Registry.new
|
|
22
|
+
configuration.model_names.each do |name|
|
|
23
|
+
begin
|
|
24
|
+
model_class = name.constantize
|
|
25
|
+
rescue NameError
|
|
26
|
+
raise ConfigurationError, "config/backstage.yml: unknown model '#{name}'"
|
|
27
|
+
end
|
|
28
|
+
registry.register(name, AutoDiscovery.build(model_class))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
load_dsl_files!(root)
|
|
32
|
+
|
|
33
|
+
configuration.dashboard_configs.each do |hash|
|
|
34
|
+
registry.register_dashboard(DashboardConfig.new(hash))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
configuration
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resource(model_name)
|
|
41
|
+
name = model_name.to_s
|
|
42
|
+
config = registry.resource_for(name)
|
|
43
|
+
yield config if block_given?
|
|
44
|
+
rescue KeyError
|
|
45
|
+
raise ConfigurationError, "config/backstage/*.rb: no registered model '#{name}'"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def load_dsl_files!(root)
|
|
51
|
+
dir = File.join(root, "config", "backstage")
|
|
52
|
+
return unless File.directory?(dir)
|
|
53
|
+
Dir.glob(File.join(dir, "*.rb")).sort.each { |f| load f }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Backstage
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def copy_config
|
|
9
|
+
copy_file "backstage.yml", "config/backstage.yml"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def copy_skill
|
|
13
|
+
copy_file "SKILL.md", ".claude/skills/backstage-install.md"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mount_engine
|
|
17
|
+
route 'mount Backstage::Engine, at: "/admin"'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def print_instructions
|
|
21
|
+
say "\nBackstage installed!", :green
|
|
22
|
+
say " 1. Edit config/backstage.yml to list your models"
|
|
23
|
+
say " 2. Wire up current_user in config/initializers/backstage.rb"
|
|
24
|
+
say " 3. Visit /admin"
|
|
25
|
+
say "\n Tip: run /backstage-install in Claude Code for a guided setup walkthrough\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|