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,260 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# One subclass per field flavor (one row of the README's combination
|
|
4
|
+
# table). A field knows how it renders (which partial), how it filters
|
|
5
|
+
# (which control + how params reach SQL), and whether it sorts.
|
|
6
|
+
#
|
|
7
|
+
# Facets declared in an `attribute` block override exactly one of those:
|
|
8
|
+
# :render (block), :filter (like-spec / block / false), :sort
|
|
9
|
+
# (column symbol / block / false).
|
|
10
|
+
class Base
|
|
11
|
+
attr_reader :name, :model, :options, :facets
|
|
12
|
+
|
|
13
|
+
def initialize(name, model, options = {}, facets = {})
|
|
14
|
+
@name = name.to_sym
|
|
15
|
+
@model = model
|
|
16
|
+
@options = options
|
|
17
|
+
@facets = facets
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def human_name
|
|
21
|
+
return options[:label] if options[:label].is_a?(String)
|
|
22
|
+
|
|
23
|
+
model.human_attribute_name(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ── column header (issue #4) ───────────────────────────────────────────
|
|
27
|
+
# A column may own its `<th>`: a custom `header:` (a String rendered as-is —
|
|
28
|
+
# mark it html_safe for markup — or a view-context block, e.g. a link), and
|
|
29
|
+
# `header_actions:` — plain Action objects rendered in the header. A
|
|
30
|
+
# `:selection` action there acts on the ticked rows (it submits the shared
|
|
31
|
+
# select-form); any other action renders as a link/button. Available on every
|
|
32
|
+
# field flavor: a DynamicColumn passes them through its options, a declared
|
|
33
|
+
# `attribute` takes them as options too.
|
|
34
|
+
def header = options[:header]
|
|
35
|
+
def header_actions = Array(options[:header_actions])
|
|
36
|
+
|
|
37
|
+
# Whether this column brings its own header markup or header actions — the
|
|
38
|
+
# layout falls back to the plain human_name + sort link when it doesn't.
|
|
39
|
+
def custom_header? = !header.nil? || header_actions.any?
|
|
40
|
+
|
|
41
|
+
# Column-picker grouping: the heading this column sits under (a path
|
|
42
|
+
# column groups under the association(s) it reaches through), or nil for an
|
|
43
|
+
# own column. `picker_label` is the label shown within that group.
|
|
44
|
+
def group_label = nil
|
|
45
|
+
def picker_label = human_name
|
|
46
|
+
|
|
47
|
+
# The model the column-picker groups this column under (Pipedrive-style):
|
|
48
|
+
# its own model for a plain column, the *associated* model for an
|
|
49
|
+
# association or path column — so `publisher`, `publisher.name` and
|
|
50
|
+
# `publisher.founded_on` all sit under "Publisher".
|
|
51
|
+
def group_model = model
|
|
52
|
+
|
|
53
|
+
# The DB column backing this field, if any (nil for associations and
|
|
54
|
+
# computed fields).
|
|
55
|
+
def column
|
|
56
|
+
model.columns_hash[name.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether the backing column permits NULL — gates the "not set" filter
|
|
60
|
+
# choice and the 3-state form control for nullable boolean/enum fields.
|
|
61
|
+
def nullable?
|
|
62
|
+
!!column&.null
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether this field's filter offers a "not set" (IS NULL) choice.
|
|
66
|
+
def filter_includes_null?
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def value(record)
|
|
71
|
+
record.public_send(name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ── rendering ────────────────────────────────────────────────────────
|
|
75
|
+
def renderer(_record = nil)
|
|
76
|
+
options[:as] || default_renderer
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_renderer
|
|
80
|
+
:string
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_block
|
|
84
|
+
facets[:render]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def renderer_options
|
|
88
|
+
options.except(:as, :if, :form_as, :label, :header, :header_actions)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── permissions ──────────────────────────────────────────────────────
|
|
92
|
+
def permitted?(context, record = nil)
|
|
93
|
+
Permission.permitted?(options[:if], model, context, record)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ── filtering ────────────────────────────────────────────────────────
|
|
97
|
+
def filterable?
|
|
98
|
+
return false if facets[:filter] == false
|
|
99
|
+
return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
|
|
100
|
+
return true if filter_facet
|
|
101
|
+
|
|
102
|
+
derived_filterable?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def filter_facet
|
|
106
|
+
facets[:filter].is_a?(Proc) || facets[:filter].is_a?(Array) ||
|
|
107
|
+
facets[:filter].is_a?(Hash) || facets[:filter].is_a?(Symbol) ? facets[:filter] : nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def derived_filterable?
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Which filter control partial to render: :text, :select, :boolean,
|
|
115
|
+
# :number_range or :date_range.
|
|
116
|
+
def filter_control
|
|
117
|
+
filter_facet ? :text : derived_filter_control
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def derived_filter_control
|
|
121
|
+
:text
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def filter_choices(_query = nil)
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def range_filter?
|
|
129
|
+
filter_control == :number_range || filter_control == :date_range
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `exact`, `geq`, `leq` are the raw param values (Strings or nil).
|
|
133
|
+
def apply_filter(scope, exact: nil, geq: nil, leq: nil)
|
|
134
|
+
if filter_facet
|
|
135
|
+
return scope unless exact
|
|
136
|
+
|
|
137
|
+
apply_filter_facet(scope, exact)
|
|
138
|
+
else
|
|
139
|
+
apply_derived_filter(scope, exact:, geq:, leq:)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_filter_facet(scope, value)
|
|
144
|
+
facet = filter_facet
|
|
145
|
+
if facet.is_a?(Proc)
|
|
146
|
+
facet.call(scope.extending(WhereLike), value)
|
|
147
|
+
else
|
|
148
|
+
LikeSpec.apply(scope, facet, value)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def apply_derived_filter(scope, **)
|
|
153
|
+
scope
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ── sorting ──────────────────────────────────────────────────────────
|
|
157
|
+
def sortable?
|
|
158
|
+
return false if facets[:sort] == false
|
|
159
|
+
return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
|
|
160
|
+
return true if sort_facet
|
|
161
|
+
|
|
162
|
+
derived_sortable?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def sort_facet
|
|
166
|
+
facets[:sort].is_a?(Proc) || facets[:sort].is_a?(Symbol) ? facets[:sort] : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def derived_sortable?
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def apply_sort(scope, dir)
|
|
174
|
+
case (facet = sort_facet)
|
|
175
|
+
when Proc then facet.call(scope, dir)
|
|
176
|
+
when Symbol then scope.reorder(model.arel_table[facet].public_send(dir))
|
|
177
|
+
else scope.reorder(model.arel_table[name].public_send(dir))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ── forms ──────────────────────────────────────────────────────────────
|
|
182
|
+
# Columns that exist but are never user-editable in a derived form.
|
|
183
|
+
NON_EDITABLE_COLUMNS = %w[id created_at updated_at].freeze
|
|
184
|
+
|
|
185
|
+
# Whether this field appears as an *input* in a derived form. `editable:`
|
|
186
|
+
# overrides; a symbol/Proc means "editable, subject to a can? check"
|
|
187
|
+
# (see editable_permitted?).
|
|
188
|
+
def editable?
|
|
189
|
+
case options[:editable]
|
|
190
|
+
when false then false
|
|
191
|
+
when nil then default_editable?
|
|
192
|
+
else true
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def default_editable?
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def editable_permitted?(context, record = nil)
|
|
201
|
+
condition = options[:editable]
|
|
202
|
+
return true unless condition.is_a?(Symbol) || condition.is_a?(Proc)
|
|
203
|
+
|
|
204
|
+
# recordless: false — a record-dependent `editable:` can't be granted by
|
|
205
|
+
# the class-level permit list (no record there); deny by default and let
|
|
206
|
+
# the per-record form check decide where a record is present.
|
|
207
|
+
Permission.permitted?(condition, model, context, record, recordless: false)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The form-input flavor; nil = no form representation (json, computed).
|
|
211
|
+
def form_control
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The form-input partial to render: crud_components/form_fields/_<name>.
|
|
216
|
+
# Defaults to the field's form_control type; override per field with
|
|
217
|
+
# `form_as:` (mirrors `as:` for the read-only/display renderer). The
|
|
218
|
+
# partial receives the simple_form builder `f`, the `field`, and `form`.
|
|
219
|
+
def form_partial
|
|
220
|
+
options[:form_as] || form_control
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# What this field contributes to a strong-params permit list — a symbol
|
|
224
|
+
# or a nested hash; collected by Structure#permitted_params.
|
|
225
|
+
def permit_param
|
|
226
|
+
name
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── loading ──────────────────────────────────────────────────────────
|
|
230
|
+
# Includes-specs (symbols/nested hashes for ActiveRecord#includes) to
|
|
231
|
+
# eager-load when this column is shown. Base contributes the per-attribute
|
|
232
|
+
# `preload:` — associations a render block / custom renderer reaches on the
|
|
233
|
+
# listed model. Association fields override to also nest the target's
|
|
234
|
+
# identity_preloads under the association name.
|
|
235
|
+
def eager_load
|
|
236
|
+
declared_preloads
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The `preload:` option as an array of includes-specs (a nested hash kept
|
|
240
|
+
# intact, unlike Array()).
|
|
241
|
+
def declared_preloads
|
|
242
|
+
case (p = options[:preload])
|
|
243
|
+
when nil then []
|
|
244
|
+
when Array then p
|
|
245
|
+
else [p]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def arel_column
|
|
252
|
+
model.arel_table[name]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def like_pattern(value)
|
|
256
|
+
"%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# belongs_to / has_one: nil-safe link via the target's label. The filter
|
|
4
|
+
# (belongs_to only) accepts both the target's identify_by value (what the
|
|
5
|
+
# select submits) and free text matched against the target's search_in —
|
|
6
|
+
# one param, two OR-combined parameterized subqueries.
|
|
7
|
+
class BelongsToField < Base
|
|
8
|
+
def default_renderer = :association
|
|
9
|
+
|
|
10
|
+
def reflection
|
|
11
|
+
@reflection ||= model.reflect_on_association(name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def target
|
|
15
|
+
reflection.klass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Picker grouping: a belongs_to/has_one column anchors its target's group
|
|
19
|
+
# (polymorphic has no single target, so it groups under its own model).
|
|
20
|
+
def group_model = reflection.polymorphic? ? model : target
|
|
21
|
+
|
|
22
|
+
def target_structure
|
|
23
|
+
Structure.for(target)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def derived_filterable?
|
|
27
|
+
reflection.belongs_to? && !reflection.polymorphic?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def derived_sortable? = false
|
|
31
|
+
|
|
32
|
+
# select (a dropdown of all targets) below `select_limit` rows, else free
|
|
33
|
+
# text. Counted per render, not memoized: the field instance lives on the
|
|
34
|
+
# process-cached Structure, so a memoized count would freeze at its boot-time
|
|
35
|
+
# value and render the wrong control once the table grows past the limit.
|
|
36
|
+
# One COUNT per filter-row render is negligible next to rendering the table.
|
|
37
|
+
def derived_filter_control
|
|
38
|
+
target.count <= CrudComponents.config.select_limit ? :select : :text
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def filter_choices(_query = nil)
|
|
42
|
+
structure = target_structure
|
|
43
|
+
target.all.map { |record| [structure.label_for(record).to_s, record.public_send(structure.identify_by)] }
|
|
44
|
+
.sort_by(&:first)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_derived_filter(scope, exact: nil, **)
|
|
48
|
+
return scope unless exact
|
|
49
|
+
|
|
50
|
+
identified = scope.where(name => target.where(target_structure.identify_by => exact))
|
|
51
|
+
searched = like_subquery(scope, exact)
|
|
52
|
+
searched ? identified.or(searched) : identified
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Load the association, nesting the target's identity_preloads (its label's
|
|
56
|
+
# own association deps) plus any per-column `preload:` so the target's
|
|
57
|
+
# label never N+1s. e.g. { order: %i[customer training] }.
|
|
58
|
+
# A polymorphic belongs_to has no single target class, so we can't nest its
|
|
59
|
+
# label's preloads — just preload the association itself (Rails groups it by
|
|
60
|
+
# type); the cell still renders each record's label and links it at runtime.
|
|
61
|
+
def eager_load
|
|
62
|
+
return [name] if reflection.polymorphic?
|
|
63
|
+
|
|
64
|
+
nested = (target_structure.identity_preloads + declared_preloads).uniq
|
|
65
|
+
[nested.empty? ? name : { name => nested }]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ── forms ────────────────────────────────────────────────────────────
|
|
69
|
+
# Assigned via the foreign key; the select submits real ids (forms are
|
|
70
|
+
# POST bodies, not shareable URLs — unlike the filter, which uses
|
|
71
|
+
# identify_by).
|
|
72
|
+
def default_editable? = reflection.belongs_to? && !reflection.polymorphic?
|
|
73
|
+
def form_control = :belongs_to
|
|
74
|
+
def permit_param = reflection.foreign_key.to_sym
|
|
75
|
+
|
|
76
|
+
def form_choices
|
|
77
|
+
structure = target_structure
|
|
78
|
+
target.all.map { |record| [structure.label_for(record).to_s, record.id] }.sort_by(&:first)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def like_subquery(scope, value)
|
|
84
|
+
spec = target_structure.search_in_spec
|
|
85
|
+
return nil if spec.nil? || spec.empty?
|
|
86
|
+
|
|
87
|
+
scope.where(name => LikeSpec.apply(target.all, spec, value))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# boolean column: ✓/✗ cell, any/yes/no select; values cast & validated,
|
|
4
|
+
# invalid ones leave the scope unchanged.
|
|
5
|
+
class BooleanField < Base
|
|
6
|
+
def default_renderer = :boolean
|
|
7
|
+
def derived_filterable? = true
|
|
8
|
+
def derived_sortable? = true
|
|
9
|
+
def derived_filter_control = :boolean
|
|
10
|
+
def default_editable? = true
|
|
11
|
+
def form_control = :boolean
|
|
12
|
+
|
|
13
|
+
# Stricter than ActiveModel's cast (which makes any junk string true):
|
|
14
|
+
# only recognizable values filter, everything else is ignored.
|
|
15
|
+
TRUE_VALUES = %w[true t 1 yes on].freeze
|
|
16
|
+
FALSE_VALUES = %w[false f 0 no off].freeze
|
|
17
|
+
|
|
18
|
+
# A nullable column offers a "not set" (IS NULL) choice in the filter.
|
|
19
|
+
def filter_includes_null? = nullable?
|
|
20
|
+
|
|
21
|
+
def apply_derived_filter(scope, exact: nil, **)
|
|
22
|
+
case exact&.downcase
|
|
23
|
+
when *TRUE_VALUES then scope.where(name => true)
|
|
24
|
+
when *FALSE_VALUES then scope.where(name => false)
|
|
25
|
+
when CrudComponents::NULL_FILTER_VALUE then nullable? ? scope.where(name => nil) : scope
|
|
26
|
+
else scope
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# A name Rails doesn't know: a public model method (rendered by its value
|
|
4
|
+
# type) or a declared render block. Query behavior only through facets —
|
|
5
|
+
# a Ruby-computed value has no SQL meaning until a facet gives it one.
|
|
6
|
+
class ComputedField < Base
|
|
7
|
+
def renderer(record = nil)
|
|
8
|
+
return options[:as] if options[:as]
|
|
9
|
+
return nil if render_block
|
|
10
|
+
|
|
11
|
+
record ? renderer_for_value(value(record)) : :string
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def default_renderer = :string
|
|
15
|
+
|
|
16
|
+
def value(record)
|
|
17
|
+
render_block ? nil : record.public_send(name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def renderer_for_value(value)
|
|
23
|
+
case value
|
|
24
|
+
when Numeric then :number
|
|
25
|
+
when Date then :date
|
|
26
|
+
when Time, DateTime then :datetime
|
|
27
|
+
when true, false then :boolean
|
|
28
|
+
when Hash, Array then :json
|
|
29
|
+
else :string
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# date/datetime column: from–to range plus exact day; datetime ranges are
|
|
4
|
+
# whole-day-inclusive (a `leq` of 2026-01-31 includes that entire day).
|
|
5
|
+
class DateField < Base
|
|
6
|
+
def datetime?
|
|
7
|
+
@datetime ||= %i[datetime timestamp timestamptz].include?(model.columns_hash[name.to_s]&.type)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def default_renderer = datetime? ? :datetime : :date
|
|
11
|
+
def derived_filterable? = true
|
|
12
|
+
def derived_sortable? = true
|
|
13
|
+
def derived_filter_control = :date_range
|
|
14
|
+
def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
|
|
15
|
+
def form_control = datetime? ? :datetime : :date
|
|
16
|
+
|
|
17
|
+
def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
|
|
18
|
+
if (d = cast(exact)) then scope = apply_day(scope, d) end
|
|
19
|
+
if (d = cast(geq)) then scope = scope.where(arel_column.gteq(lower_bound(d))) end
|
|
20
|
+
if (d = cast(leq)) then scope = scope.where(arel_column.lteq(upper_bound(d))) end
|
|
21
|
+
scope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def apply_day(scope, day)
|
|
27
|
+
if datetime?
|
|
28
|
+
scope.where(arel_column.gteq(lower_bound(day)).and(arel_column.lteq(upper_bound(day))))
|
|
29
|
+
else
|
|
30
|
+
scope.where(name => day)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lower_bound(day)
|
|
35
|
+
datetime? ? day.beginning_of_day : day
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def upper_bound(day)
|
|
39
|
+
datetime? ? day.end_of_day : day
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cast(value)
|
|
43
|
+
return nil if value.nil?
|
|
44
|
+
|
|
45
|
+
Date.parse(value)
|
|
46
|
+
rescue ArgumentError, TypeError, RangeError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# The field flavor behind a {CrudComponents::DynamicColumn}: a column with no
|
|
4
|
+
# row in the model's table. It renders like a ComputedField (value typed, or
|
|
5
|
+
# `as:`), but the value comes from the column's resolver block rather than a
|
|
6
|
+
# method on the record, and a `preload:` lambda primes a per-page cache so a
|
|
7
|
+
# whole table costs one fetch, not one per row.
|
|
8
|
+
#
|
|
9
|
+
# Unlike the built-in fields — memoized on the immutable Structure and shared
|
|
10
|
+
# across every request — a DynamicField is built fresh per `crud_collection`
|
|
11
|
+
# call, so it may safely hold request state (the loaded cache). Filtering and
|
|
12
|
+
# sorting work only through the column's `filter:`/`sort:` facets; without
|
|
13
|
+
# them the column never reaches SQL, which keeps the query whitelist intact.
|
|
14
|
+
class DynamicField < ComputedField
|
|
15
|
+
# header:/header_actions: arrive via the column's options and are read by
|
|
16
|
+
# Fields::Base#header / #header_actions / #custom_header? — the layout picks
|
|
17
|
+
# them up through the Collection presenter, same as a declared attribute's.
|
|
18
|
+
def initialize(column, model)
|
|
19
|
+
super(column.name, model, column.options, column.facets)
|
|
20
|
+
@value_block = column.value_block
|
|
21
|
+
@preload_block = column.preload_block
|
|
22
|
+
@loaded = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run the batch loader once over the page's records; the resolver then
|
|
26
|
+
# reads per-record from whatever it returned. Called by the presenter just
|
|
27
|
+
# before rendering, only for the columns that end up visible.
|
|
28
|
+
def preload!(records)
|
|
29
|
+
@loaded = @preload_block&.call(records)
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def value(record)
|
|
34
|
+
return super unless @value_block
|
|
35
|
+
|
|
36
|
+
@value_block.arity == 1 ? @value_block.call(record) : @value_block.call(record, @loaded)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Never backed by a real DB column — keep column/nullable introspection
|
|
40
|
+
# nil so nothing tries to read model.columns_hash[name].
|
|
41
|
+
def column = nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# enum: badge cell, select of enum keys, values validated against the
|
|
4
|
+
# enum definition — invalid ones leave the scope unchanged.
|
|
5
|
+
class EnumField < Base
|
|
6
|
+
def default_renderer = :enum
|
|
7
|
+
def derived_filterable? = true
|
|
8
|
+
def derived_sortable? = true
|
|
9
|
+
def derived_filter_control = :select
|
|
10
|
+
def default_editable? = true
|
|
11
|
+
def form_control = :enum
|
|
12
|
+
|
|
13
|
+
def form_choices
|
|
14
|
+
enum_keys.map { |key| [human_value(key), key] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def enum_keys
|
|
18
|
+
model.defined_enums[name.to_s].keys
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def filter_choices(_query = nil)
|
|
22
|
+
enum_keys.map { |key| [human_value(key), key] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def human_value(key)
|
|
26
|
+
model.human_attribute_name("#{name}.#{key}", default: key.to_s.humanize)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A nullable column offers a "not set" (IS NULL) choice in the filter.
|
|
30
|
+
def filter_includes_null? = nullable?
|
|
31
|
+
|
|
32
|
+
def apply_derived_filter(scope, exact: nil, **)
|
|
33
|
+
return scope.where(name => nil) if exact == CrudComponents::NULL_FILTER_VALUE && nullable?
|
|
34
|
+
return scope unless exact && enum_keys.include?(exact)
|
|
35
|
+
|
|
36
|
+
scope.where(name => exact)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# has_many / habtm: truncated list of links ("a, b +3 more"). No derived
|
|
4
|
+
# filter or sort; opt in with `filter like: :assoc` (delegation).
|
|
5
|
+
class HasManyField < Base
|
|
6
|
+
def default_renderer = :association_list
|
|
7
|
+
|
|
8
|
+
def reflection
|
|
9
|
+
@reflection ||= model.reflect_on_association(name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def target
|
|
13
|
+
reflection.klass
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Picker grouping: a has_many/habtm column anchors its target's group, so
|
|
17
|
+
# `authors`, `authors.name` and `authors.email` all sit under "Author".
|
|
18
|
+
def group_model = target
|
|
19
|
+
|
|
20
|
+
# Load the association, nesting each target's identity_preloads (+ this
|
|
21
|
+
# column's `preload:`) so rendering the list's labels never N+1s.
|
|
22
|
+
def eager_load
|
|
23
|
+
nested = (target_structure.identity_preloads + declared_preloads).uniq
|
|
24
|
+
[nested.empty? ? name : { name => nested }]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The index a "+n more" / list link points at: the nested route under
|
|
28
|
+
# the owner if it resolves, else the target's index filtered by the
|
|
29
|
+
# owner. Resolved in the view (RouteResolver); here we just expose the
|
|
30
|
+
# association so the renderer can build it.
|
|
31
|
+
def collection_link_target = target
|
|
32
|
+
|
|
33
|
+
# ── forms ────────────────────────────────────────────────────────────
|
|
34
|
+
# Editable only for habtm (and simple has_many) via the *_ids setter —
|
|
35
|
+
# reassigning ids is safe; nested attributes are out of scope.
|
|
36
|
+
def habtm? = reflection.macro == :has_and_belongs_to_many
|
|
37
|
+
def default_editable? = habtm?
|
|
38
|
+
def form_control = :habtm
|
|
39
|
+
def ids_method = "#{name.to_s.singularize}_ids".to_sym
|
|
40
|
+
def permit_param = { ids_method => [] }
|
|
41
|
+
|
|
42
|
+
def form_choices
|
|
43
|
+
structure = target_structure
|
|
44
|
+
target.all.map { |record| [structure.label_for(record).to_s, record.id] }.sort_by(&:first)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def target_structure = Structure.for(target)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# json/jsonb column: pretty-printed <pre>; no derived filter or sort.
|
|
4
|
+
class JsonField < Base
|
|
5
|
+
def default_renderer = :json
|
|
6
|
+
# JSON columns are not form-editable in v1: round-tripping raw JSON
|
|
7
|
+
# through a textarea needs parse-on-assign. Renders read-only.
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# numeric column: min–max range plus exact match; unparsable values ignored.
|
|
4
|
+
class NumericField < Base
|
|
5
|
+
def default_renderer = :number
|
|
6
|
+
def derived_filterable? = true
|
|
7
|
+
def derived_sortable? = true
|
|
8
|
+
def derived_filter_control = :number_range
|
|
9
|
+
def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
|
|
10
|
+
def form_control = :number
|
|
11
|
+
|
|
12
|
+
def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
|
|
13
|
+
if (v = cast(exact)) then scope = scope.where(name => v) end
|
|
14
|
+
if (v = cast(geq)) then scope = scope.where(arel_column.gteq(v)) end
|
|
15
|
+
if (v = cast(leq)) then scope = scope.where(arel_column.lteq(v)) end
|
|
16
|
+
scope
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def cast(value)
|
|
22
|
+
return nil if value.nil?
|
|
23
|
+
|
|
24
|
+
decimal = BigDecimal(value)
|
|
25
|
+
decimal.finite? ? decimal : nil # reject NaN / Infinity — they aren't filters
|
|
26
|
+
rescue ArgumentError, TypeError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|