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,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,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
|