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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. 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