layered-resource-rails 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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
  3. data/AGENTS.md +36 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLA.md +10 -0
  6. data/LICENSE +201 -0
  7. data/NOTICE +7 -0
  8. data/README.md +912 -0
  9. data/Rakefile +23 -0
  10. data/TRADEMARK.md +31 -0
  11. data/app/controllers/layered/resource/controller.rb +284 -0
  12. data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
  13. data/app/controllers/layered/resource/internal/columns.rb +207 -0
  14. data/app/controllers/layered/resource/internal/routing.rb +60 -0
  15. data/app/controllers/layered/resource/resources_controller.rb +20 -0
  16. data/app/helpers/layered/resource/filters_helper.rb +321 -0
  17. data/app/views/layered/resource/columns/_badge.html.erb +2 -0
  18. data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
  19. data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
  20. data/app/views/layered/resource/columns/_text.html.erb +1 -0
  21. data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
  22. data/app/views/layered/resource/resources/_filters.html.erb +60 -0
  23. data/app/views/layered/resource/resources/edit.html.erb +16 -0
  24. data/app/views/layered/resource/resources/index.html.erb +106 -0
  25. data/app/views/layered/resource/resources/new.html.erb +16 -0
  26. data/app/views/layered/resource/resources/show.html.erb +34 -0
  27. data/config/locales/en.yml +9 -0
  28. data/lib/generators/layered/resource/column/column_generator.rb +63 -0
  29. data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
  30. data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
  31. data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
  32. data/lib/generators/layered/resource/resource_generator.rb +63 -0
  33. data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
  34. data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
  35. data/lib/generators/layered/resource/views/views_generator.rb +49 -0
  36. data/lib/layered/resource/base.rb +625 -0
  37. data/lib/layered/resource/engine.rb +33 -0
  38. data/lib/layered/resource/routing.rb +366 -0
  39. data/lib/layered/resource/version.rb +5 -0
  40. data/lib/layered/resource.rb +61 -0
  41. data/lib/layered-resource-rails.rb +1 -0
  42. metadata +299 -0
@@ -0,0 +1,34 @@
1
+ <% record_label = layered_record_label(@record) %>
2
+ <% index_path = @_route_entry[:actions].include?(:index) ? layered_collection_path : nil %>
3
+
4
+ <div class="l-ui-spread">
5
+ <div>
6
+ <% if layered_breadcrumbs.any? || index_path %>
7
+ <%= l_ui_breadcrumbs do %>
8
+ <% layered_breadcrumbs.each do |crumb| %>
9
+ <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
10
+ <% end %>
11
+ <% if index_path %>
12
+ <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, index_path %>
13
+ <% end %>
14
+ <% end %>
15
+ <% end %>
16
+
17
+ <h1 class="l-ui-heading"><%= record_label %></h1>
18
+ </div>
19
+
20
+ <div class="l-ui-spread">
21
+ <% if resource_can?(:edit, @record) %>
22
+ <%= link_to "Edit",
23
+ layered_member_path(@record, action: :edit),
24
+ class: "l-ui-button l-ui-button--primary" %>
25
+ <% end %>
26
+ <% if resource_can?(:destroy, @record) %>
27
+ <%= button_to "Delete",
28
+ layered_member_path(@record),
29
+ method: :delete,
30
+ class: "l-ui-button l-ui-button--outline-danger",
31
+ data: { turbo_confirm: "Are you sure?" } %>
32
+ <% end %>
33
+ </div>
34
+ </div>
@@ -0,0 +1,9 @@
1
+ en:
2
+ layered:
3
+ resource:
4
+ flash:
5
+ created: "%{model} created"
6
+ updated: "%{model} updated"
7
+ deleted: "%{model} deleted"
8
+ not_deleted: "%{model} could not be deleted"
9
+ dependent_records: "%{model} could not be deleted because other records depend on it"
@@ -0,0 +1,63 @@
1
+ require "rails/generators/base"
2
+
3
+ module Layered
4
+ module Resource
5
+ module Generators
6
+ # Ejects a built-in column partial into the host app, or scaffolds a
7
+ # custom one. Lookup order at runtime is per-resource → host-wide →
8
+ # gem default, so ejected partials override the gem's built-ins.
9
+ #
10
+ # rails g layered:resource:column badge # → app/views/layered/resource/columns/_badge.html.erb
11
+ # rails g layered:resource:column badge questions # → app/views/layered/questions/columns/_badge.html.erb
12
+ # rails g layered:resource:column priority_badge # → scaffold a new type (host-wide)
13
+ class ColumnGenerator < ::Rails::Generators::Base
14
+ BUILT_INS = %w[text datetime badge boolean].freeze
15
+
16
+ argument :type, type: :string, banner: "type"
17
+ argument :resource, type: :string, default: nil, optional: true,
18
+ banner: "[resource]"
19
+
20
+ source_root Layered::Resource::Engine.root.join("app/views/layered/resource/columns")
21
+
22
+ desc "Eject a built-in column partial, or scaffold a custom one."
23
+
24
+ def create_partial
25
+ if BUILT_INS.include?(type)
26
+ copy_file "_#{type}.html.erb", target_path
27
+ else
28
+ create_file target_path, scaffold_template
29
+ end
30
+ end
31
+
32
+ def show_next_steps
33
+ say ""
34
+ say "Edit #{target_path} to customise rendering."
35
+ say ""
36
+ say "Use it from a resource with:"
37
+ say " columns [{ attribute: :something, as: :#{type} }]"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def target_path
44
+ dir = resource.present? ? "app/views/layered/#{resource}/columns" : "app/views/layered/resource/columns"
45
+ File.join(dir, "_#{type}.html.erb")
46
+ end
47
+
48
+ # Starter content for a new (non-built-in) column type. Locals
49
+ # contract matches the runtime: (record, value, options).
50
+ def scaffold_template
51
+ <<~ERB
52
+ <%# Column partial for as: :#{type}.
53
+ Locals:
54
+ record - the model instance
55
+ value - record.public_send(column_attribute)
56
+ options - the column hash (read keys like :variants, :format) %>
57
+ <%= value %>
58
+ ERB
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,54 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module Layered
4
+ module Resource
5
+ module Generators
6
+ # Creates a controller that inherits from
7
+ # Layered::Resource::ResourcesController so the user can override
8
+ # individual actions while keeping the rest of the gem's behaviour.
9
+ #
10
+ # rails g layered:resource:controller articles
11
+ #
12
+ # Generates app/controllers/articles_controller.rb and prints the
13
+ # routes change needed to wire it up.
14
+ class ControllerGenerator < ::Rails::Generators::NamedBase
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Generate a controller that inherits from Layered::Resource::ResourcesController."
18
+
19
+ def create_controller_file
20
+ path = File.join("app/controllers", class_path, "#{file_name}_controller.rb")
21
+ full_path = File.join(destination_root, path)
22
+ if File.exist?(full_path) && !options[:force]
23
+ raise Thor::Error,
24
+ "#{path} already exists. The layered:resource:controller generator " \
25
+ "scaffolds a fresh subclass and would overwrite your existing file. " \
26
+ "Pass --force to overwrite, or delete/rename the existing file first."
27
+ end
28
+ template "controller.rb.tt", path
29
+ end
30
+
31
+ def show_routing_instructions
32
+ say ""
33
+ say "Point the route at the new controller:"
34
+ say ""
35
+ indent = " "
36
+ class_path.each_with_index do |segment, depth|
37
+ say "#{indent * (depth + 1)}namespace :#{segment} do"
38
+ end
39
+ say "#{indent * (class_path.length + 1)}layered_resources :#{file_name}, controller: \"#{file_name}\""
40
+ class_path.length.downto(1) do |depth|
41
+ say "#{indent * depth}end"
42
+ end
43
+ say ""
44
+ end
45
+
46
+ private
47
+
48
+ def file_name
49
+ @_file_name ||= super.sub(/_?controller$/i, "")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,31 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>Controller < Layered::Resource::ResourcesController
3
+ # Override any of the standard CRUD actions (#index, #show, #new,
4
+ # #create, #edit, #update, #destroy) and call `super` when you only
5
+ # want to tweak behaviour. Drop the inheritance and write a plain
6
+ # Rails controller if you outgrow the gem entirely.
7
+ #
8
+ # Custom member/collection actions declared in the matching
9
+ # `layered_resources` block dispatch here. @resource, @model,
10
+ # @columns, @fields, and the @resource_can_* flags are already set.
11
+ #
12
+ # Member actions also get @record auto-loaded from params[:id] via
13
+ # @resource.scope(self).find — add
14
+ # `skip_before_action :load_layered_member_record, only: [:foo]`
15
+ # to opt out. Collection actions never auto-load (no :id in the
16
+ # path), so do any lookups yourself.
17
+ #
18
+ # Example member action:
19
+ #
20
+ # def publish
21
+ # @record.update!(published_at: Time.current)
22
+ # redirect_to layered_member_path(@record), notice: "Published"
23
+ # end
24
+ #
25
+ # Path helpers available:
26
+ # layered_collection_path # index
27
+ # layered_member_path(record) # show/update/destroy
28
+ # layered_routes.<name>_path(...) # any registered route,
29
+ # # parent params filled in
30
+ end
31
+ <% end -%>
@@ -0,0 +1,26 @@
1
+ module Layered
2
+ module Resource
3
+ module Generators
4
+ class InstallAgentSkillGenerator < Rails::Generators::Base
5
+ desc "Copy the layered-resource-rails agent skill into the host application"
6
+
7
+ def self.source_root
8
+ Layered::Resource::Engine.root
9
+ end
10
+
11
+ def copy_skill
12
+ skill_source = File.join(self.class.source_root, ".claude/skills/layered-resource-rails")
13
+ skill_dest = ".claude/skills/layered-resource-rails"
14
+
15
+ directory skill_source, skill_dest
16
+ end
17
+
18
+ def show_instructions
19
+ say ""
20
+ say "Agent skill installed to .claude/skills/layered-resource-rails/", :green
21
+ say ""
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module Layered
4
+ module Resource
5
+ module Generators
6
+ # Generates an `app/layered_resources/<name>_resource.rb` file and
7
+ # appends a `layered_resources :name` route. Use
8
+ # `rails g layered:resource:scaffold` for the full one-shot that also
9
+ # creates the model and migration.
10
+ #
11
+ # rails g layered:resource article title:string body:text
12
+ class ResourceGenerator < ::Rails::Generators::NamedBase
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]"
16
+
17
+ class_option :skip_route, type: :boolean, default: false,
18
+ desc: "Skip appending a layered_resources route"
19
+
20
+ desc "Generate an app/layered_resources/<name>_resource.rb file and a route."
21
+
22
+ def create_resource_file
23
+ template "resource.rb.tt",
24
+ File.join("app/layered_resources", "#{singular_name}_resource.rb")
25
+ end
26
+
27
+ def add_route
28
+ return if options[:skip_route]
29
+
30
+ route "layered_resources :#{plural_name}"
31
+ end
32
+
33
+ private
34
+
35
+ def singular_name
36
+ file_name.singularize
37
+ end
38
+
39
+ def plural_name
40
+ file_name.pluralize
41
+ end
42
+
43
+ def resource_class_name
44
+ singular_name.camelize
45
+ end
46
+
47
+ def column_attributes
48
+ attributes.reject { |a| a.reference? || a.password_digest? }
49
+ end
50
+
51
+ def field_attributes
52
+ column_attributes
53
+ end
54
+
55
+ def field_as(attr)
56
+ case attr.type
57
+ when :text then ", as: :text"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,94 @@
1
+ require "rails/generators/named_base"
2
+ require "generators/layered/resource/resource_generator"
3
+ require "generators/layered/resource/views/views_generator"
4
+
5
+ module Layered
6
+ module Resource
7
+ module Generators
8
+ # One-shot scaffolder: generates the migration + model (via Rails'
9
+ # built-in model generator), an `app/layered_resources/<name>_resource.rb`
10
+ # (via `layered:resource`), and appends a `layered_resources :name` route.
11
+ # Views are intentionally left to the gem's defaults - run
12
+ # `rails g layered:resource:views <name>` to eject them.
13
+ #
14
+ # rails g layered:resource:scaffold article title:string body:text
15
+ class ScaffoldGenerator < ::Rails::Generators::NamedBase
16
+ argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]"
17
+
18
+ class_option :skip_model, type: :boolean, default: false,
19
+ desc: "Skip generating the migration and model (use when they already exist)"
20
+ class_option :actions, type: :array, default: nil, banner: "index show new create edit update destroy",
21
+ desc: "Restrict the generated route to these actions (passed to layered_resources only:)"
22
+ class_option :except, type: :array, default: nil, banner: "destroy",
23
+ desc: "Exclude these actions from the generated route (passed to layered_resources except:)"
24
+ class_option :controller, type: :boolean, default: false,
25
+ desc: "Also eject a controller (invokes layered:resource:controller) and wire it into the route"
26
+ class_option :views, type: :boolean, default: false,
27
+ desc: "Also eject the view templates (invokes layered:resource:views)"
28
+
29
+ desc "Generate a model, migration, resource class, and route in one shot."
30
+
31
+ def create_model
32
+ return if options[:skip_model]
33
+
34
+ invoke "model", [singular_name, *attributes.map(&:to_s)]
35
+ end
36
+
37
+ def create_resource_file
38
+ invoke ResourceGenerator, [singular_name, *attributes.map(&:to_s)], skip_route: true
39
+ end
40
+
41
+ def eject_controller
42
+ invoke "layered:resource:controller", [plural_name] if options[:controller]
43
+ end
44
+
45
+ def eject_views
46
+ invoke ViewsGenerator, [plural_name] if options[:views]
47
+ end
48
+
49
+ def add_route
50
+ route "layered_resources :#{plural_name}#{route_controller_option}#{route_action_filter}"
51
+ end
52
+
53
+ def show_next_steps
54
+ say ""
55
+ say "Next steps for app/layered_resources/#{singular_name}_resource.rb:"
56
+ say " - Add `validates` calls to #{resource_class_name} for any required fields - the form marks fields required based on unconditional presence validators."
57
+ say " - Consider `default_sort attribute: :created_at, direction: :desc` (or another column) so the index has a stable order."
58
+ say " - Consider `search_fields [...]` listing the columns the index search box should match against."
59
+ say ""
60
+ end
61
+
62
+ private
63
+
64
+ def route_controller_option
65
+ ", controller: \"#{plural_name}\"" if options[:controller]
66
+ end
67
+
68
+ def route_action_filter
69
+ if options[:actions].present?
70
+ ", only: #{action_list(options[:actions])}"
71
+ elsif options[:except].present?
72
+ ", except: #{action_list(options[:except])}"
73
+ end
74
+ end
75
+
76
+ def action_list(actions)
77
+ "[#{actions.map { |a| ":#{a}" }.join(', ')}]"
78
+ end
79
+
80
+ def singular_name
81
+ file_name.singularize
82
+ end
83
+
84
+ def plural_name
85
+ file_name.pluralize
86
+ end
87
+
88
+ def resource_class_name
89
+ singular_name.camelize
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,19 @@
1
+ class <%= resource_class_name %>Resource < Layered::Resource::Base
2
+ model <%= resource_class_name %>
3
+ <% if column_attributes.any? -%>
4
+
5
+ columns [
6
+ <% column_attributes.each_with_index do |attr, i| -%>
7
+ { attribute: :<%= attr.name %><%= ", primary: true" if i.zero? %> }<%= "," unless i == column_attributes.length - 1 %>
8
+ <% end -%>
9
+ ]
10
+ <% end -%>
11
+ <% if field_attributes.any? -%>
12
+
13
+ fields [
14
+ <% field_attributes.each_with_index do |attr, i| -%>
15
+ { attribute: :<%= attr.name %><%= field_as(attr) %> }<%= "," unless i == field_attributes.length - 1 %>
16
+ <% end -%>
17
+ ]
18
+ <% end -%>
19
+ end
@@ -0,0 +1,49 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module Layered
4
+ module Resource
5
+ module Generators
6
+ # Copies the gem's view templates into the host app so they can be
7
+ # edited freely. The controller's view-resolution escape hatch picks
8
+ # them up automatically - no further wiring needed.
9
+ #
10
+ # rails g layered:resource:views articles
11
+ #
12
+ # Generates app/views/layered/articles/{index,show,new,edit}.html.erb.
13
+ class ViewsGenerator < ::Rails::Generators::NamedBase
14
+ source_root Layered::Resource::Engine.root.join("app/views/layered/resource/resources")
15
+
16
+ desc "Copy the gem's view templates into app/views/layered/<name>/ for full customisation."
17
+
18
+ VIEWS = %w[index.html.erb show.html.erb new.html.erb edit.html.erb
19
+ _filters.html.erb _filter_control.html.erb].freeze
20
+
21
+ def copy_views
22
+ VIEWS.each do |view|
23
+ copy_file view, File.join("app/views/layered", resource_directory, view)
24
+ end
25
+ end
26
+
27
+ def show_next_steps
28
+ say ""
29
+ say "Edit any of the templates in app/views/layered/#{resource_directory}/ - the gem will use them in place of its defaults."
30
+ say ""
31
+ end
32
+
33
+ private
34
+
35
+ # The controller's view-resolution escape hatch keys off the
36
+ # plural name passed to `layered_resources`, ignoring any Rails
37
+ # namespace. Mirror that here so generated files actually get
38
+ # picked up.
39
+ def resource_directory
40
+ file_name.pluralize
41
+ end
42
+
43
+ def file_name
44
+ @_file_name ||= super.singularize
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end