crud_components 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/.gitignore +9 -0
- data/Gemfile +23 -0
- data/LICENSE +21 -0
- data/README.md +511 -0
- data/RELEASING.md +44 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/crud_components.css +35 -0
- data/app/views/crud_components/_action_button.html.erb +11 -0
- data/app/views/crud_components/_actions.html.erb +12 -0
- data/app/views/crud_components/_column_header.html.erb +24 -0
- data/app/views/crud_components/_column_picker.html.erb +66 -0
- data/app/views/crud_components/_filter.html.erb +34 -0
- data/app/views/crud_components/_form.html.erb +30 -0
- data/app/views/crud_components/_pager.html.erb +41 -0
- data/app/views/crud_components/_record.html.erb +15 -0
- data/app/views/crud_components/_row.html.erb +26 -0
- data/app/views/crud_components/_selection_action.html.erb +14 -0
- data/app/views/crud_components/_sort_link.html.erb +17 -0
- data/app/views/crud_components/_toolbar.html.erb +50 -0
- data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
- data/app/views/crud_components/fields/_association.html.erb +13 -0
- data/app/views/crud_components/fields/_association_list.html.erb +24 -0
- data/app/views/crud_components/fields/_attachment.html.erb +16 -0
- data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
- data/app/views/crud_components/fields/_boolean.html.erb +13 -0
- data/app/views/crud_components/fields/_date.html.erb +6 -0
- data/app/views/crud_components/fields/_datetime.html.erb +6 -0
- data/app/views/crud_components/fields/_email.html.erb +7 -0
- data/app/views/crud_components/fields/_enum.html.erb +14 -0
- data/app/views/crud_components/fields/_json.html.erb +10 -0
- data/app/views/crud_components/fields/_markdown.html.erb +9 -0
- data/app/views/crud_components/fields/_number.html.erb +8 -0
- data/app/views/crud_components/fields/_string.html.erb +8 -0
- data/app/views/crud_components/fields/_text.html.erb +9 -0
- data/app/views/crud_components/fields/_url.html.erb +11 -0
- data/app/views/crud_components/filters/_boolean.html.erb +12 -0
- data/app/views/crud_components/filters/_date_range.html.erb +11 -0
- data/app/views/crud_components/filters/_number_range.html.erb +13 -0
- data/app/views/crud_components/filters/_select.html.erb +8 -0
- data/app/views/crud_components/filters/_text.html.erb +5 -0
- data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
- data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
- data/app/views/crud_components/form_fields/_date.html.erb +2 -0
- data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
- data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
- data/app/views/crud_components/form_fields/_file.html.erb +47 -0
- data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
- data/app/views/crud_components/form_fields/_number.html.erb +2 -0
- data/app/views/crud_components/form_fields/_string.html.erb +3 -0
- data/app/views/crud_components/form_fields/_text.html.erb +2 -0
- data/app/views/crud_components/layouts/_table.html.erb +143 -0
- data/config/locales/crud_components.de.yml +39 -0
- data/config/locales/crud_components.en.yml +40 -0
- data/crud_components.gemspec +48 -0
- data/docs/extending.md +308 -0
- data/docs/fields.md +442 -0
- data/docs/forms.md +253 -0
- data/docs/performance.md +90 -0
- data/docs/security.md +139 -0
- data/docs/views.md +405 -0
- data/lib/crud_components/action.rb +85 -0
- data/lib/crud_components/builder.rb +246 -0
- data/lib/crud_components/config.rb +128 -0
- data/lib/crud_components/dynamic_column.rb +68 -0
- data/lib/crud_components/engine.rb +25 -0
- data/lib/crud_components/errors.rb +9 -0
- data/lib/crud_components/fields/attachment_field.rb +22 -0
- data/lib/crud_components/fields/base.rb +260 -0
- data/lib/crud_components/fields/belongs_to_field.rb +91 -0
- data/lib/crud_components/fields/boolean_field.rb +31 -0
- data/lib/crud_components/fields/computed_field.rb +34 -0
- data/lib/crud_components/fields/date_field.rb +51 -0
- data/lib/crud_components/fields/dynamic_field.rb +44 -0
- data/lib/crud_components/fields/enum_field.rb +40 -0
- data/lib/crud_components/fields/has_many_field.rb +50 -0
- data/lib/crud_components/fields/json_field.rb +10 -0
- data/lib/crud_components/fields/numeric_field.rb +31 -0
- data/lib/crud_components/fields/path_field.rb +327 -0
- data/lib/crud_components/fields/string_field.rb +41 -0
- data/lib/crud_components/fields/text_field.rb +9 -0
- data/lib/crud_components/fieldset.rb +38 -0
- data/lib/crud_components/helpers.rb +259 -0
- data/lib/crud_components/like_spec.rb +113 -0
- data/lib/crud_components/markup.rb +36 -0
- data/lib/crud_components/model.rb +33 -0
- data/lib/crud_components/permission_context.rb +62 -0
- data/lib/crud_components/presenters/actions.rb +51 -0
- data/lib/crud_components/presenters/base.rb +95 -0
- data/lib/crud_components/presenters/cell_context.rb +28 -0
- data/lib/crud_components/presenters/cells.rb +160 -0
- data/lib/crud_components/presenters/collection.rb +498 -0
- data/lib/crud_components/presenters/column_selection.rb +91 -0
- data/lib/crud_components/presenters/filter.rb +38 -0
- data/lib/crud_components/presenters/form.rb +57 -0
- data/lib/crud_components/presenters/record.rb +57 -0
- data/lib/crud_components/query.rb +110 -0
- data/lib/crud_components/route_resolver.rb +123 -0
- data/lib/crud_components/structure.rb +343 -0
- data/lib/crud_components/version.rb +3 -0
- data/lib/crud_components/where_like.rb +13 -0
- data/lib/crud_components.rb +160 -0
- data/lib/generators/crud_components/install/install_generator.rb +43 -0
- data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
- data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
- data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
- data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
- data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
- data/lib/generators/crud_components/views/views_generator.rb +14 -0
- metadata +209 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# The single `record_presenter` local of the record partial.
|
|
4
|
+
class Record < Base
|
|
5
|
+
include ColumnSelection
|
|
6
|
+
|
|
7
|
+
attr_reader :record, :model, :structure, :fieldset, :param_prefix
|
|
8
|
+
|
|
9
|
+
def initialize(view:, record:, fieldset: nil, actions: true, picked_columns: :auto,
|
|
10
|
+
param_prefix: nil, extra_columns: nil)
|
|
11
|
+
super(view: view)
|
|
12
|
+
@record = record
|
|
13
|
+
@model = record.class
|
|
14
|
+
@structure = Structure.for(@model)
|
|
15
|
+
@fieldset = @structure.fieldset(fieldset || :show)
|
|
16
|
+
@actions_enabled = actions
|
|
17
|
+
@param_prefix = param_prefix
|
|
18
|
+
# Dynamic columns work on a detail view too — user-defined properties
|
|
19
|
+
# whose data lives outside the model's table, shown as extra rows.
|
|
20
|
+
@dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model).preload!([record]) }
|
|
21
|
+
# A column picker can narrow/order this dl too, but a detail view has no
|
|
22
|
+
# inline gear of its own (no `picker:` knob) — the gear is a standalone
|
|
23
|
+
# `crud_column_picker` on the page. So pass `picked_columns:` an Array you
|
|
24
|
+
# resolved (e.g. via `CrudComponents.selected_columns(params)`); `:auto`
|
|
25
|
+
# here means "don't narrow" (no gear → a stray `?cols=` is ignored).
|
|
26
|
+
# (`fields`, `column_visible?` and the picker logic come from ColumnSelection.)
|
|
27
|
+
@picker = false
|
|
28
|
+
@picked_columns = normalize_picked_columns(picked_columns)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def title
|
|
32
|
+
structure.label_for(record, view)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Every field this user may see on this record — declared fields plus the
|
|
36
|
+
# dynamic columns; the picker's universe.
|
|
37
|
+
def available_fields
|
|
38
|
+
@available_fields ||= (structure.fieldset_fields(fieldset) + @dynamic_fields)
|
|
39
|
+
.select { |f| f.permitted?(permission_context, record) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def value_html(field)
|
|
43
|
+
render_cell(field, record, surface: :record)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Row actions for this record; a derived :show button to the page we
|
|
47
|
+
# are already on would be noise.
|
|
48
|
+
def actions
|
|
49
|
+
return nil unless @actions_enabled
|
|
50
|
+
|
|
51
|
+
@actions ||= Actions.new(view: view, subject: record, structure: structure,
|
|
52
|
+
actions: structure.fieldset_actions(fieldset, on: :row),
|
|
53
|
+
suppress_show: true)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# Applies URL params to a relation: filtering, global search, sorting.
|
|
3
|
+
#
|
|
4
|
+
# The uniform rule: a param is applied iff it names a filterable field of
|
|
5
|
+
# the fieldset in play that the current user may see (or one of the
|
|
6
|
+
# reserved params q/sort/dir/page/per). Everything else never reaches SQL.
|
|
7
|
+
class Query
|
|
8
|
+
SORT_DIRECTIONS = %w[asc desc].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :model, :structure, :fieldset, :param_prefix
|
|
11
|
+
|
|
12
|
+
def initialize(model, params, fieldset: nil, ability: nil, param_prefix: nil, extra_fields: [])
|
|
13
|
+
@model = model
|
|
14
|
+
@structure = Structure.for(model)
|
|
15
|
+
@fieldset = fieldset.is_a?(Fieldset) ? fieldset : @structure.fieldset(fieldset)
|
|
16
|
+
@params = extract(params)
|
|
17
|
+
@permission = PermissionContext.new(ability)
|
|
18
|
+
@param_prefix = param_prefix
|
|
19
|
+
@extra_fields = extra_fields
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def apply(scope)
|
|
23
|
+
scope = apply_filters(scope)
|
|
24
|
+
scope = apply_search(scope)
|
|
25
|
+
apply_sort(scope)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fieldset_name = fieldset.name
|
|
29
|
+
|
|
30
|
+
def filter_fields
|
|
31
|
+
(structure.fieldset_filter_fields(fieldset) + @extra_fields.select(&:filterable?))
|
|
32
|
+
.select { |f| f.permitted?(@permission) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sortable_fields
|
|
36
|
+
(structure.fieldset_sortable_fields(fieldset) + @extra_fields.select(&:sortable?))
|
|
37
|
+
.select { |f| f.permitted?(@permission) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def searchable? = structure.searchable?
|
|
41
|
+
|
|
42
|
+
# Current value of a (logical, unprefixed) param — for filter controls.
|
|
43
|
+
def value(key) = param(key)
|
|
44
|
+
|
|
45
|
+
def active?
|
|
46
|
+
keys = filter_fields.flat_map { |f| [f.name.to_s, "#{f.name}_geq", "#{f.name}_leq"] }
|
|
47
|
+
(keys + ['q']).any? { |key| param(key) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [field_name_string, direction_string] or nil.
|
|
51
|
+
def sort_state
|
|
52
|
+
field = current_sort_field
|
|
53
|
+
field && [field.name.to_s, direction]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def param_name(key) = "#{prefix}#{key}"
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def prefix = param_prefix ? "#{param_prefix}_" : ''
|
|
61
|
+
|
|
62
|
+
def param(key)
|
|
63
|
+
raw = @params[param_name(key)]
|
|
64
|
+
raw.is_a?(String) && raw.present? ? raw : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract(params)
|
|
68
|
+
hash = if params.respond_to?(:to_unsafe_h)
|
|
69
|
+
params.to_unsafe_h
|
|
70
|
+
else
|
|
71
|
+
params.to_h
|
|
72
|
+
end
|
|
73
|
+
hash.transform_keys(&:to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_filters(scope)
|
|
77
|
+
filter_fields.reduce(scope) do |current, field|
|
|
78
|
+
exact = param(field.name.to_s)
|
|
79
|
+
geq = param("#{field.name}_geq")
|
|
80
|
+
leq = param("#{field.name}_leq")
|
|
81
|
+
next current unless exact || geq || leq
|
|
82
|
+
|
|
83
|
+
field.apply_filter(current, exact: exact, geq: geq, leq: leq)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def apply_search(scope)
|
|
88
|
+
q = param('q')
|
|
89
|
+
return scope unless q && structure.searchable?
|
|
90
|
+
|
|
91
|
+
structure.apply_search(scope, q, permission: @permission)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def current_sort_field
|
|
95
|
+
sort = param('sort')
|
|
96
|
+
sort && sortable_fields.find { |f| f.name.to_s == sort }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def direction
|
|
100
|
+
SORT_DIRECTIONS.include?(param('dir')) ? param('dir') : 'asc'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def apply_sort(scope)
|
|
104
|
+
field = current_sort_field
|
|
105
|
+
return scope unless field
|
|
106
|
+
|
|
107
|
+
field.apply_sort(scope, direction.to_sym)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# Resolves derived actions and record links to routes: the most specific
|
|
3
|
+
# conventional route first (association-scoped when the collection came
|
|
4
|
+
# from an association), then the top-level route, then nil — a nil means
|
|
5
|
+
# the button/link is omitted, never broken.
|
|
6
|
+
module RouteResolver
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def action_path(view, action, record: nil, model: nil, owner: nil)
|
|
10
|
+
if action.path_block
|
|
11
|
+
subject = record || model
|
|
12
|
+
return view.instance_exec(subject, &action.path_block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
if action.collection?
|
|
16
|
+
collection_path(view, action, model, owner)
|
|
17
|
+
else
|
|
18
|
+
member_path(view, action, record, owner)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The plain link to a record (label cells, association cells):
|
|
23
|
+
# show route, then edit. Returns [path, kind] or nil.
|
|
24
|
+
def record_path(view, record, owner: nil)
|
|
25
|
+
path = try_helpers(view, member_candidates(nil, record, owner))
|
|
26
|
+
return [path, :show] if path
|
|
27
|
+
|
|
28
|
+
path = try_helpers(view, member_candidates('edit_', record, owner))
|
|
29
|
+
path ? [path, :edit] : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the record has a plain (show) route — feeds the
|
|
33
|
+
# ":show button only without a label link" rule.
|
|
34
|
+
def show_path(view, record, owner: nil)
|
|
35
|
+
try_helpers(view, member_candidates(nil, record, owner))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The index a has_many "+n more" link points at:
|
|
39
|
+
# 1. the nested index under the owner (publisher_books_path(publisher)),
|
|
40
|
+
# 2. else the target's index filtered by the owner — but ONLY when the
|
|
41
|
+
# target actually has a filterable belongs_to back to the owner
|
|
42
|
+
# (publisher→books works; a habtm like author↔books does not, so we
|
|
43
|
+
# do not emit a link that would silently show everything),
|
|
44
|
+
# 3. else nil (the renderer shows "+n more" as plain text).
|
|
45
|
+
# `assoc_name` is the owner's reflection name (e.g. :books).
|
|
46
|
+
def collection_index_path(view, target, owner, assoc_name)
|
|
47
|
+
return nil unless owner
|
|
48
|
+
|
|
49
|
+
key = target.model_name.route_key
|
|
50
|
+
owner_key = owner.model_name.singular_route_key
|
|
51
|
+
nested = "#{owner_key}_#{key}_path"
|
|
52
|
+
return safe_url(view, nested, owner) if view.respond_to?(nested)
|
|
53
|
+
|
|
54
|
+
flat = "#{key}_path"
|
|
55
|
+
return nil unless view.respond_to?(flat)
|
|
56
|
+
|
|
57
|
+
filter = inverse_filter(target, owner, assoc_name)
|
|
58
|
+
return nil unless filter
|
|
59
|
+
|
|
60
|
+
safe_url(view, flat, **{ filter[:param] => filter[:value] })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The target's belongs_to that mirrors the owner's collection (matched by
|
|
64
|
+
# foreign key), if it is filterable — with the owner's identify_by value.
|
|
65
|
+
def inverse_filter(target, owner, assoc_name)
|
|
66
|
+
owner_reflection = owner.class.reflect_on_association(assoc_name)
|
|
67
|
+
return nil unless owner_reflection&.foreign_key
|
|
68
|
+
|
|
69
|
+
fk = owner_reflection.foreign_key.to_s
|
|
70
|
+
inverse = target.reflect_on_all_associations(:belongs_to).find { |r| r.foreign_key.to_s == fk }
|
|
71
|
+
return nil unless inverse
|
|
72
|
+
|
|
73
|
+
field = Structure.for(target).field(inverse.name)
|
|
74
|
+
return nil unless field.filterable?
|
|
75
|
+
|
|
76
|
+
identify = Structure.for(owner.class).identify_by
|
|
77
|
+
{ param: inverse.name, value: owner.public_send(identify) }
|
|
78
|
+
rescue CrudComponents::DefinitionError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def safe_url(view, helper, *args, **kwargs)
|
|
83
|
+
kwargs.empty? ? view.public_send(helper, *args) : view.public_send(helper, *args, **kwargs)
|
|
84
|
+
rescue ActionController::UrlGenerationError, NoMethodError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def member_path(view, action, record, owner)
|
|
89
|
+
prefix = { show: nil, destroy: nil, edit: 'edit_' }.fetch(action.name, "#{action.name}_")
|
|
90
|
+
try_helpers(view, member_candidates(prefix, record, owner))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def collection_path(view, action, model, owner)
|
|
94
|
+
prefix = action.name == :new ? 'new_' : "#{action.name}_"
|
|
95
|
+
key = action.name == :new ? model.model_name.singular_route_key : model.model_name.route_key
|
|
96
|
+
candidates = []
|
|
97
|
+
candidates << ["#{prefix}#{owner.model_name.singular_route_key}_#{key}_path", [owner]] if owner
|
|
98
|
+
candidates << ["#{prefix}#{key}_path", []]
|
|
99
|
+
try_helpers(view, candidates)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def member_candidates(prefix, record, owner)
|
|
103
|
+
key = record.model_name.singular_route_key
|
|
104
|
+
candidates = []
|
|
105
|
+
candidates << ["#{prefix}#{owner.model_name.singular_route_key}_#{key}_path", [owner, record]] if owner
|
|
106
|
+
candidates << ["#{prefix}#{key}_path", [record]]
|
|
107
|
+
candidates
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def try_helpers(view, candidates)
|
|
111
|
+
candidates.each do |helper, args|
|
|
112
|
+
next unless view.respond_to?(helper)
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
return view.public_send(helper, *args)
|
|
116
|
+
rescue ActionController::UrlGenerationError, NoMethodError
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# The resolved, validated description of how a model appears in the UI.
|
|
3
|
+
# Built lazily per model class and memoized; works for models without any
|
|
4
|
+
# declaration (rule zero: everything is derived from what Rails knows).
|
|
5
|
+
class Structure
|
|
6
|
+
RENDERER_GEMS = {
|
|
7
|
+
markdown: %w[commonmarker redcarpet kramdown],
|
|
8
|
+
asciidoc: %w[asciidoctor]
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def for(model)
|
|
13
|
+
unless model.respond_to?(:columns_hash)
|
|
14
|
+
raise ArgumentError, "#{model.inspect} is not an ActiveRecord model class"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
cached = model.instance_variable_get(:@_crud_structure) if model.instance_variable_defined?(:@_crud_structure)
|
|
18
|
+
return cached if cached
|
|
19
|
+
|
|
20
|
+
structure = new(model, find_builder(model))
|
|
21
|
+
model.instance_variable_set(:@_crud_structure, structure)
|
|
22
|
+
structure
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Class-level ivars are not inherited; walk up for STI subclasses.
|
|
28
|
+
def find_builder(model)
|
|
29
|
+
klass = model
|
|
30
|
+
while klass.respond_to?(:instance_variable_defined?)
|
|
31
|
+
if klass.instance_variable_defined?(:@_crud_structure_block)
|
|
32
|
+
block = klass.instance_variable_get(:@_crud_structure_block)
|
|
33
|
+
return Builder.new(model, &block) if block
|
|
34
|
+
end
|
|
35
|
+
klass = klass.superclass
|
|
36
|
+
break if klass.nil? || klass == ActiveRecord::Base
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# identity_preloads: associations to eager-load whenever this model is shown
|
|
43
|
+
# as another model's association cell (its label/render dependencies), from
|
|
44
|
+
# `label …, preload:` and the standalone `preload` declaration.
|
|
45
|
+
attr_reader :model, :identify_by, :identity_preloads
|
|
46
|
+
|
|
47
|
+
def initialize(model, builder = nil)
|
|
48
|
+
@model = model
|
|
49
|
+
@declarations = builder&.declarations || {}
|
|
50
|
+
@label_decl = builder&.label_decl
|
|
51
|
+
@identify_by = builder&.identify_by_decl || :id
|
|
52
|
+
@icon_decl = builder&.icon_decl
|
|
53
|
+
@search_decl = builder&.search_decl
|
|
54
|
+
@identity_preloads = ((builder&.label_preload_decl || []) + (builder&.preload_decl || [])).uniq
|
|
55
|
+
@declared_actions = builder&.actions || {}
|
|
56
|
+
@declared_fieldsets = builder&.fieldsets || {}
|
|
57
|
+
@fields = {}
|
|
58
|
+
validate!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── fields ───────────────────────────────────────────────────────────────
|
|
62
|
+
def field(name)
|
|
63
|
+
@fields[name.to_sym] ||= resolve_field(name.to_sym)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The :all set: every column (foreign keys swapped for their belongs_to),
|
|
67
|
+
# then Active Storage attachments, then non-belongs_to associations
|
|
68
|
+
# (has_many / habtm / has_one), then any declared computed fields — all
|
|
69
|
+
# derived, in a stable order.
|
|
70
|
+
def default_field_names
|
|
71
|
+
@default_field_names ||= begin
|
|
72
|
+
base = column_field_names + attachment_field_names + association_field_names
|
|
73
|
+
base + (@declarations.keys - base)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Active Storage attachments (has_one_attached / has_many_attached),
|
|
78
|
+
# surfaced as image fields — derived, no declaration needed.
|
|
79
|
+
def attachment_field_names
|
|
80
|
+
@attachment_field_names ||=
|
|
81
|
+
model.respond_to?(:reflect_on_all_attachments) ? model.reflect_on_all_attachments.map(&:name) : []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# has_many / habtm / has_one — belongs_to already arrive via the FK swap.
|
|
85
|
+
# Active Storage's generated join associations (images_attachments/_blobs)
|
|
86
|
+
# and ActionText's rich-text associations are not user-facing columns.
|
|
87
|
+
def association_field_names
|
|
88
|
+
@association_field_names ||=
|
|
89
|
+
model.reflect_on_all_associations.reject(&:belongs_to?).map(&:name)
|
|
90
|
+
.reject { |n| n.to_s.start_with?('rich_text_', 'with_attached_') }
|
|
91
|
+
.reject { |n| attachment_support_names.include?(n) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The join associations behind each attachment — excluded from the field
|
|
95
|
+
# universe, since the attachment itself is the field.
|
|
96
|
+
def attachment_support_names
|
|
97
|
+
@attachment_support_names ||=
|
|
98
|
+
attachment_field_names.flat_map do |att|
|
|
99
|
+
%W[#{att}_attachment #{att}_attachments #{att}_blob #{att}_blobs].map(&:to_sym)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ── fieldsets ────────────────────────────────────────────────────────────
|
|
104
|
+
# :default always exists; :index and :show fall back to :default when not
|
|
105
|
+
# declared; any other name must be declared (typo protection).
|
|
106
|
+
def fieldset(name = :default)
|
|
107
|
+
name = (name || :default).to_sym
|
|
108
|
+
return @declared_fieldsets[name] if @declared_fieldsets.key?(name)
|
|
109
|
+
return default_fieldset if %i[default index show].include?(name)
|
|
110
|
+
|
|
111
|
+
known = (@declared_fieldsets.keys + [:default]).uniq
|
|
112
|
+
raise UnknownFieldsetError, "#{model} has no fieldset :#{name} — " \
|
|
113
|
+
"available: #{known.map(&:inspect).join(', ')}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def default_fieldset
|
|
117
|
+
@default_fieldset ||= Fieldset.new(:default, :all)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fieldset_fields(fieldset)
|
|
121
|
+
names = fieldset.all_fields? ? default_field_names : fieldset.field_names
|
|
122
|
+
names.map { |name| field(name) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fieldset_filter_fields(fieldset)
|
|
126
|
+
(fieldset_fields(fieldset) + fieldset.filter_names.map { |name| field(name) })
|
|
127
|
+
.uniq.select(&:filterable?)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fieldset_sortable_fields(fieldset)
|
|
131
|
+
fieldset_fields(fieldset).select(&:sortable?)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ── forms ──────────────────────────────────────────────────────────────
|
|
135
|
+
# Form field selection falls back most-specific-first:
|
|
136
|
+
# the action's own fieldset → :form → :default.
|
|
137
|
+
def form_fieldset(action = nil)
|
|
138
|
+
names = [action, :form, :default].compact
|
|
139
|
+
names.each do |name|
|
|
140
|
+
return @declared_fieldsets[name] if @declared_fieldsets.key?(name)
|
|
141
|
+
end
|
|
142
|
+
default_fieldset
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Editable, permitted fields of the form fieldset, as a strong-params
|
|
146
|
+
# permit list (symbols and nested hashes) — the controller's single
|
|
147
|
+
# source of truth, so form and params can never drift.
|
|
148
|
+
def permitted_params(action, context)
|
|
149
|
+
fields = fieldset_fields(form_fieldset(action))
|
|
150
|
+
fields.select { |f| f.permitted?(context) && f.editable? && f.editable_permitted?(context) && f.form_control }
|
|
151
|
+
.map(&:permit_param)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ── identity ─────────────────────────────────────────────────────────────
|
|
155
|
+
def label_source
|
|
156
|
+
return @label_decl if @label_decl
|
|
157
|
+
|
|
158
|
+
@label_source ||= %i[name title].find { |attr| model.columns_hash.key?(attr.to_s) } ||
|
|
159
|
+
model.columns.find { |col| col.type == :string }&.name&.to_sym
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def label_for(record, context = nil)
|
|
163
|
+
case (source = label_source)
|
|
164
|
+
when Proc then context ? context.instance_exec(record, &source) : source.call(record)
|
|
165
|
+
when Symbol then record.public_send(source)
|
|
166
|
+
else "#{model.model_name.human} ##{record.id}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The field whose cell carries the record link (nil for block labels).
|
|
171
|
+
def label_field_name
|
|
172
|
+
label_source.is_a?(Symbol) ? label_source : nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The icon name (no library prefix) badging this model: the declared `icon`,
|
|
176
|
+
# else the name-based guess in `config.model_icons` (keyed by the singular
|
|
177
|
+
# underscored model name), else `config.model_fallback_icon` (nil = none).
|
|
178
|
+
# Resolved per call so a host's config changes apply without rebuilding.
|
|
179
|
+
def icon
|
|
180
|
+
@icon_decl || CrudComponents.config.model_icons[model.model_name.element] ||
|
|
181
|
+
CrudComponents.config.model_fallback_icon
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── search ───────────────────────────────────────────────────────────────
|
|
185
|
+
# nil when search_in is a custom block (delegation is then impossible).
|
|
186
|
+
def search_in_spec
|
|
187
|
+
return nil if @search_decl.is_a?(Proc)
|
|
188
|
+
|
|
189
|
+
@search_in_spec ||= (@search_decl.presence || default_search_spec)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def default_search_spec
|
|
193
|
+
model.columns.select { |col| %i[string text].include?(col.type) }
|
|
194
|
+
.map { |col| col.name.to_sym }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def searchable?
|
|
198
|
+
@search_decl.is_a?(Proc) || search_in_spec.any?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def apply_search(scope, query_string, permission: nil)
|
|
202
|
+
if @search_decl.is_a?(Proc)
|
|
203
|
+
@search_decl.call(scope.extending(WhereLike), query_string)
|
|
204
|
+
else
|
|
205
|
+
spec = permission ? permitted_search_spec(permission) : search_in_spec
|
|
206
|
+
spec.any? ? LikeSpec.apply(scope, spec, query_string) : scope
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A declared, permission-gated column (`attribute :x, if: :manage`) is
|
|
211
|
+
# hidden everywhere including ?q= — so drop it from the search spec for a
|
|
212
|
+
# user who may not see it. Undeclared columns in the default spec are
|
|
213
|
+
# model-global search by design and stay.
|
|
214
|
+
def permitted_search_spec(permission)
|
|
215
|
+
search_in_spec.reject do |entry|
|
|
216
|
+
entry.is_a?(Symbol) && model.columns_hash.key?(entry.to_s) &&
|
|
217
|
+
@declarations.key?(entry) && !field(entry).permitted?(permission)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── actions ──────────────────────────────────────────────────────────────
|
|
222
|
+
def actions
|
|
223
|
+
@actions ||= begin
|
|
224
|
+
derived = %i[new show edit destroy].to_h { |name| [name, Action.new(name, derived: true)] }
|
|
225
|
+
merged = derived.merge(@declared_actions)
|
|
226
|
+
custom = @declared_actions.keys - derived.keys
|
|
227
|
+
order = [:new, :show, :edit, *custom, :destroy]
|
|
228
|
+
order.to_h { |name| [name, merged.fetch(name)] }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def action(name)
|
|
233
|
+
actions[name.to_sym] || raise(DefinitionError, "#{model} has no action :#{name} — " \
|
|
234
|
+
"available: #{actions.keys.map(&:inspect).join(', ')}")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A fieldset's actions: list is authoritative per kind: listing only row
|
|
238
|
+
# actions curates the row buttons without losing the derived :new button
|
|
239
|
+
# (and vice versa). An empty list hides everything.
|
|
240
|
+
def fieldset_actions(fieldset, on:)
|
|
241
|
+
of_kind = ->(a) { a.public_send("#{on}?") }
|
|
242
|
+
names = fieldset.action_names
|
|
243
|
+
return actions.values.select(&of_kind) if names.nil?
|
|
244
|
+
return [] if names.empty?
|
|
245
|
+
|
|
246
|
+
listed = names.map { |name| action(name) }.select(&of_kind)
|
|
247
|
+
listed.any? ? listed : actions.values.select(&of_kind)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── validation (DefinitionError with a way out, at first build) ──────────
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def validate!
|
|
254
|
+
@declarations.each_key { |name| field(name) }
|
|
255
|
+
validate_renderer_gems!
|
|
256
|
+
validate_fieldsets!
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_renderer_gems!
|
|
260
|
+
@declarations.each do |name, decl|
|
|
261
|
+
gems = RENDERER_GEMS[decl[:options][:as]]
|
|
262
|
+
next unless gems
|
|
263
|
+
next if gems.any? { |gem_name| try_require(gem_name) }
|
|
264
|
+
|
|
265
|
+
raise DefinitionError, "#{model}.#{name}: as: :#{decl[:options][:as]} needs one of these gems " \
|
|
266
|
+
"in your bundle: #{gems.join(', ')}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def try_require(gem_name)
|
|
271
|
+
require gem_name
|
|
272
|
+
true
|
|
273
|
+
rescue LoadError
|
|
274
|
+
false
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def validate_fieldsets!
|
|
278
|
+
@declared_fieldsets.each_value do |fs|
|
|
279
|
+
fs.field_names.each { |name| field(name) } unless fs.all_fields?
|
|
280
|
+
fs.filter_names.each do |name|
|
|
281
|
+
next if field(name).filterable?
|
|
282
|
+
|
|
283
|
+
raise DefinitionError, "#{model}: fieldset :#{fs.name} lists :#{name} under filters:, " \
|
|
284
|
+
'but that field is not filterable — give it a filter facet first'
|
|
285
|
+
end
|
|
286
|
+
fs.action_names&.each { |name| action(name) }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def resolve_field(name)
|
|
291
|
+
decl = @declarations[name] || {}
|
|
292
|
+
field_class_for(name, decl[:facets] || {})
|
|
293
|
+
.new(name, model, decl[:options] || {}, decl[:facets] || {})
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def field_class_for(name, facets)
|
|
297
|
+
if name.to_s.include?('.')
|
|
298
|
+
Fields::PathField
|
|
299
|
+
elsif model.defined_enums.key?(name.to_s)
|
|
300
|
+
Fields::EnumField
|
|
301
|
+
elsif (reflection = model.reflect_on_association(name))
|
|
302
|
+
reflection.collection? ? Fields::HasManyField : Fields::BelongsToField
|
|
303
|
+
elsif model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(name)
|
|
304
|
+
Fields::AttachmentField
|
|
305
|
+
elsif (column = model.columns_hash[name.to_s])
|
|
306
|
+
column_field_class(column)
|
|
307
|
+
elsif facets[:render] || model.method_defined?(name)
|
|
308
|
+
Fields::ComputedField
|
|
309
|
+
else
|
|
310
|
+
raise DefinitionError, "#{model} has no column, enum, association or public method '#{name}'. " \
|
|
311
|
+
"Computed fields need a render facet: attribute(:#{name}) { |record| ... }"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def column_field_class(column)
|
|
316
|
+
case column.type
|
|
317
|
+
when :text then Fields::TextField
|
|
318
|
+
when :integer, :float, :decimal then Fields::NumericField
|
|
319
|
+
when :date, :datetime, :timestamp, :timestamptz then Fields::DateField
|
|
320
|
+
when :boolean then Fields::BooleanField
|
|
321
|
+
when :json, :jsonb then Fields::JsonField
|
|
322
|
+
else Fields::StringField
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def column_field_names
|
|
327
|
+
@column_field_names ||= begin
|
|
328
|
+
by_foreign_key = {}
|
|
329
|
+
polymorphic_type_columns = []
|
|
330
|
+
model.reflect_on_all_associations(:belongs_to).each do |ref|
|
|
331
|
+
by_foreign_key[ref.foreign_key.to_s] = ref.name
|
|
332
|
+
polymorphic_type_columns << ref.foreign_type.to_s if ref.polymorphic?
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
model.columns.filter_map do |col|
|
|
336
|
+
next nil if polymorphic_type_columns.include?(col.name)
|
|
337
|
+
|
|
338
|
+
by_foreign_key[col.name] || col.name.to_sym
|
|
339
|
+
end.uniq
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# Extended onto every scope handed to filter/search blocks, so custom query
|
|
3
|
+
# logic keeps the safe ILIKE machinery without hand-written SQL:
|
|
4
|
+
#
|
|
5
|
+
# filter do |scope, value|
|
|
6
|
+
# scope.where(active: true).where_like({ authors: :name }, value)
|
|
7
|
+
# end
|
|
8
|
+
module WhereLike
|
|
9
|
+
def where_like(spec, value)
|
|
10
|
+
CrudComponents::LikeSpec.apply(self, spec, value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|