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.
- checksums.yaml +7 -0
- data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
- data/AGENTS.md +36 -0
- data/CHANGELOG.md +45 -0
- data/CLA.md +10 -0
- data/LICENSE +201 -0
- data/NOTICE +7 -0
- data/README.md +912 -0
- data/Rakefile +23 -0
- data/TRADEMARK.md +31 -0
- data/app/controllers/layered/resource/controller.rb +284 -0
- data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
- data/app/controllers/layered/resource/internal/columns.rb +207 -0
- data/app/controllers/layered/resource/internal/routing.rb +60 -0
- data/app/controllers/layered/resource/resources_controller.rb +20 -0
- data/app/helpers/layered/resource/filters_helper.rb +321 -0
- data/app/views/layered/resource/columns/_badge.html.erb +2 -0
- data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
- data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
- data/app/views/layered/resource/columns/_text.html.erb +1 -0
- data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
- data/app/views/layered/resource/resources/_filters.html.erb +60 -0
- data/app/views/layered/resource/resources/edit.html.erb +16 -0
- data/app/views/layered/resource/resources/index.html.erb +106 -0
- data/app/views/layered/resource/resources/new.html.erb +16 -0
- data/app/views/layered/resource/resources/show.html.erb +34 -0
- data/config/locales/en.yml +9 -0
- data/lib/generators/layered/resource/column/column_generator.rb +63 -0
- data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
- data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
- data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
- data/lib/generators/layered/resource/resource_generator.rb +63 -0
- data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
- data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
- data/lib/generators/layered/resource/views/views_generator.rb +49 -0
- data/lib/layered/resource/base.rb +625 -0
- data/lib/layered/resource/engine.rb +33 -0
- data/lib/layered/resource/routing.rb +366 -0
- data/lib/layered/resource/version.rb +5 -0
- data/lib/layered/resource.rb +61 -0
- data/lib/layered-resource-rails.rb +1 -0
- 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
|