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
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require "rake/testtask"
9
+
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << "test"
12
+ t.pattern = "test/**/*_test.rb"
13
+ t.verbose = false
14
+ end
15
+
16
+ # `db:prepare` seeds, and the dummy app's seeds deliberately create more users
17
+ # than Layered::Resource.filter_combobox_threshold so the switch to a combobox
18
+ # is visible in bin/dev. Seeded rows in the test database would then change the
19
+ # control a filter renders as, so the suite loads the schema into a purged test
20
+ # database instead of inheriting whatever a previous `db:prepare` left behind.
21
+ task test: "app:db:test:prepare"
22
+
23
+ task default: :test
data/TRADEMARK.md ADDED
@@ -0,0 +1,31 @@
1
+ # layered.ai Trademark and Brand Policy
2
+
3
+ The layered.ai name, logo, domain names, and related brand assets are
4
+ trademarks of LAYERED AI LIMITED (UK company number: 17056830).
5
+
6
+ ## Scope
7
+
8
+ The layered.ai software is licensed under the Apache License 2.0.
9
+ That license grants rights to use, modify, and distribute the software
10
+ but does not grant rights to use the layered.ai name, logo, or branding.
11
+
12
+ ## Restrictions
13
+
14
+ Without a separate written brand license from LAYERED AI LIMITED, you may not:
15
+
16
+ - Use the name "layered.ai" to identify a fork, derivative work, or hosted service
17
+ - Use the layered.ai logo or visual identity
18
+ - Present a product or service as official or endorsed
19
+ - Use confusingly similar names, logos, or domains
20
+
21
+ ## Forks and Redistribution
22
+
23
+ Forks and redistributions must use a distinct name and branding.
24
+ Statements such as "compatible with layered.ai" are allowed if they do
25
+ not imply endorsement.
26
+
27
+ ## Brand Licenses
28
+
29
+ Commercial use of the layered.ai name or marks requires a brand license.
30
+
31
+ Contact: support@layered.ai
@@ -0,0 +1,284 @@
1
+ module Layered
2
+ module Resource
3
+ # All the controller behaviour for a layered resource. Concern so that
4
+ # host engines can declare their own `ResourcesController` inheriting
5
+ # from their own ApplicationController and still pick up the full DSL:
6
+ #
7
+ # class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
8
+ # include Layered::Resource::Controller
9
+ # end
10
+ #
11
+ # The default `Layered::Resource::ResourcesController < ::ApplicationController`
12
+ # exists for non-engine host apps that just want everything wired up.
13
+ module Controller
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ include Internal::Routing
18
+ include Internal::Breadcrumbs
19
+ include Internal::Columns
20
+
21
+ helper Rails.application.routes.url_helpers
22
+ helper Layered::Ui::TableHelper
23
+ helper Layered::Ui::FormHelper
24
+ helper Layered::Ui::RansackHelper
25
+ helper Layered::Ui::PagyHelper
26
+ helper Layered::Ui::BreadcrumbsHelper
27
+ helper Layered::Resource::FiltersHelper
28
+
29
+ before_action :load_layered_resource
30
+ before_action :load_layered_member_record
31
+ before_action :require_layered_fields, only: %i[new create edit update]
32
+ before_action :set_layered_page_title
33
+
34
+ helper_method :layered_routes
35
+ helper_method :layered_collection_path
36
+ helper_method :layered_member_path
37
+ helper_method :layered_breadcrumbs
38
+ helper_method :resource_can?
39
+ helper_method :layered_record_label
40
+ end
41
+
42
+ def index
43
+ apply_layered_filter_defaults
44
+ @q = @resource.scope(self).ransack(params[:q], auth_object: @resource)
45
+ if @q.sorts.empty?
46
+ ds = @resource.default_sort
47
+ @q.sorts = "#{ds[:attribute]} #{ds[:direction]}"
48
+ end
49
+ scope = @q.result(distinct: @resource.requires_distinct?)
50
+
51
+ # Pagy rebuilds page links from the full request query; `fo` (the
52
+ # one-shot open-this-tag's-popover param from the add-filter link)
53
+ # must not ride along or paginating would reopen the popover.
54
+ @pagy, @records = pagy(scope, limit: @resource.per_page,
55
+ querify: ->(query) { query.delete("fo") })
56
+ decorate_columns
57
+ end
58
+
59
+ def show
60
+ @record = @resource.scope(self).find(params[:id])
61
+ authorize_layered_record(@record)
62
+ @page_title = layered_record_label(@record)
63
+ end
64
+
65
+ def new
66
+ @record = @resource.build_record(self)
67
+ authorize_layered_record(@record)
68
+ @form_url = layered_collection_path
69
+ end
70
+
71
+ def create
72
+ @record = @resource.build_record(self)
73
+ authorize_layered_record(@record)
74
+ @record.assign_attributes(layered_resource_params)
75
+
76
+ if @record.save
77
+ redirect_to @resource.after_save_path(self, @record),
78
+ notice: t("layered.resource.flash.created", model: @resource.model.model_name.human)
79
+ else
80
+ @form_url = layered_collection_path
81
+ render :new, status: :unprocessable_entity
82
+ end
83
+ end
84
+
85
+ def edit
86
+ @record = @resource.scope(self).find(params[:id])
87
+ authorize_layered_record(@record)
88
+ @form_url = layered_member_path(@record)
89
+ @page_title = "Edit #{layered_record_label(@record)}"
90
+ end
91
+
92
+ def update
93
+ @record = @resource.scope(self).find(params[:id])
94
+ authorize_layered_record(@record)
95
+ if @record.update(layered_resource_params)
96
+ redirect_to @resource.after_save_path(self, @record),
97
+ notice: t("layered.resource.flash.updated", model: @resource.model.model_name.human)
98
+ else
99
+ @form_url = layered_member_path(@record)
100
+ @page_title = "Edit #{layered_record_label(@record)}"
101
+ render :edit, status: :unprocessable_entity
102
+ end
103
+ end
104
+
105
+ def destroy
106
+ @record = @resource.scope(self).find(params[:id])
107
+ authorize_layered_record(@record)
108
+ redirect_path = @resource.after_save_path(self, @record)
109
+ model_name = @resource.model.model_name.human
110
+ begin
111
+ if @record.destroy
112
+ redirect_to redirect_path, notice: t("layered.resource.flash.deleted", model: model_name)
113
+ else
114
+ redirect_to redirect_path, alert: t("layered.resource.flash.not_deleted", model: model_name)
115
+ end
116
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
117
+ redirect_to redirect_path,
118
+ alert: t("layered.resource.flash.dependent_records", model: model_name)
119
+ end
120
+ end
121
+
122
+ # Composes the route-exposure flag for an action with the per-record
123
+ # Pundit policy when `use_pundit` is enabled. `action` is one of
124
+ # `:new`, `:create`, `:show`, `:edit`, `:update`, `:destroy`. Each key
125
+ # gates the route of the same name and runs the matching Pundit query
126
+ # (`new?`, `create?`, etc.). Pass a `record` to gate on an individual
127
+ # record (e.g. inside the index row); omit it to fall back to the
128
+ # class-level policy (`policy(@resource.model)`). Without `use_pundit`
129
+ # this just returns the `@resource_can_*` flag.
130
+ def resource_can?(action, record = nil)
131
+ flag = case action
132
+ when :new then @resource_can_new
133
+ when :create then @resource_can_create
134
+ when :show then @resource_can_show
135
+ when :edit then @resource_can_edit
136
+ when :update then @resource_can_update
137
+ when :destroy then @resource_can_destroy
138
+ else raise ArgumentError, "unknown action #{action.inspect}"
139
+ end
140
+ return false unless flag
141
+ return true unless @resource.pundit_enabled?
142
+
143
+ policy(record || @resource.model).public_send(:"#{action}?")
144
+ end
145
+
146
+ # View lookup chain:
147
+ # 1. layered/<resource_name>/ — host-app per-resource override (e.g. `app/views/layered/posts/`)
148
+ # 2. <controller's own inheritance prefixes> — host's own layout chain
149
+ # 3. layered/resource/resources/ — gem-shipped defaults (always last)
150
+ #
151
+ # The trailing default ensures custom subclasses (like
152
+ # `CustomNamespace::ResourcesController` including `Layered::Resource::Controller`)
153
+ # still find the gem's templates without re-declaring view paths.
154
+ def _prefixes
155
+ base = super
156
+ prefixes = []
157
+ prefixes << "layered/#{@layered_resource_name}" if @layered_resource_name
158
+ prefixes.concat(base)
159
+ prefixes << "layered/resource/resources" unless prefixes.include?("layered/resource/resources")
160
+ prefixes
161
+ end
162
+
163
+ private
164
+
165
+ # Looks up the resource class from the route registry and sets all
166
+ # the instance variables the views need (@resource, @model, @columns,
167
+ # @fields, and the @resource_can_* route-exposure flags).
168
+ def load_layered_resource
169
+ route_key = request.path_parameters.delete(:_layered_resource_route_key)
170
+ params.delete(:_layered_resource_route_key)
171
+ @_route_entry = Layered::Resource::Routing.lookup(route_key)
172
+ raise ActionController::RoutingError, "No layered resource registered for route" unless @_route_entry
173
+
174
+ @resource = @_route_entry[:resource].safe_constantize
175
+ unless @resource && @resource < Layered::Resource::Base
176
+ raise ActionController::RoutingError,
177
+ "#{@_route_entry[:resource]} is not a layered resource (must inherit from Layered::Resource::Base)"
178
+ end
179
+
180
+ @resource.configure_ransack if Layered::Resource.auto_configure_ransack
181
+
182
+ @model = @resource.model
183
+ @columns = @resource.columns
184
+ @layered_route_key = route_key
185
+ @layered_resource_name = @_route_entry[:resource_name].presence || route_key
186
+ @fields = @resource.resolved_fields
187
+ @crud_enabled = @fields.any?
188
+
189
+ resource_actions = @_route_entry[:actions]
190
+ @resource_can_new = @crud_enabled && resource_actions.include?(:new)
191
+ @resource_can_create = @crud_enabled && resource_actions.include?(:create)
192
+ @resource_can_edit = @crud_enabled && resource_actions.include?(:edit)
193
+ @resource_can_update = @crud_enabled && resource_actions.include?(:update)
194
+ @resource_can_destroy = resource_actions.include?(:destroy)
195
+ @resource_can_show = resource_actions.include?(:show)
196
+ end
197
+
198
+ # For custom member actions declared in a `layered_resources` block,
199
+ # populate @record from params[:id] so action bodies don't have to
200
+ # repeat `@resource.scope(self).find(params[:id])`. Skip this with
201
+ # `skip_before_action :load_layered_member_record, only: [:foo]` if
202
+ # the action doesn't need the record (or shouldn't 404 on a missing
203
+ # one).
204
+ def load_layered_member_record
205
+ return unless @_route_entry
206
+ return unless params[:id]
207
+
208
+ member_actions = @_route_entry[:member_actions] || []
209
+ return unless member_actions.include?(action_name.to_sym)
210
+
211
+ @record = @resource.scope(self).find(params[:id])
212
+ authorize_layered_record(@record)
213
+ end
214
+
215
+ # No-op unless the resource opts into Pundit. Defers to the host's
216
+ # Pundit::Authorization mixin (typically included on ApplicationController).
217
+ def authorize_layered_record(record)
218
+ return unless @resource&.pundit_enabled?
219
+
220
+ authorize(record, :"#{action_name}?")
221
+ end
222
+
223
+ # Merges each filter's `default:` into params[:q] when the request
224
+ # carries none of that filter's keys, so both the Ransack query and the
225
+ # filter-bar helpers see the same effective state (the tag shows the
226
+ # default as active, and links/forms round-trip it explicitly). A key
227
+ # that is present-but-blank means the user explicitly cleared a
228
+ # defaulted filter — the remove/Clear links write blanks for exactly
229
+ # this reason — so the default must NOT re-apply.
230
+ def apply_layered_filter_defaults
231
+ q = params[:q].respond_to?(:to_unsafe_h) ? params[:q].to_unsafe_h.stringify_keys : {}
232
+ additions = {}
233
+
234
+ @resource.resolved_filters.each do |filter|
235
+ default = filter[:default]
236
+ next if default.nil?
237
+ next if filter[:param_keys].any? { |k| q.key?(k) }
238
+
239
+ default = default.call if default.respond_to?(:call)
240
+ if filter[:predicates]
241
+ bounds = default.is_a?(Hash) ? default.symbolize_keys : { from: default }
242
+ from_key, to_key = filter[:param_keys]
243
+ additions[from_key] = bounds[:from] unless bounds[:from].nil?
244
+ additions[to_key] = bounds[:to] unless bounds[:to].nil?
245
+ else
246
+ additions[filter[:param_keys].first] = filter[:multiple] ? Array(default) : default
247
+ end
248
+ end
249
+
250
+ params[:q] = q.merge(additions) if additions.any?
251
+ end
252
+
253
+ def require_layered_fields
254
+ return if @crud_enabled
255
+
256
+ raise ActionController::RoutingError,
257
+ "Define fields on #{@resource.name} to enable CRUD actions"
258
+ end
259
+
260
+ # Sets a default `@page_title` based on the action and resource model.
261
+ # `show` and `edit` actions refine this with the loaded record's label.
262
+ def set_layered_page_title
263
+ human = @model.model_name.human
264
+ @page_title = case action_name
265
+ when "new", "create" then "New #{human}"
266
+ when "edit", "update" then "Edit #{human}"
267
+ when "show" then human
268
+ else human.pluralize
269
+ end
270
+ end
271
+
272
+ # Renders a record as a human label, via the resource's
273
+ # `label_attribute` (the primary column unless declared otherwise).
274
+ def layered_record_label(record)
275
+ @resource.record_label(record)
276
+ end
277
+
278
+ def layered_resource_params
279
+ params.require(@resource.model.model_name.param_key)
280
+ .permit(*@resource.permitted_params)
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,80 @@
1
+ module Layered
2
+ module Resource
3
+ module Internal
4
+ # Builds breadcrumb entries from parent route params.
5
+ # Depends on @_route_entry being set by the controller's
6
+ # load_layered_resource before_action.
7
+ module Breadcrumbs
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # e.g. a route scoped under users/:user_id will produce
13
+ # breadcrumbs like "Users" (linked) and "Alice" by looking up
14
+ # the parent model and its layered index route. A `root_breadcrumb`
15
+ # declared on the resource is prepended to the trail.
16
+ def layered_breadcrumbs
17
+ @_layered_breadcrumbs ||= begin
18
+ root_crumbs = [@resource.root_breadcrumb].compact
19
+ parent_param_keys = @_route_entry[:parent_params]
20
+ parent_collection_keys = @_route_entry[:parent_collection_keys] || {}
21
+
22
+ current_namespace = @_route_entry[:resource].to_s.deconstantize.presence
23
+
24
+ root_crumbs + parent_param_keys.flat_map do |key|
25
+ match = key.to_s.match(/\A(.+)_id\z/)
26
+ next [] unless match
27
+
28
+ model_name = match[1]
29
+
30
+ # Resolve the parent model class. Prefer the registered layered
31
+ # resource entry (authoritative) when the parent is itself a
32
+ # layered_resources. Otherwise try the current resource's
33
+ # namespace before falling back to a top-level constant — this
34
+ # lets a namespaced child have a plain `resources :provider`
35
+ # parent that lives in the same namespace.
36
+ collection_key = parent_collection_keys[key]
37
+ collection_entry = collection_key && Layered::Resource::Routing.lookup(collection_key)
38
+ model_class =
39
+ if collection_entry
40
+ collection_entry[:resource].constantize.model
41
+ elsif current_namespace
42
+ "#{current_namespace}::#{model_name.classify}".safe_constantize ||
43
+ model_name.classify.safe_constantize
44
+ else
45
+ model_name.classify.safe_constantize
46
+ end
47
+ next [] unless model_class
48
+
49
+ crumbs = []
50
+
51
+ # Link to the parent's layered index if a route exists
52
+ if collection_entry
53
+ rs = collection_entry[:routes] || Rails.application.routes
54
+ helper = :"#{collection_key}_path"
55
+ if rs.url_helpers.method_defined?(helper)
56
+ ancestor_args = collection_entry[:parent_params].each_with_object({}) do |p, h|
57
+ h[p] = params[p]
58
+ end
59
+ if ancestor_args.values.all?(&:present?)
60
+ path = rs.url_helpers.send(helper, default_url_options.merge(ancestor_args))
61
+ crumbs << { label: model_class.model_name.human.pluralize, path: path }
62
+ end
63
+ end
64
+ end
65
+
66
+ # Add the specific record breadcrumb
67
+ record = model_class.find_by(id: params[key])
68
+ if record
69
+ label = record.try(:name) || record.try(:title) || "#{model_class.model_name.human} ##{record.id}"
70
+ crumbs << { label: label, path: nil }
71
+ end
72
+
73
+ crumbs
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,207 @@
1
+ module Layered
2
+ module Resource
3
+ module Internal
4
+ # Decorates the column hashes set up by load_layered_resource with the
5
+ # render procs the index view needs. Depends on @columns being set by
6
+ # the controller's load_layered_resource before_action.
7
+ module Columns
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # Walks @columns and installs the default render proc for any column
13
+ # that doesn't already have one, then wraps columns with a `link:`
14
+ # option in a link to the named route.
15
+ def decorate_columns
16
+ apply_column_sortability
17
+ apply_column_renderers
18
+ apply_column_links
19
+ apply_primary_column_link if action_name == "index"
20
+ end
21
+
22
+ # Marks columns sortable: false unless they map to a real DB column.
23
+ # Virtual / association-derived columns (e.g. :user_name on Post) can't
24
+ # be sorted by Ransack without the associated model also having
25
+ # ransackable_attributes configured for the underlying field, so
26
+ # leaving them sortable produces sort links that 500 when clicked.
27
+ # Resources can opt back in by setting sortable: true explicitly and
28
+ # are then responsible for the associated model's allowlist.
29
+ def apply_column_sortability
30
+ db_columns = @resource.model.column_names
31
+
32
+ @columns = @columns.map do |col|
33
+ next col if col.key?(:sortable)
34
+
35
+ col.merge(sortable: db_columns.include?(col[:attribute].to_s))
36
+ end
37
+ end
38
+
39
+ # Column rendering precedence:
40
+ # 1. render: proc — escape hatch. Procs with arity 1 receive only
41
+ # the record. Anything else (fixed arity >= 2, or variadic /
42
+ # optional-arg procs with negative arity such as
43
+ # `->(record, view = nil)`) also receives view_context as a
44
+ # second arg, letting escape-hatch procs emit HTML via link_to
45
+ # / tag.* without ActionController::Base.helpers gymnastics.
46
+ # Normalised to single-arity here so downstream helpers
47
+ # (l_ui_table) keep calling proc.call(record).
48
+ # 2. as: <type> — dispatch to a partial. Lookup order:
49
+ # a. app/views/layered/<resource_name>/columns/_<type>.html.erb
50
+ # b. app/views/layered/resource/columns/_<type>.html.erb
51
+ # (host-app override > gem default, via Rails view paths).
52
+ # Partial locals: (record, value, options). options is the
53
+ # column hash itself - partials read keys like :variants,
54
+ # :true_label, :format.
55
+ # 3. Default: raw attribute value with a strftime fallback so
56
+ # datetime columns render readably without ceremony.
57
+ def apply_column_renderers
58
+ view = view_context
59
+
60
+ @columns = @columns.map do |col|
61
+ if (user_render = col[:render])
62
+ arity = user_render.arity
63
+ if arity == 1
64
+ col
65
+ elsif arity >= 2 || arity < 0
66
+ col.merge(render: ->(record) { user_render.call(record, view) })
67
+ else
68
+ raise ArgumentError,
69
+ "render: proc for column #{col[:attribute].inspect} must accept " \
70
+ "(record) or (record, view_context); got arity #{arity}."
71
+ end
72
+ else
73
+ attr = col[:attribute]
74
+ type = col[:as] || infer_column_type(attr)
75
+ partial = resolve_column_partial(type)
76
+ options = col
77
+ col.merge(
78
+ render: ->(record) {
79
+ view.render(partial: partial, locals: { record: record, value: record.public_send(attr), options: options })
80
+ }
81
+ )
82
+ end
83
+ end
84
+ end
85
+
86
+ # Picks a default column partial type from the model's schema:
87
+ # boolean → :boolean, date/time/datetime → :datetime, everything
88
+ # else (including virtual attributes with no DB column) → :text.
89
+ # Resources can pin a specific renderer with as:.
90
+ def infer_column_type(attr)
91
+ col = @resource.model.columns_hash[attr.to_s]
92
+ case col&.type
93
+ when :boolean then :boolean
94
+ when :datetime, :date, :time, :timestamp then :datetime
95
+ else :text
96
+ end
97
+ end
98
+
99
+ # Per-resource override wins; otherwise fall back to the host-wide
100
+ # path. The host-wide path resolves to the host app's override (if
101
+ # any) before the gem's built-in via the standard view-path chain.
102
+ # Raises if neither exists - typo'd `as:` types should fail loudly,
103
+ # not render an empty cell.
104
+ def resolve_column_partial(type)
105
+ per_resource = "layered/#{@layered_resource_name}/columns/#{type}"
106
+ shared = "layered/resource/columns/#{type}"
107
+
108
+ if lookup_context.exists?(type, ["layered/#{@layered_resource_name}/columns"], true)
109
+ per_resource
110
+ elsif lookup_context.exists?(type, ["layered/resource/columns"], true)
111
+ shared
112
+ else
113
+ raise ArgumentError,
114
+ "No column partial found for as: #{type.inspect}. " \
115
+ "Looked for #{per_resource} and #{shared}. " \
116
+ "Run `rails g layered:resource:column #{type}` to scaffold one."
117
+ end
118
+ end
119
+
120
+ # Wraps the primary column's render proc to link the cell to the most
121
+ # useful per-record page: the edit page when editable, otherwise the
122
+ # show page when one exists. The default show view is a blank canvas,
123
+ # so the index links to edit (where you actually act on the record)
124
+ # and only falls back to show for read-only resources that still
125
+ # expose a detail page. The "primary" column is the one marked
126
+ # primary: true (or the first column if none is). Columns that already
127
+ # declare a custom link: are left alone.
128
+ def apply_primary_column_link
129
+ singular = @layered_route_key.singularize
130
+ helper = if @resource_can_edit
131
+ :"edit_#{singular}_path"
132
+ elsif @resource_can_show
133
+ :"#{singular}_path"
134
+ end
135
+ return unless helper
136
+
137
+ routes_proxy = layered_routes
138
+ return unless routes_proxy.respond_to?(helper)
139
+
140
+ primary_index = @columns.index { |c| c[:primary] } || 0
141
+ view = view_context
142
+
143
+ @columns = @columns.each_with_index.map do |col, i|
144
+ next col unless i == primary_index
145
+ next col if col[:link]
146
+
147
+ inner_render = col[:render]
148
+ col.merge(
149
+ render: ->(record) {
150
+ value = inner_render.call(record)
151
+ view.link_to value, routes_proxy.send(helper, record), data: { turbo_frame: "_top" }
152
+ }
153
+ )
154
+ end
155
+ end
156
+
157
+ # Rewrites columns with a `link:` option, e.g.:
158
+ # { attribute: :posts_count, link: :users_posts }
159
+ #
160
+ # Raises if the named route hasn't been registered, has no parent
161
+ # params (so there's no way to pass the record id), or has no
162
+ # generated path helper. Silent failure here turns a typo into a
163
+ # mysterious unlinked column; better to surface it.
164
+ def apply_column_links
165
+ view = view_context
166
+ opts = default_url_options
167
+
168
+ @columns = @columns.map do |col|
169
+ next col unless col[:link]
170
+
171
+ linked_key = col[:link].to_s
172
+ linked_entry = Layered::Resource::Routing.lookup(linked_key) ||
173
+ raise(ArgumentError, "Column #{col[:attribute].inspect} on #{@resource.name} has link: #{col[:link].inspect} but no layered_resources route is registered under that key")
174
+
175
+ rs = linked_entry[:routes] || Rails.application.routes
176
+ link_parent_params = linked_entry[:parent_params]
177
+ if link_parent_params.empty?
178
+ raise ArgumentError,
179
+ "Column #{col[:attribute].inspect} on #{@resource.name} links to #{col[:link].inspect}, " \
180
+ "but that route has no parent params - link: only works for nested layered_resources"
181
+ end
182
+
183
+ path_helper = :"#{linked_key}_path"
184
+ unless rs.url_helpers.method_defined?(path_helper)
185
+ raise ArgumentError,
186
+ "Column #{col[:attribute].inspect} on #{@resource.name} links to #{col[:link].inspect}, " \
187
+ "but #{path_helper} is not defined - does the route include :index?"
188
+ end
189
+
190
+ *ancestor_params, immediate_parent = link_parent_params
191
+ inner_render = col[:render]
192
+ col.merge(
193
+ render: ->(record) {
194
+ value = inner_render.call(record)
195
+ args = opts.dup
196
+ ancestor_params.each { |p| args[p] = record.public_send(p) }
197
+ args[immediate_parent] = record.id
198
+ path = rs.url_helpers.send(path_helper, args)
199
+ view.link_to value, path, data: { turbo_frame: "_top" }
200
+ }
201
+ )
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,60 @@
1
+ module Layered
2
+ module Resource
3
+ module Internal
4
+ # Path generation for the current layered resource. Routes are
5
+ # registered with Rails-standard `:as` names (e.g. `user_posts_path`),
6
+ # so the helpers here can be backed either by named route lookup or
7
+ # `polymorphic_path` — they're equivalent. We use named lookup for
8
+ # the current resource's own paths because we already have the route
9
+ # entry in hand from `load_layered_resource`; views and breadcrumbs
10
+ # use polymorphic helpers when reaching across resources.
11
+ module Routing
12
+ extend ActiveSupport::Concern
13
+
14
+ # Returns an object that responds to the route helpers (e.g.
15
+ # users_posts_path) with parent params already filled in from
16
+ # the current request. Used by views/columns to generate links
17
+ # without needing to know the parent params.
18
+ def layered_routes
19
+ @_layered_routes ||= begin
20
+ rs = @_route_entry[:routes] || Rails.application.routes
21
+ proxy = Object.new
22
+ proxy.singleton_class.include(rs.url_helpers)
23
+ ctrl = self
24
+ parent_values = request.path_parameters.slice(*@_route_entry[:parent_params])
25
+ proxy.singleton_class.remove_method(:default_url_options) if proxy.singleton_class.method_defined?(:default_url_options)
26
+ proxy.define_singleton_method(:default_url_options) { ctrl.send(:default_url_options).merge(parent_values) }
27
+ proxy
28
+ end
29
+ end
30
+
31
+ # Path to the current resource's collection (or one of its
32
+ # collection-scoped actions, e.g. `:new`).
33
+ def layered_collection_path(action: nil)
34
+ base = action ? :"#{action}_#{@layered_route_key.singularize}" : @layered_route_key.to_sym
35
+ helper = :"#{base}_path"
36
+ unless layered_routes.respond_to?(helper)
37
+ raise ActionController::RoutingError,
38
+ "No #{action || 'collection'} route registered for #{@layered_route_key}. " \
39
+ "Include the matching action in only:."
40
+ end
41
+ layered_routes.send(helper)
42
+ end
43
+
44
+ # Path to a member of the current resource (or a member-scoped
45
+ # action like `:edit`).
46
+ def layered_member_path(record, action: nil)
47
+ singular = @layered_route_key.singularize
48
+ base = action ? :"#{action}_#{singular}" : singular.to_sym
49
+ helper = :"#{base}_path"
50
+ unless layered_routes.respond_to?(helper)
51
+ raise ActionController::RoutingError,
52
+ "No #{action || 'member'} route registered for #{@layered_route_key}. " \
53
+ "Include the matching action in only:."
54
+ end
55
+ layered_routes.send(helper, record)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end