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,113 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# The declarative mini-language shared by `filter like:` and `search_in`:
|
|
3
|
+
# case-insensitive contains across columns, joining associations as needed.
|
|
4
|
+
#
|
|
5
|
+
# :title own column
|
|
6
|
+
# %i[title subtitle] several own columns, OR-combined
|
|
7
|
+
# { authors: %i[name email] } join, explicit columns
|
|
8
|
+
# :publisher join, delegate to Publisher's search_in
|
|
9
|
+
# { user: :address } nested join, delegate to Address
|
|
10
|
+
#
|
|
11
|
+
# Specs never contain SQL strings; conditions are built through Arel with
|
|
12
|
+
# LIKE wildcards escaped, so they are parameterized end to end.
|
|
13
|
+
module LikeSpec
|
|
14
|
+
# Only *delegation* hops (an association name resolved through the target's
|
|
15
|
+
# search_in) can form a cycle; explicit nesting is bounded by the literal
|
|
16
|
+
# spec. So the guard counts delegations only.
|
|
17
|
+
MAX_DELEGATIONS = 5
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def apply(scope, spec, value)
|
|
22
|
+
model = scope.respond_to?(:model) ? scope.model : scope
|
|
23
|
+
entries = expand(model, spec)
|
|
24
|
+
return scope if entries.empty?
|
|
25
|
+
|
|
26
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
|
|
27
|
+
condition = entries.map { |entry| entry.arel_condition(pattern) }.reduce(:or)
|
|
28
|
+
joins = entries.filter_map(&:join_fragment).reduce({}) { |acc, j| deep_merge(acc, j) }
|
|
29
|
+
|
|
30
|
+
return scope.where(condition) if joins.empty?
|
|
31
|
+
|
|
32
|
+
# distinct only matters once a join can multiply rows
|
|
33
|
+
scope.left_joins(joins).where(condition).distinct
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Entry = Struct.new(:path, :klass, :column) do
|
|
37
|
+
# escape '\' must be explicit: sanitize_sql_like escapes with a
|
|
38
|
+
# backslash, which is not the default LIKE escape char on SQLite.
|
|
39
|
+
def arel_condition(pattern)
|
|
40
|
+
Arel::Table.new(klass.table_name)[column].matches(pattern, '\\')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def join_fragment
|
|
44
|
+
return nil if path.empty?
|
|
45
|
+
|
|
46
|
+
path.reverse.reduce(nil) { |inner, assoc| inner ? { assoc => inner } : assoc }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves a spec into flat [path, klass, column] entries, expanding
|
|
51
|
+
# association names without columns through the target's search_in spec.
|
|
52
|
+
# `delegations` counts only delegation hops (the cycle risk).
|
|
53
|
+
def expand(model, spec, path = [], delegations = 0)
|
|
54
|
+
if delegations > MAX_DELEGATIONS
|
|
55
|
+
raise DefinitionError, "search_in/like delegation more than #{MAX_DELEGATIONS} levels deep " \
|
|
56
|
+
"starting at #{model} — most likely a delegation cycle"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Array.wrap(spec).flat_map do |item|
|
|
60
|
+
case item
|
|
61
|
+
when Symbol, String then expand_name(model, item.to_sym, path, delegations)
|
|
62
|
+
when Hash then item.flat_map { |assoc, sub| expand_assoc(model, assoc.to_sym, sub, path, delegations) }
|
|
63
|
+
else
|
|
64
|
+
raise DefinitionError, "invalid like-spec element #{item.inspect} for #{model} — " \
|
|
65
|
+
'use column symbols, association symbols, or { assoc => columns } hashes'
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def expand_name(model, name, path, delegations)
|
|
71
|
+
if model.columns_hash.key?(name.to_s)
|
|
72
|
+
[Entry.new(path, model, name)]
|
|
73
|
+
elsif (reflection = model.reflect_on_association(name))
|
|
74
|
+
delegate(model, reflection, path, delegations)
|
|
75
|
+
else
|
|
76
|
+
raise DefinitionError, "like-spec references '#{name}', which is neither a column nor " \
|
|
77
|
+
"an association of #{model}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Explicit nesting ({ assoc => columns }) — bounded by the spec, not a
|
|
82
|
+
# cycle risk, so it does not count against the delegation limit.
|
|
83
|
+
def expand_assoc(model, assoc, sub, path, delegations)
|
|
84
|
+
reflection = model.reflect_on_association(assoc)
|
|
85
|
+
raise DefinitionError, "like-spec references association '#{assoc}', " \
|
|
86
|
+
"which #{model} does not have" unless reflection
|
|
87
|
+
|
|
88
|
+
expand(reflection.klass, sub, path + [assoc], delegations)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Association name without columns: use the target model's search_in spec.
|
|
92
|
+
def delegate(model, reflection, path, delegations)
|
|
93
|
+
target = reflection.klass
|
|
94
|
+
target_spec = Structure.for(target).search_in_spec
|
|
95
|
+
if target_spec.nil?
|
|
96
|
+
raise DefinitionError, "cannot delegate like-spec to #{model}##{reflection.name}: " \
|
|
97
|
+
"#{target}'s search_in is a custom block — spell the columns out, " \
|
|
98
|
+
"e.g. { #{reflection.name}: %i[...] }"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
expand(target, target_spec, path + [reflection.name], delegations + 1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deep_merge(left, right)
|
|
105
|
+
normalize = ->(j) { j.is_a?(Hash) ? j : { j => {} } }
|
|
106
|
+
l = normalize.call(left)
|
|
107
|
+
normalize.call(right).each do |key, value|
|
|
108
|
+
l[key] = l.key?(key) ? deep_merge(l[key], value) : value
|
|
109
|
+
end
|
|
110
|
+
l
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# Soft-dependency markup rendering: works with whichever gem the host app
|
|
3
|
+
# already has; raises at structure build (not here) when none is present.
|
|
4
|
+
module Markup
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def markdown(source)
|
|
8
|
+
source = source.to_s
|
|
9
|
+
if defined?(Commonmarker)
|
|
10
|
+
Commonmarker.to_html(source)
|
|
11
|
+
elsif defined?(CommonMarker)
|
|
12
|
+
CommonMarker.render_html(source)
|
|
13
|
+
elsif defined?(Redcarpet)
|
|
14
|
+
@redcarpet ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML.new)
|
|
15
|
+
@redcarpet.render(source)
|
|
16
|
+
elsif defined?(Kramdown)
|
|
17
|
+
Kramdown::Document.new(source).to_html
|
|
18
|
+
else
|
|
19
|
+
source
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def asciidoc(source)
|
|
24
|
+
return source.to_s unless defined?(Asciidoctor)
|
|
25
|
+
|
|
26
|
+
Asciidoctor.convert(source.to_s, safe: :safe)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def highlight_json(pretty)
|
|
30
|
+
return nil unless defined?(Rouge)
|
|
31
|
+
|
|
32
|
+
formatter = Rouge::Formatters::HTMLInline.new(Rouge::Themes::Github.new)
|
|
33
|
+
formatter.format(Rouge::Lexers::JSON.new.lex(pretty))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# `include CrudComponents::Model` adds the crud_structure DSL. It is only
|
|
3
|
+
# needed to declare things — rendering works for any ActiveRecord model.
|
|
4
|
+
module Model
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def crud_structure(&block)
|
|
11
|
+
raise ArgumentError, 'crud_structure requires a block' unless block
|
|
12
|
+
|
|
13
|
+
if instance_variable_defined?(:@_crud_structure_block) && @_crud_structure_block
|
|
14
|
+
raise DefinitionError, "crud_structure already declared on #{self} — merge the two blocks " \
|
|
15
|
+
'(the second one would otherwise silently win)'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@_crud_structure_block = block
|
|
19
|
+
@_crud_structure = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The strong-params permit list is {CrudComponents.permitted_attributes}
|
|
23
|
+
# (a model class works whether or not it includes this concern), so there
|
|
24
|
+
# is one way to ask for it — no model-side alias to drift from it.
|
|
25
|
+
|
|
26
|
+
# For tests and code reloading.
|
|
27
|
+
def reset_crud_structure!
|
|
28
|
+
@_crud_structure_block = nil
|
|
29
|
+
@_crud_structure = nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# The minimal context `if:` conditions run in when there is no view around
|
|
3
|
+
# (i.e. inside Query). Exposes `can?` backed by the passed ability; without
|
|
4
|
+
# an ability nothing is permitted — safe by default.
|
|
5
|
+
class PermissionContext
|
|
6
|
+
def initialize(ability)
|
|
7
|
+
@ability = ability
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def can?(action, subject)
|
|
11
|
+
return false unless @ability
|
|
12
|
+
|
|
13
|
+
@ability.can?(action, subject)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Shared evaluation of `if:`/`editable:` options on attributes and actions. A
|
|
18
|
+
# callable may depend on the **ability** (`can?` is available in its context),
|
|
19
|
+
# the **record** (a one-arity lambda / `it`-proc receives it), or both:
|
|
20
|
+
# if: ->(book) { can?(:edit, book) && book.published? }
|
|
21
|
+
#
|
|
22
|
+
# `recordless:` is what a *record-dependent* condition evaluates to when there
|
|
23
|
+
# is no record to decide about — a column-level / strong-params check, where
|
|
24
|
+
# the lambda can't run (it would hit `nil`). It is `true` for visibility
|
|
25
|
+
# (`if:` — show the column; the record/form surfaces still apply the
|
|
26
|
+
# per-record decision) and `false` for editability (`editable:` — a
|
|
27
|
+
# class-level permit list must not grant per-record write access, so stay
|
|
28
|
+
# safe). Ability-only conditions (Symbol, zero-arity lambda) don't need a
|
|
29
|
+
# record and are evaluated regardless.
|
|
30
|
+
module Permission
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def permitted?(condition, model, context, record = nil, recordless: true)
|
|
34
|
+
return true if condition.nil?
|
|
35
|
+
|
|
36
|
+
case condition
|
|
37
|
+
when Symbol
|
|
38
|
+
# Sugar for can?(symbol, record) — the record being decided about — so a
|
|
39
|
+
# symbol matches the derived action check (can?(:edit, @book)). For a
|
|
40
|
+
# column-level decision there is no record, so fall back to the model
|
|
41
|
+
# class (can?(symbol, Book)).
|
|
42
|
+
context.can?(condition, record || model)
|
|
43
|
+
when Proc
|
|
44
|
+
if condition.lambda? && condition.arity.zero?
|
|
45
|
+
context.instance_exec(&condition) # ability-only — no record needed
|
|
46
|
+
elsif record.nil?
|
|
47
|
+
recordless # record-dependent, but nothing to decide on
|
|
48
|
+
else
|
|
49
|
+
context.instance_exec(record, &condition) # receives the record; can? still in scope
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
if !condition.respond_to?(:call)
|
|
53
|
+
!!condition
|
|
54
|
+
elsif record.nil?
|
|
55
|
+
recordless
|
|
56
|
+
else
|
|
57
|
+
condition.call(record)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# Resolves a list of actions for a subject (record or model class) into
|
|
4
|
+
# renderable items. Derived actions are self-disabling: no permission or
|
|
5
|
+
# no resolvable route → no button, never a broken link.
|
|
6
|
+
class Actions < Base
|
|
7
|
+
Item = Struct.new(:action, :path, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(view:, subject:, structure: nil, actions: nil, owner: nil, suppress_show: false)
|
|
10
|
+
super(view: view)
|
|
11
|
+
@subject = subject
|
|
12
|
+
@model = subject.is_a?(Class) ? subject : subject.class
|
|
13
|
+
@structure = structure || Structure.for(@model)
|
|
14
|
+
@list = actions
|
|
15
|
+
@owner = owner
|
|
16
|
+
@suppress_show = suppress_show
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def kind = @subject.is_a?(Class) ? :collection : :row
|
|
20
|
+
|
|
21
|
+
def items
|
|
22
|
+
@items ||= list.filter_map do |action|
|
|
23
|
+
next if action.name == :show && action.derived? && @suppress_show
|
|
24
|
+
next unless action.permitted?(permission_context, @subject)
|
|
25
|
+
|
|
26
|
+
path = RouteResolver.action_path(view, action,
|
|
27
|
+
record: kind == :row ? @subject : nil,
|
|
28
|
+
model: @model, owner: @owner)
|
|
29
|
+
next unless path
|
|
30
|
+
|
|
31
|
+
Item.new(action: action, path: path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def any? = items.any?
|
|
36
|
+
|
|
37
|
+
# Bootstrap btn-group only renders cleanly when every child is a direct
|
|
38
|
+
# `.btn`. A non-GET action is a button_to *form*, which breaks the
|
|
39
|
+
# group's edge radii — so we only join when all actions are GET links.
|
|
40
|
+
def joinable?
|
|
41
|
+
items.all? { |item| item.action.http_method == :get }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def list
|
|
47
|
+
@list || @structure.fieldset_actions(@structure.default_fieldset, on: kind)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# Presenters are the single local each partial receives — they hold the
|
|
4
|
+
# logic so the templates stay dumb markup.
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :view
|
|
7
|
+
|
|
8
|
+
def initialize(view:)
|
|
9
|
+
@view = view
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def config = CrudComponents.config
|
|
13
|
+
def css = config.css
|
|
14
|
+
|
|
15
|
+
# can?-shaped context for `if:` checks; the view itself when CanCanCan
|
|
16
|
+
# (or anything can?-shaped) is around.
|
|
17
|
+
def permission_context
|
|
18
|
+
@permission_context ||= view.respond_to?(:can?) ? view : PermissionContext.new(nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# An ability object for Query (auto mode).
|
|
22
|
+
def ability
|
|
23
|
+
if view.respond_to?(:current_ability)
|
|
24
|
+
view.current_ability
|
|
25
|
+
elsif view.respond_to?(:can?)
|
|
26
|
+
ViewAbility.new(view)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ViewAbility
|
|
31
|
+
def initialize(view) = @view = view
|
|
32
|
+
|
|
33
|
+
def can?(action, subject) = @view.can?(action, subject)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Where the gem's own field partials live — used to tell a host override
|
|
37
|
+
# apart from the shipped partial (so the fast path only skips the latter).
|
|
38
|
+
GEM_VIEW_ROOT = File.expand_path('../../../app/views', __dir__).freeze
|
|
39
|
+
|
|
40
|
+
# Renders one field value: the render block (in view context, record as
|
|
41
|
+
# argument), a fast inline renderer (built-in types, no host override), or
|
|
42
|
+
# the renderer partial `crud_components/fields/_<name>`. `cell_context`
|
|
43
|
+
# (a CellContext or nil) lets value renderers build click-to-filter links;
|
|
44
|
+
# it is nil on surfaces without a query.
|
|
45
|
+
def render_cell(field, record, surface:, cell_context: nil)
|
|
46
|
+
# The render block gets the record *and* the field's value — so a block on
|
|
47
|
+
# a DynamicColumn can read its `preload:`-ed value without an `as:` partial.
|
|
48
|
+
# Extra arg is harmless for one-arg blocks/procs (Proc ignores surplus args).
|
|
49
|
+
return view.instance_exec(record, field.value(record), &field.render_block) if field.render_block
|
|
50
|
+
|
|
51
|
+
renderer = field.renderer(record) || :string
|
|
52
|
+
locals = { value: field.value(record), record: record, field: field,
|
|
53
|
+
surface: surface, cell_context: cell_context }
|
|
54
|
+
if fast_cell?(renderer)
|
|
55
|
+
cells.render(renderer, **locals)
|
|
56
|
+
else
|
|
57
|
+
view.render("crud_components/fields/#{renderer}", **locals)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Renders one filter control partial `crud_components/filters/_<control>`.
|
|
62
|
+
def render_filter_control(field, query, form_id: nil, compact: false, autosubmit: false)
|
|
63
|
+
view.render("crud_components/filters/#{field.filter_control}",
|
|
64
|
+
field: field, query: query, form_id: form_id, compact: compact,
|
|
65
|
+
autosubmit: autosubmit, param_name: query.param_name(field.name.to_s),
|
|
66
|
+
css: css)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def cells
|
|
72
|
+
@cells ||= Cells.new(view)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Fast inline path only for built-in renderers the host hasn't overridden.
|
|
76
|
+
def fast_cell?(renderer)
|
|
77
|
+
config.fast_cells && Cells.handles?(renderer) && !host_overrides_field_partial?(renderer)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Does the resolved fields/_<renderer> partial come from the host app
|
|
81
|
+
# rather than the gem? Memoized per presenter (one table = one instance).
|
|
82
|
+
def host_overrides_field_partial?(renderer)
|
|
83
|
+
(@field_partial_override ||= {}).fetch(renderer) do
|
|
84
|
+
@field_partial_override[renderer] =
|
|
85
|
+
begin
|
|
86
|
+
template = view.lookup_context.find(renderer.to_s, ['crud_components/fields'], true)
|
|
87
|
+
!template.identifier.to_s.start_with?(GEM_VIEW_ROOT)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
true # can't tell → use the (overridable) partial, never wrong output
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# Passed to value renderers so a cell can offer click-to-filter (enum
|
|
4
|
+
# badges, boolean icons) — respecting the active query's fieldset
|
|
5
|
+
# whitelist and param_prefix. Nil on surfaces without a query (the record
|
|
6
|
+
# page, static collections), so renderers must null-check it.
|
|
7
|
+
class CellContext
|
|
8
|
+
def initialize(view:, query:)
|
|
9
|
+
@view = view
|
|
10
|
+
@query = query
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Is this field one the current query would actually act on?
|
|
14
|
+
def filterable?(field)
|
|
15
|
+
@query.filter_fields.include?(field)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A URL that adds (or replaces) this field's filter, keeping every other
|
|
19
|
+
# active param — prefixed correctly for multi-collection pages.
|
|
20
|
+
def filter_url(field, value)
|
|
21
|
+
params = @view.request.query_parameters.merge(
|
|
22
|
+
@query.param_name(field.name.to_s) => value.to_s
|
|
23
|
+
)
|
|
24
|
+
"#{@view.request.path}?#{params.to_query}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# Fast inline cell renderers — Ruby equivalents of the
|
|
4
|
+
# crud_components/fields/_*.html.erb partials, built with the tag/link
|
|
5
|
+
# helpers so a big table skips one partial render per cell (the dominant
|
|
6
|
+
# cost: a partial cell is ~200µs, this is ~10–20µs). Output matches the
|
|
7
|
+
# partials (same elements, classes, links, escaping).
|
|
8
|
+
#
|
|
9
|
+
# Used only when the renderer is one of these built-ins AND the host hasn't
|
|
10
|
+
# overridden its partial (Base#render_cell checks both). markdown/asciidoc
|
|
11
|
+
# and any custom `as:` renderer keep using their partials.
|
|
12
|
+
class Cells
|
|
13
|
+
BUILTINS = %i[string text number boolean date datetime enum
|
|
14
|
+
association association_list json attachment].freeze
|
|
15
|
+
|
|
16
|
+
def self.handles?(renderer) = BUILTINS.include?(renderer)
|
|
17
|
+
|
|
18
|
+
def initialize(view)
|
|
19
|
+
@v = view
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render(renderer, value:, record:, field:, surface:, cell_context:)
|
|
23
|
+
public_send(renderer, value, record, field, surface, cell_context)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def string(value, _record, _field, surface, _cc)
|
|
27
|
+
return dash if blank?(value)
|
|
28
|
+
|
|
29
|
+
surface == :collection ? @v.truncate(value.to_s, length: 120) : esc(value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def text(value, _record, _field, surface, _cc)
|
|
33
|
+
return dash if blank?(value)
|
|
34
|
+
return @v.truncate(value.to_s, length: 120) if surface == :collection
|
|
35
|
+
|
|
36
|
+
@v.tag.div(esc(value), class: 'crud-text', style: 'white-space: pre-line')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def number(value, _record, field, _surface, _cc)
|
|
40
|
+
return dash if value.nil?
|
|
41
|
+
|
|
42
|
+
o = field.renderer_options
|
|
43
|
+
formatted = o[:digits] ? @v.number_with_precision(value, precision: o[:digits], delimiter: ',')
|
|
44
|
+
: @v.number_with_delimiter(value)
|
|
45
|
+
o[:unit] ? @v.safe_join([formatted, " #{o[:unit]}"]) : @v.safe_join([formatted])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def boolean(value, _record, field, _surface, cc)
|
|
49
|
+
return dash if value.nil?
|
|
50
|
+
|
|
51
|
+
icon = @v.tag.span(value ? '✓' : '✗', class: value ? css.boolean_true : css.boolean_false)
|
|
52
|
+
filter_link(cc, field, value, icon) { icon }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def date(value, _record, _field, _surface, _cc)
|
|
56
|
+
value.nil? ? dash : esc(@v.l(value.to_date))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def datetime(value, _record, _field, _surface, _cc)
|
|
60
|
+
value.nil? ? dash : esc(@v.l(value, format: :short))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def enum(value, _record, field, _surface, cc)
|
|
64
|
+
return dash if value.nil?
|
|
65
|
+
|
|
66
|
+
label = field.respond_to?(:human_value) ? field.human_value(value) : value
|
|
67
|
+
badge = @v.tag.span(label, class: css.badge)
|
|
68
|
+
filter_link(cc, field, value, badge,
|
|
69
|
+
title: @v.t('crud_components.filter_by', name: label, default: "Filter by #{label}")) { badge }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def association(value, record, field, _surface, _cc)
|
|
73
|
+
return dash if value.nil?
|
|
74
|
+
|
|
75
|
+
label = @v.crud_association_label(field, value)
|
|
76
|
+
path = @v.crud_record_path(value, owner: record)
|
|
77
|
+
path ? @v.link_to(label, path, data: { turbo_action: 'advance' }) : esc(label)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def association_list(value, record, field, surface, _cc)
|
|
81
|
+
items = value.to_a
|
|
82
|
+
return dash if items.empty?
|
|
83
|
+
|
|
84
|
+
shown = surface == :collection ? items.first(3) : items
|
|
85
|
+
links = @v.safe_join(shown.map { |item| association_item(field, item, record) }, ', ')
|
|
86
|
+
return links if items.size <= shown.size
|
|
87
|
+
|
|
88
|
+
more = @v.t('crud_components.more', count: items.size - shown.size, default: '+%{count} more')
|
|
89
|
+
index_path = @v.crud_association_index_path(record, field)
|
|
90
|
+
more_html = index_path ? @v.link_to(more, index_path, class: css.muted, data: { turbo_action: 'advance' })
|
|
91
|
+
: @v.tag.span(more, class: css.muted)
|
|
92
|
+
@v.safe_join([links, ' ', more_html])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def json(value, _record, _field, surface, _cc)
|
|
96
|
+
return dash if value.nil?
|
|
97
|
+
|
|
98
|
+
pretty = value.is_a?(String) ? value : JSON.pretty_generate(value)
|
|
99
|
+
pretty = @v.truncate(pretty, length: 120) if surface == :collection
|
|
100
|
+
highlighted = CrudComponents::Markup.highlight_json(pretty)
|
|
101
|
+
inner = highlighted ? @v.raw(highlighted) : pretty
|
|
102
|
+
@v.tag.pre(@v.tag.code(inner), class: 'crud-json mb-0')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def attachment(value, _record, field, surface, _cc)
|
|
106
|
+
return dash unless value.respond_to?(:attached?) && value.attached?
|
|
107
|
+
|
|
108
|
+
if field.respond_to?(:many?) && field.many?
|
|
109
|
+
thumbs = value.map { |a| attachment_thumb(a, surface) }
|
|
110
|
+
@v.tag.span(@v.safe_join(thumbs), class: 'd-inline-flex flex-wrap gap-1')
|
|
111
|
+
else
|
|
112
|
+
attachment_thumb(value, surface)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def config = CrudComponents.config
|
|
119
|
+
def css = config.css
|
|
120
|
+
|
|
121
|
+
def blank?(value) = value.nil? || value == ''
|
|
122
|
+
|
|
123
|
+
# The muted em-dash shown for a nil/blank value.
|
|
124
|
+
def dash = @v.tag.span('—', class: css.muted)
|
|
125
|
+
|
|
126
|
+
# html_escape — matches ERB `<%= value %>` (passes SafeBuffers through).
|
|
127
|
+
def esc(value) = ERB::Util.html_escape(value)
|
|
128
|
+
|
|
129
|
+
# Wrap `content` in a click-to-filter link when the query would act on the
|
|
130
|
+
# field, else yield the bare content (the no-JS / no-query path).
|
|
131
|
+
def filter_link(cell_context, field, value, content, **link_options)
|
|
132
|
+
return yield unless cell_context&.filterable?(field)
|
|
133
|
+
|
|
134
|
+
@v.link_to(content, cell_context.filter_url(field, value),
|
|
135
|
+
class: css.filter_link, data: { turbo_action: 'advance' }, **link_options)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def association_item(field, item, owner)
|
|
139
|
+
label = @v.crud_association_label(field, item)
|
|
140
|
+
path = @v.crud_record_path(item, owner: owner)
|
|
141
|
+
path ? @v.link_to(label, path, data: { turbo_action: 'advance' }) : label.to_s
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def attachment_thumb(attachment, surface)
|
|
145
|
+
style = surface == :record ? 'max-height: 16rem' : 'max-height: 2.5rem'
|
|
146
|
+
if attachment.image?
|
|
147
|
+
@v.image_tag(attachment, class: 'rounded crud-image', style: style)
|
|
148
|
+
elsif attachment.previewable? && CrudComponents.previews_available?
|
|
149
|
+
@v.image_tag(attachment.preview(resize_to_limit: [600, 600]), class: 'rounded crud-image', style: style)
|
|
150
|
+
else
|
|
151
|
+
@v.link_to(@v.rails_blob_path(attachment, disposition: 'attachment'),
|
|
152
|
+
class: 'd-inline-flex align-items-center gap-1 text-decoration-none') do
|
|
153
|
+
@v.safe_join([@v.tag.i('', class: "#{css.icon_prefix}#{@v.crud_file_icon(attachment.filename)}"),
|
|
154
|
+
@v.tag.span(attachment.filename)])
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|