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,327 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # A column that reaches *through* associations: a dotted name like
4
+ # `publisher.name` or `authors.email`. The leading segments are associations
5
+ # on the model; the last is an attribute (or method) on the target.
6
+ #
7
+ # A single-valued path (belongs_to / has_one) **delegates to the target
8
+ # model's own field** for that attribute: `publisher.founded_on` renders,
9
+ # filters and sorts exactly like Publisher's `founded_on` column does — a date
10
+ # cell, a date-range filter, an ORDER BY the date — and `publisher.price` keeps
11
+ # the target's `unit:`/`digits:` formatting. The path column can still override
12
+ # any of it (`as:`, a `filter`/`sort`/`render` facet, or its own options) —
13
+ # **override > target field > default**. A collection path (has_many / habtm)
14
+ # renders the values as a list and filters by contains-match through the join.
15
+ # The association is eager-loaded automatically.
16
+ #
17
+ # When the leaf attribute *is* the target's label field (`publisher.name`),
18
+ # the cell renders a link to that record — its model icon then a link to its
19
+ # show page — so a path column doubles as a jump-to-the-object.
20
+ #
21
+ # Two limits (see #validate!): the chain may be at most `config.max_path_depth`
22
+ # associations deep, and it may cross **at most one to-many** association —
23
+ # belongs_to/has_one chain freely, but a second has_many/habtm would fan a
24
+ # list out into a meaningless list-of-lists. `habtm → one` (authors.publisher.name)
25
+ # is fine; `habtm → many` is not.
26
+ class PathField < ComputedField
27
+ # Target field flavors a single-valued path delegates render/filter/sort to.
28
+ # belongs_to/has_one/attachment/json/computed targets keep the path's own
29
+ # value-type rendering and contains-match filtering (no delegation).
30
+ SCALAR_TARGETS = [StringField, TextField, NumericField, DateField,
31
+ BooleanField, EnumField].freeze
32
+
33
+ def initialize(name, model, options = {}, facets = {})
34
+ super
35
+ @segments = name.to_s.split('.').map(&:to_sym)
36
+ validate!
37
+ end
38
+
39
+ # The list of values reached by the path: an Array for a collection path,
40
+ # the single value (or nil) otherwise.
41
+ def value(record)
42
+ values = leaf_values(record)
43
+ collection? ? values : values.first
44
+ end
45
+
46
+ # Single value: the target field's renderer (date/number/email/…) when it's
47
+ # a scalar column — so the path renders like the target — unless overridden.
48
+ # Collection / non-scalar target: fall back to the inferred value-type
49
+ # renderer (ComputedField).
50
+ def renderer(record = nil)
51
+ return options[:as] if options[:as]
52
+ return nil if render_block
53
+
54
+ return target_field.renderer if delegating?
55
+
56
+ super
57
+ end
58
+
59
+ # Target-field options (unit/digits/…) as the base, overridden by any the
60
+ # path column declares itself — `override > target field`.
61
+ def renderer_options
62
+ own = super
63
+ delegating? ? target_field.renderer_options.merge(own) : own
64
+ end
65
+
66
+ # Single paths render through the renderer; collection paths render as a
67
+ # joined list, and a path to the target's label field renders a link.
68
+ def render_block
69
+ return facets[:render] if facets[:render]
70
+ return list_renderer if collection?
71
+ return label_link_renderer if link_to_target?
72
+
73
+ nil
74
+ end
75
+
76
+ # @api private — runs in the view context (`view`), via the render block.
77
+ def render_list(view, record)
78
+ items = Array(value(record)).map { |v| v.to_s.strip }.reject(&:blank?)
79
+ return view.tag.span('—', class: CrudComponents.config.css.muted) if items.empty?
80
+
81
+ # ask the target's field how it renders (email → mailto, url → link)
82
+ semantic = target_field&.renderer
83
+ view.safe_join(items.map { |item| link_value(view, semantic, item) }, ', ')
84
+ end
85
+
86
+ # @api private — the label-field link (model icon + link to the record's
87
+ # show page), runs in the view context via the render block.
88
+ def render_list_label(view, record)
89
+ target = target_record(record)
90
+ muted = CrudComponents.config.css.muted
91
+ return view.tag.span('—', class: muted) if target.nil?
92
+
93
+ label = value(record).to_s
94
+ icon = view.crud_model_icon(target_model)
95
+ inner = view.safe_join([icon, view.tag.span(label)].compact, icon ? ' ' : '')
96
+ path = view.crud_record_path(target)
97
+ path ? view.link_to(inner, path, data: { turbo_action: 'advance' }) : inner
98
+ end
99
+
100
+ # Header: a breadcrumb "Parent › Attribute" (Pipedrive-style). The picker
101
+ # groups by `group_label` and shows the short `picker_label`, so it isn't
102
+ # repeated there.
103
+ def human_name
104
+ return options[:label] if options[:label].is_a?(String)
105
+
106
+ "#{group_label} › #{picker_label}"
107
+ end
108
+
109
+ def group_label
110
+ reflections.map { |ref| ref.active_record.human_attribute_name(ref.name) }.join(' › ')
111
+ end
112
+
113
+ def picker_label = target_model.human_attribute_name(attribute_name)
114
+
115
+ # Picker grouping: a path column sits under its target model's group, next
116
+ # to the association column that anchors it.
117
+ def group_model = target_model
118
+
119
+ # Eager-load the association chain so a whole page costs one query, not one
120
+ # per row (e.g. `publisher.founded_on` → includes(:publisher)).
121
+ def eager_load
122
+ spec = assoc_segments.reverse.reduce(nil) { |inner, seg| inner ? { seg => inner } : seg }
123
+ spec ? [spec] : []
124
+ end
125
+
126
+ # ── filtering ─────────────────────────────────────────────────────────────
127
+ # Single-valued scalar paths offer the target field's own control (a date
128
+ # range, an enum select, …) and apply it through the association; collection
129
+ # / non-scalar paths keep the safe contains-match.
130
+ def filterable?
131
+ return false if facets[:filter] == false
132
+ return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
133
+
134
+ true
135
+ end
136
+
137
+ def filter_control
138
+ return :text if filter_facet
139
+ delegating? ? target_field.filter_control : :text
140
+ end
141
+
142
+ def filter_choices(query = nil)
143
+ return nil unless delegating? && !filter_facet
144
+
145
+ target_field.filter_choices(query)
146
+ end
147
+
148
+ def filter_includes_null?
149
+ delegating? && !filter_facet ? target_field.filter_includes_null? : false
150
+ end
151
+
152
+ def nullable?
153
+ delegating? ? target_field.nullable? : super
154
+ end
155
+
156
+ # The target field humanizes its own values (enum labels); a path delegates
157
+ # so a `publisher.status` cell badges the same text the Publisher table does.
158
+ def human_value(value)
159
+ delegating? && target_field.respond_to?(:human_value) ? target_field.human_value(value) : value
160
+ end
161
+
162
+ def apply_filter(scope, exact: nil, geq: nil, leq: nil)
163
+ return super if filter_facet # an author-supplied facet wins
164
+ return delegate_filter(scope, exact: exact, geq: geq, leq: leq) if delegating?
165
+ return scope unless exact
166
+
167
+ LikeSpec.apply(scope, filter_spec, exact)
168
+ end
169
+
170
+ # ── sorting: single-valued paths only ────────────────────────────────────
171
+ def sortable?
172
+ return false if collection? || facets[:sort] == false
173
+ return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
174
+
175
+ true
176
+ end
177
+
178
+ def apply_sort(scope, dir)
179
+ return super if sort_facet
180
+
181
+ joins = eager_load.first
182
+ scope = scope.left_joins(joins) if joins
183
+ scope.reorder(target_model.arel_table[attribute_name].public_send(dir))
184
+ end
185
+
186
+ def collection? = reflections.any?(&:collection?)
187
+ def single? = !collection?
188
+
189
+ private
190
+
191
+ def assoc_segments = @segments[0..-2]
192
+ def attribute_name = @segments.last
193
+
194
+ def reflections
195
+ @reflections ||= begin
196
+ klass = model
197
+ assoc_segments.map do |seg|
198
+ ref = klass.reflect_on_association(seg)
199
+ klass = ref.klass
200
+ ref
201
+ end
202
+ end
203
+ end
204
+
205
+ def target_model = reflections.last.klass
206
+
207
+ # The target model's own field for the leaf attribute — what a single-valued
208
+ # path delegates to. nil when the attribute isn't a real field (a bare
209
+ # method): then the path keeps its value-type rendering / contains filter.
210
+ def target_field
211
+ return @target_field if defined?(@target_field)
212
+
213
+ @target_field = Structure.for(target_model).field(attribute_name)
214
+ rescue DefinitionError
215
+ @target_field = nil
216
+ end
217
+
218
+ # Delegate render/filter/sort to the target field when the path is
219
+ # single-valued and the target is a scalar column field (not an association,
220
+ # attachment, json or computed method).
221
+ def delegating?
222
+ single? && (tf = target_field) && SCALAR_TARGETS.any? { |k| tf.is_a?(k) }
223
+ end
224
+
225
+ # A single-valued path whose leaf attribute *is* the target's label field —
226
+ # rendered as a link to that record.
227
+ def link_to_target?
228
+ single? && !options[:as] && !facets[:render] &&
229
+ Structure.for(target_model).label_field_name == attribute_name
230
+ end
231
+
232
+ # Apply the target field's own filter through the association: filter the
233
+ # target model on its own table (so `where(col => …)` binds correctly), then
234
+ # constrain the root through the association chain — an IN subquery, no JOIN,
235
+ # so it can't multiply rows.
236
+ def delegate_filter(scope, exact:, geq:, leq:)
237
+ matched = target_field.apply_filter(target_model.all, exact: exact, geq: geq, leq: leq)
238
+ constrained = reflections.reverse.reduce(matched) do |sub, ref|
239
+ ref.active_record.where(ref.name => sub)
240
+ end
241
+ scope.where(model.primary_key => constrained)
242
+ end
243
+
244
+ # Walk the path, following each segment over every object reached so far;
245
+ # association collections fan out and flatten. Nils drop.
246
+ def leaf_values(record)
247
+ @segments.reduce([record]) do |objects, seg|
248
+ objects.flat_map do |object|
249
+ next [] if object.nil?
250
+
251
+ value = object.public_send(seg)
252
+ value.is_a?(Enumerable) ? value.to_a : [value]
253
+ end
254
+ end.compact
255
+ end
256
+
257
+ # The associated record a single-valued path reaches (book → book.publisher),
258
+ # or nil when any hop is nil. Used by the label-field link.
259
+ def target_record(record)
260
+ assoc_segments.reduce(record) do |object, seg|
261
+ break nil if object.nil?
262
+
263
+ object.public_send(seg)
264
+ end
265
+ end
266
+
267
+ # The like-spec the path describes, e.g. [:authors, :email] → { authors: :email }.
268
+ def filter_spec
269
+ assoc_segments.reverse.reduce(attribute_name) { |inner, seg| { seg => inner } }
270
+ end
271
+
272
+ def list_renderer
273
+ field = self
274
+ # `self` inside the block is the view (instance_exec'd by render_cell).
275
+ proc { |record| field.render_list(self, record) }
276
+ end
277
+
278
+ def label_link_renderer
279
+ field = self
280
+ proc { |record| field.render_list_label(self, record) }
281
+ end
282
+
283
+ # One list item as a link (mailto / http) when the target name calls for
284
+ # it, else a plain (escaped-on-join) value.
285
+ def link_value(view, semantic, value)
286
+ case semantic
287
+ when :email then view.mail_to(value)
288
+ when :url then value.match?(%r{\Ahttps?://}i) ? view.link_to(value, value, rel: 'noopener', target: '_blank') : value
289
+ else value
290
+ end
291
+ end
292
+
293
+ def validate!
294
+ klass = model
295
+ to_many = 0
296
+ assoc_segments.each do |seg|
297
+ ref = klass.reflect_on_association(seg)
298
+ unless ref
299
+ raise DefinitionError,
300
+ "#{model}: '#{name}' is not a valid column path — '#{seg}' is not an " \
301
+ "association on #{klass}. A path column is association(s) then an attribute, " \
302
+ 'e.g. publisher.name or authors.email.'
303
+ end
304
+ to_many += 1 if ref.collection?
305
+ klass = ref.klass
306
+ end
307
+
308
+ max = CrudComponents.config.max_path_depth
309
+ if assoc_segments.size > max
310
+ raise DefinitionError,
311
+ "#{model}: column path '#{name}' chains #{assoc_segments.size} associations; the " \
312
+ "limit is #{max} (config.max_path_depth — raise it if you need deeper paths)."
313
+ end
314
+
315
+ # Chaining belongs_to/has_one is cheap and single-valued; a second
316
+ # to-many hop would fan a list out into a list-of-lists with no sensible
317
+ # flat rendering, sort or filter. One to-many (incl. habtm→one) is fine.
318
+ return unless to_many > 1
319
+
320
+ raise DefinitionError,
321
+ "#{model}: column path '#{name}' crosses #{to_many} to-many associations. At most one " \
322
+ 'has_many/habtm hop is allowed — chain belongs_to/has_one freely, but a to-many may ' \
323
+ 'appear only once (e.g. authors.email, or authors.publisher.name).'
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,41 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # string column: text cell, text input, escaped case-insensitive contains.
4
+ class StringField < Base
5
+ # Name-gated smart renderers: a column literally named `email` (or `*_email`)
6
+ # renders as a `mailto:` link; one named `url`, `website`, `link` or
7
+ # `homepage` renders an http(s) value as a link. Gated on the column *name*,
8
+ # never the value — a column that merely happens to hold a URL or an "@" is
9
+ # left alone, so the behaviour is predictable and you can't accidentally turn
10
+ # arbitrary text into links. `as:` overrides (opt out, or force a renderer).
11
+ URL_NAMES = %w[url website link homepage].freeze
12
+
13
+ # A column named email / url / website / link gets the matching smart
14
+ # renderer by default (a mailto: / http link). `as:` still overrides.
15
+ def default_renderer = smart_renderer || :string
16
+
17
+ # The renderer the column *name* implies (:email / :url), or nil for none.
18
+ # Public so a path column (publisher.email, authors.email) can reuse the same
19
+ # name rules by delegating to this field rather than duplicating them.
20
+ def smart_renderer
21
+ n = name.to_s
22
+ return :email if n == 'email' || n.end_with?('_email')
23
+ return :url if URL_NAMES.include?(n)
24
+
25
+ nil
26
+ end
27
+
28
+ def derived_filterable? = true
29
+ def derived_sortable? = true
30
+ def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
31
+ def form_control = :string
32
+
33
+ def apply_derived_filter(scope, exact: nil, **)
34
+ return scope unless exact
35
+
36
+ # explicit escape char: backslash is not SQLite's default
37
+ scope.where(arel_column.matches(like_pattern(exact), '\\'))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # text column: truncated in collections, line breaks preserved on records.
4
+ class TextField < StringField
5
+ def default_renderer = :text
6
+ def form_control = :text
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ module CrudComponents
2
+ # A named selection of fields and actions. `filters:`
3
+ # extends the filterable set beyond the visible fields ("filter only what
4
+ # you can see" is the default rule).
5
+ class Fieldset
6
+ attr_reader :name, :field_names, :action_spec, :filter_names
7
+
8
+ # @param name [Symbol] the fieldset name.
9
+ # @param fields [Array<Symbol>, :all] the fields, in order (`:all` = every
10
+ # declared/derived field).
11
+ # @param actions [Array<Symbol>, String, nil] a curated list of action names,
12
+ # or a custom partial path; nil keeps the derived actions.
13
+ # @param filters [Array<Symbol>, nil] filterable fields beyond the visible ones.
14
+ def initialize(name, fields = :all, actions: nil, filters: nil)
15
+ @name = name.to_sym
16
+ @field_names = fields == :all ? :all : Array(fields).map(&:to_sym)
17
+ @action_spec = actions
18
+ @filter_names = Array(filters || []).map(&:to_sym)
19
+ end
20
+
21
+ # @return [Boolean] whether this fieldset shows every field.
22
+ def all_fields? = @field_names == :all
23
+
24
+ # The curated action names (`actions: %i[edit destroy]`), or nil when the
25
+ # derived defaults apply.
26
+ # @return [Array<Symbol>, nil]
27
+ def action_names
28
+ @action_spec.is_a?(Array) ? @action_spec.map(&:to_sym) : nil
29
+ end
30
+
31
+ # A custom actions partial (`actions: 'books/actions'`, receiving `record`),
32
+ # or nil.
33
+ # @return [String, nil]
34
+ def custom_actions_partial
35
+ @action_spec.is_a?(String) ? @action_spec : nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,259 @@
1
+ module CrudComponents
2
+ # The everyday view API, included into ActionView by the engine. Every helper
3
+ # builds a presenter and renders a partial you can override via the host app's
4
+ # view path (`app/views/crud_components/…`).
5
+ module Helpers
6
+ # A set of records as a table (or any layout partial you point `layout:` at).
7
+ #
8
+ # @param records [ActiveRecord::Relation] the rows to render. Pass a scope,
9
+ # not a model class, so your authorization and any pre-scoping apply before
10
+ # the gem renders (e.g. `Book.accessible_by(current_ability)`).
11
+ # @param fieldset [Symbol, nil] which declared fieldset to use; defaults to
12
+ # `:index` (or every column when the model declares nothing).
13
+ # @param layout [Symbol] the layout partial under `crud_components/layouts/`
14
+ # — `:table` ships; add your own (e.g. `:cards`) and pass its name.
15
+ # @param query [Symbol, CrudComponents::Query] query mode: `:auto` (default)
16
+ # reads the request params and filters/sorts; `:static` renders no filter row
17
+ # or sort links (params ignored); a {Query} is manual mode (records already
18
+ # filtered). See {Presenters::Collection}.
19
+ # @param param_prefix [Symbol, nil] namespaces this collection's params so two
20
+ # auto collections can share one page.
21
+ # @param actions [Boolean] render the actions column + toolbar (false to place
22
+ # them yourself with {#crud_actions}).
23
+ # @param group_by [Symbol, nil] a column, belongs_to or enum to group rows
24
+ # under collapsible headers.
25
+ # @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] user-defined
26
+ # columns whose data lives outside the model's table (custom properties from
27
+ # a separate store, JSONB, an API). Appended after the declared columns and
28
+ # subject to the same `if:` permission gate; filter/sort only when the column
29
+ # supplies those facets.
30
+ # @param picker [Boolean] render the column-picker gear in the header row
31
+ # (default false). The gear stays put regardless of `picked_columns:`, so it
32
+ # survives across ephemeral and persisted selections alike.
33
+ # @param picked_columns [Symbol, Array<Symbol>] which columns to show when the
34
+ # picker is on: `:auto` (default) reads the `?cols=` submit; an `Array` shows
35
+ # exactly those columns and **never reads the param** — the backend resolved
36
+ # it (from a persisted preference, or from the param via
37
+ # {CrudComponents.selected_columns}). A forged/stale name can only hide or
38
+ # reorder, never reveal a column the `if:` gate forbids.
39
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
40
+ def crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
41
+ actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
42
+ presenter = Presenters::Collection.new(view: self, records: records, fieldset: fieldset,
43
+ query: query, layout: layout, param_prefix: param_prefix,
44
+ actions: actions, group_by: group_by, extra_columns: extra_columns,
45
+ picker: picker, picked_columns: picked_columns)
46
+ render "crud_components/layouts/#{presenter.layout}", collection: presenter
47
+ end
48
+
49
+ # One record as a definition list (or any layout partial you point `layout:`
50
+ # at). Extend by creating your own partial — e.g. `crud_components/_card`
51
+ # — and passing `layout: :card`.
52
+ #
53
+ # @param record [ActiveRecord::Base] the record to show.
54
+ # @param fieldset [Symbol, nil] which fieldset to use; defaults to `:show`.
55
+ # @param actions [Boolean] render the row actions (false to place them with
56
+ # {#crud_actions}).
57
+ # @param layout [Symbol] the partial under `crud_components/` (`:record` ships).
58
+ # @param picked_columns [Symbol, Array<Symbol>] narrow/order the fields shown. A
59
+ # detail view has no inline gear of its own, so pass an `Array` you resolved
60
+ # (e.g. from a standalone {#crud_column_picker} on the page, via
61
+ # {CrudComponents.selected_columns}). `:auto` (default) means "don't narrow" —
62
+ # with no gear here a stray `?cols=` is ignored.
63
+ # @param param_prefix [Symbol, nil] namespaces the `?cols=` param this view reads
64
+ # (match it to the picker's `param_prefix:`).
65
+ # @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] user-defined
66
+ # columns whose data lives outside the model's table, shown as extra rows
67
+ # (same as {#crud_collection}'s `extra_columns:`, for a detail view).
68
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
69
+ def crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto,
70
+ param_prefix: nil, extra_columns: nil)
71
+ presenter = Presenters::Record.new(view: self, record: record, fieldset: fieldset, actions: actions,
72
+ picked_columns: picked_columns, param_prefix: param_prefix,
73
+ extra_columns: extra_columns)
74
+ render "crud_components/#{layout}", record_presenter: presenter
75
+ end
76
+
77
+ # A standalone column picker — the same gear-and-checklist the table renders in
78
+ # its header, but placed wherever you like (e.g. above a `crud_record` detail
79
+ # view). It submits `?cols[]=` to `url` (the current page by default), so a
80
+ # `crud_collection`/`crud_record` on the target page picks it up via
81
+ # `picked_columns:` or the param directly. Persist the choice with
82
+ # {CrudComponents.selected_columns}.
83
+ #
84
+ # @param subject [ActiveRecord::Relation, Class, ActiveRecord::Base] anything the
85
+ # columns belong to — a scope, the model class, or a record.
86
+ # @param fieldset [Symbol, nil] which fieldset's fields to offer (e.g. `:show`).
87
+ # @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] dynamic columns
88
+ # to include in the choices.
89
+ # @param picked_columns [Symbol, Array<Symbol>] which boxes are pre-ticked:
90
+ # `:auto` (default) reflects the current `?cols=`; an `Array` pre-ticks that
91
+ # exact selection (no param read).
92
+ # @param url [String, nil] where the picker form submits; defaults to the current path.
93
+ # @param param_prefix [Symbol, nil] namespaces the `?cols=` param.
94
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
95
+ def crud_column_picker(subject, fieldset: nil, extra_columns: nil, picked_columns: :auto, url: nil, param_prefix: nil)
96
+ relation = if subject.respond_to?(:klass) then subject
97
+ elsif subject.is_a?(Class) then subject.all
98
+ else subject.class.all
99
+ end
100
+ presenter = Presenters::Collection.new(view: self, records: relation, fieldset: fieldset, query: :static,
101
+ extra_columns: extra_columns, picker: true, picked_columns: picked_columns,
102
+ param_prefix: param_prefix, actions: false)
103
+ render 'crud_components/column_picker', collection: presenter, url: (url || request.path)
104
+ end
105
+
106
+ # A standalone labelled filter form (modal / sidebar) — separate from the
107
+ # inline filter row a table renders.
108
+ #
109
+ # @param model [Class] the ActiveRecord model whose fields drive the form.
110
+ # @param fieldset [Symbol, nil] which fieldset's filterable fields to offer.
111
+ # @param query [CrudComponents::Query, nil] reuse an existing query's values;
112
+ # nil reads the request params.
113
+ # @param param_prefix [Symbol, nil] namespaces the form's params.
114
+ # @param layout [Symbol] the partial under `crud_components/` (`:filter` ships).
115
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
116
+ def crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
117
+ presenter = Presenters::Filter.new(view: self, model: model, fieldset: fieldset,
118
+ query: query, param_prefix: param_prefix)
119
+ render "crud_components/#{layout}", filter: presenter
120
+ end
121
+
122
+ # A derived create/edit form. The gem renders; your controller saves using
123
+ # the matching permit list ({CrudComponents.permitted_attributes}), so the
124
+ # form and strong-params can't drift.
125
+ #
126
+ # @param record [ActiveRecord::Base] a new or persisted instance.
127
+ # @param fieldset [Symbol, nil] which fieldset's fields to render; defaults to
128
+ # the form fieldset for the action.
129
+ # @param action [Symbol, nil] `:new`/`:edit`; inferred from the record when nil.
130
+ # @param url [String, nil] the form action URL; inferred from the record when nil.
131
+ # @param method [Symbol, nil] the HTTP verb; inferred when nil.
132
+ # @param layout [Symbol] the partial under `crud_components/` (`:form` ships).
133
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
134
+ def crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form)
135
+ presenter = Presenters::Form.new(view: self, record: record, fieldset: fieldset,
136
+ action: action, url: url, method: method)
137
+ render "crud_components/#{layout}", form: presenter
138
+ end
139
+
140
+ # The action buttons for a record (row actions) or a model class (collection
141
+ # actions) — for manual placement when you render with `actions: false`.
142
+ #
143
+ # @param subject [ActiveRecord::Base, Class] a record (row actions) or the
144
+ # model class (collection actions). A relation is rejected — collection
145
+ # actions are model-level (`can?(:new, Book)`), so pass the class.
146
+ # @param fieldset [Symbol, nil] which fieldset's actions to use; defaults to
147
+ # `:index` (collection) or `:show` (row).
148
+ # @return [ActiveSupport::SafeBuffer] the rendered HTML.
149
+ # @raise [ArgumentError] if given a relation.
150
+ def crud_actions(subject, fieldset: nil)
151
+ if subject.is_a?(ActiveRecord::Relation)
152
+ raise ArgumentError,
153
+ 'crud_actions takes a record (row actions) or a model class (collection ' \
154
+ "actions), not a relation — pass `#{subject.klass}`, not a scope."
155
+ end
156
+ model = subject.is_a?(Class) ? subject : subject.class
157
+ structure = Structure.for(model)
158
+ kind = subject.is_a?(Class) ? :collection : :row
159
+ resolved_fieldset = structure.fieldset(fieldset || (kind == :collection ? :index : :show))
160
+ presenter = Presenters::Actions.new(view: self, subject: subject, structure: structure,
161
+ actions: structure.fieldset_actions(resolved_fieldset, on: kind))
162
+ render 'crud_components/actions', actions: presenter
163
+ end
164
+
165
+ # ── utilities (used by the gem's partials; useful in apps too) ─────────
166
+
167
+ # The display label for a record — its declared `label`, else a humanized guess.
168
+ # @param record [ActiveRecord::Base]
169
+ # @return [String]
170
+ def crud_label(record)
171
+ Structure.for(record.class).label_for(record, self)
172
+ end
173
+
174
+ # The label for an associated record in an association column: a per-column
175
+ # `label:` callable (`attribute :order, label: ->(o) { o.full_title(short: true) }`)
176
+ # when given, else the target's default {#crud_label}. Used by the
177
+ # association / association_list renderers so a column can re-title the
178
+ # associated record for its context while keeping the nil-safe link.
179
+ # @param field [CrudComponents::Fields::Base] the association field.
180
+ # @param record [ActiveRecord::Base] the associated record.
181
+ # @return [String]
182
+ def crud_association_label(field, record)
183
+ callable = field.options[:label]
184
+ callable.respond_to?(:call) ? callable.call(record) : crud_label(record)
185
+ end
186
+
187
+ # The icon markup badging a model — an `<i>` tag (paired with
188
+ # `css.icon_prefix`) for the model's {Structure#icon}, or nil when the model
189
+ # has no icon (undeclared and unmapped, with no `model_fallback_icon`). Pass a
190
+ # record, a model class or a relation. Extra html options merge onto the tag;
191
+ # `class:` adds to the icon classes.
192
+ #
193
+ # <%= crud_model_icon(@book) %> # <i class="bi bi-book" aria-hidden="true">
194
+ # <%= crud_model_icon(Publisher, class: 'me-1') %>
195
+ #
196
+ # @param subject [ActiveRecord::Base, Class, ActiveRecord::Relation]
197
+ # @return [ActiveSupport::SafeBuffer, nil]
198
+ def crud_model_icon(subject, **html_options)
199
+ name = crud_model_icon_name(subject)
200
+ return nil unless name
201
+
202
+ extra = Array(html_options.delete(:class))
203
+ classes = ["#{CrudComponents.config.css.icon_prefix}#{name}", *extra].join(' ')
204
+ tag.i('', class: classes, aria: { hidden: true }, **html_options)
205
+ end
206
+
207
+ # The bare icon name (no library prefix) for a model — {Structure#icon} for
208
+ # the model behind a record / class / relation, or nil. Use when you need the
209
+ # name rather than the `<i>` tag {#crud_model_icon} builds.
210
+ # @param subject [ActiveRecord::Base, Class, ActiveRecord::Relation]
211
+ # @return [String, nil]
212
+ def crud_model_icon_name(subject)
213
+ model = subject.respond_to?(:klass) ? subject.klass : subject.is_a?(Class) ? subject : subject.class
214
+ Structure.for(model).icon
215
+ end
216
+
217
+ # A Bootstrap-icon name (no library prefix — pair with css.icon_prefix) for a
218
+ # filename, by extension: config.file_icons[ext], else config.file_fallback_icon.
219
+ # Used by the attachment renderer's icon fallback; override that partial to
220
+ # customize.
221
+ #
222
+ # @param filename [String, #to_s] the file name (or path) to derive from.
223
+ # @return [String] the icon name.
224
+ def crud_file_icon(filename)
225
+ config = CrudComponents.config
226
+ ext = File.extname(filename.to_s).delete('.').downcase
227
+ config.file_icons.fetch(ext, config.file_fallback_icon)
228
+ end
229
+
230
+ # The canonical path to a record (its `show`, resolved by {RouteResolver}).
231
+ # @param record [ActiveRecord::Base]
232
+ # @param owner [ActiveRecord::Base, nil] the owner, for a nested route.
233
+ # @return [String, nil] the path, or nil when none resolves.
234
+ def crud_record_path(record, owner: nil)
235
+ found = RouteResolver.record_path(self, record, owner: owner)
236
+ found&.first
237
+ end
238
+
239
+ # The index a has_many cell links to: nested under the owner, else the
240
+ # target's filtered index, else nil.
241
+ # @param owner [ActiveRecord::Base] the record that owns the association.
242
+ # @param field [CrudComponents::Fields::HasManyField] the association field.
243
+ # @return [String, nil] the path, or nil when none resolves.
244
+ def crud_association_index_path(owner, field)
245
+ RouteResolver.collection_index_path(self, field.target, owner, field.name)
246
+ end
247
+
248
+ # Inline the gem's stylesheet (the column-picker float styles) as a <style>
249
+ # tag — drop `<%= crud_components_styles %>` once in your layout <head>. This
250
+ # is the pipeline-agnostic way to load it: it needs no asset compilation, so
251
+ # it works the same under cssbundling/sass, importmap, sprockets or propshaft.
252
+ # Hosts whose pipeline serves engine assets can instead link the same file
253
+ # with `stylesheet_link_tag "crud_components"`.
254
+ def crud_components_styles
255
+ nonce = content_security_policy_nonce if respond_to?(:content_security_policy_nonce)
256
+ tag.style(CrudComponents.bundled_css.html_safe, type: 'text/css', nonce: nonce)
257
+ end
258
+ end
259
+ end