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,498 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# The single `collection` local every layout partial receives.
|
|
4
|
+
#
|
|
5
|
+
# query: :auto (default) → build a Query from the request params and apply it
|
|
6
|
+
# query: :static → no filter row, no sort links, params ignored
|
|
7
|
+
# query: <Query> → manual mode (records arrive already filtered)
|
|
8
|
+
#
|
|
9
|
+
# picker: false (default) → no column picker; true → render the gear
|
|
10
|
+
# picked_columns: :auto (default) → read ?cols=; an Array → that exact
|
|
11
|
+
# selection (no param read — the backend resolved it)
|
|
12
|
+
class Collection < Base
|
|
13
|
+
include ColumnSelection
|
|
14
|
+
|
|
15
|
+
attr_reader :model, :structure, :fieldset, :query, :layout, :param_prefix, :owner
|
|
16
|
+
|
|
17
|
+
def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
|
|
18
|
+
param_prefix: nil, actions: true, group_by: nil,
|
|
19
|
+
extra_columns: nil, picker: false, picked_columns: :auto)
|
|
20
|
+
super(view: view)
|
|
21
|
+
unless records.respond_to?(:klass)
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"crud_collection expects an ActiveRecord relation (e.g. Book.all, @books, or an " \
|
|
24
|
+
"authorized scope like Book.accessible_by(current_ability)), got #{records.class}. " \
|
|
25
|
+
'Pass a scope so your authorization and filtering apply before the gem renders.'
|
|
26
|
+
end
|
|
27
|
+
relation = records
|
|
28
|
+
@model = relation.klass
|
|
29
|
+
@structure = Structure.for(@model)
|
|
30
|
+
@owner = relation.respond_to?(:proxy_association) ? relation.proxy_association.owner : nil
|
|
31
|
+
@layout = layout
|
|
32
|
+
@param_prefix = param_prefix
|
|
33
|
+
@actions_enabled = actions
|
|
34
|
+
# Two orthogonal column-picker knobs (see ColumnSelection): the gear is on
|
|
35
|
+
# iff `picker`; the selection comes from the param (`:auto`) or verbatim
|
|
36
|
+
# from the resolved Array — they never both read the param.
|
|
37
|
+
@picker = picker
|
|
38
|
+
@picked_columns = normalize_picked_columns(picked_columns)
|
|
39
|
+
# User-defined columns whose data lives outside the model's table. Built
|
|
40
|
+
# fresh per request (never on the immutable Structure), so they may carry
|
|
41
|
+
# the per-page value cache.
|
|
42
|
+
@dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model) }
|
|
43
|
+
|
|
44
|
+
case query
|
|
45
|
+
when :static
|
|
46
|
+
@static = true
|
|
47
|
+
@fieldset = @structure.fieldset(fieldset || :index)
|
|
48
|
+
when :auto, nil
|
|
49
|
+
@fieldset = @structure.fieldset(fieldset || :index)
|
|
50
|
+
@query = Query.new(@model, view.request.query_parameters, fieldset: @fieldset,
|
|
51
|
+
ability: ability, param_prefix: param_prefix, extra_fields: @dynamic_fields)
|
|
52
|
+
relation = @query.apply(relation)
|
|
53
|
+
when Query
|
|
54
|
+
@query = query
|
|
55
|
+
@fieldset = fieldset ? @structure.fieldset(fieldset) : query.fieldset
|
|
56
|
+
@param_prefix = query.param_prefix
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"crud_collection query: expects :auto, :static or a CrudComponents::Query, got #{query.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@relation = eager_load(relation)
|
|
63
|
+
setup_grouping(group_by) if group_by
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def static? = !!@static
|
|
67
|
+
def surface = :collection
|
|
68
|
+
|
|
69
|
+
def records
|
|
70
|
+
@records ||= begin
|
|
71
|
+
rows = @relation.to_a
|
|
72
|
+
# Prime each visible dynamic column's per-page cache once (no N+1); the
|
|
73
|
+
# cell resolver then reads per row from what `preload:` returned.
|
|
74
|
+
fields.each { |f| f.preload!(rows) if f.is_a?(Fields::DynamicField) }
|
|
75
|
+
rows
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Every column this user is allowed to see — declared fieldset fields plus
|
|
80
|
+
# the dynamic columns — regardless of the current visibility selection.
|
|
81
|
+
# This is what a column picker offers as the universe to choose from.
|
|
82
|
+
# (`fields`, `column_visible?` and the picker knobs come from ColumnSelection.)
|
|
83
|
+
def available_fields
|
|
84
|
+
@available_fields ||=
|
|
85
|
+
(structure.fieldset_fields(fieldset) + @dynamic_fields).select { |f| f.permitted?(permission_context) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether to offer the column picker UI for this collection.
|
|
89
|
+
def column_picker? = @picker && available_fields.any?
|
|
90
|
+
|
|
91
|
+
# The checkbox param name the picker submits (respects param_prefix).
|
|
92
|
+
def column_param_name = "#{pn('cols')}[]"
|
|
93
|
+
|
|
94
|
+
# Hidden inputs for the picker's GET form: keep every other param (filters,
|
|
95
|
+
# search, sort, other collections) but drop our own cols (the checkboxes
|
|
96
|
+
# resubmit it) and page (a column change resets paging).
|
|
97
|
+
def picker_preserved_params
|
|
98
|
+
drop = [pn('cols'), pn('page')]
|
|
99
|
+
view.request.query_parameters.reject { |key, _| drop.include?(key) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ── cells ────────────────────────────────────────────────────────────
|
|
103
|
+
def cell(field, record)
|
|
104
|
+
html = render_cell(field, record, surface: :collection, cell_context: cell_context)
|
|
105
|
+
if label_link_field?(field) && (path = record_link(record))
|
|
106
|
+
view.link_to(html, path, class: css.record_link, data: { turbo_action: 'advance' })
|
|
107
|
+
else
|
|
108
|
+
html
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Click-to-filter context for value renderers — only when this
|
|
113
|
+
# collection actually has a live query.
|
|
114
|
+
def cell_context
|
|
115
|
+
return nil if static? || query.nil?
|
|
116
|
+
|
|
117
|
+
@cell_context ||= CellContext.new(view: view, query: query)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def label_link_field?(field)
|
|
121
|
+
field.name == structure.label_field_name
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── column headers ─────────────────────────────────────────────────────
|
|
125
|
+
# A column may carry custom `<th>` content: a header (String or a
|
|
126
|
+
# view-context block) and/or its own actions — declared on a DynamicColumn
|
|
127
|
+
# or on a plain `attribute`. The layout renders these *instead of* the plain
|
|
128
|
+
# human_name + sort link; for every other field these return nil/[] and the
|
|
129
|
+
# layout keeps its default behavior.
|
|
130
|
+
|
|
131
|
+
# Whether `field` brings its own header markup or header actions.
|
|
132
|
+
def custom_header?(field)
|
|
133
|
+
field.custom_header?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The `<th>` title for `field`: a custom `header:` (a String — html_safe to
|
|
137
|
+
# carry markup — or a view-context block, e.g. a link) replaces the plain
|
|
138
|
+
# `human_name`; otherwise the `human_name` itself. `header:` substitutes only
|
|
139
|
+
# the name — the layout still wraps this in a sort link when the column is
|
|
140
|
+
# sortable, and appends any header actions.
|
|
141
|
+
def column_header(field)
|
|
142
|
+
header = field.header
|
|
143
|
+
case header
|
|
144
|
+
when nil then field.human_name
|
|
145
|
+
when Proc then view.instance_exec(&header)
|
|
146
|
+
else header
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The header actions for `field` as an Actions presenter (collection-kind:
|
|
151
|
+
# they act on the column's object × — for a :selection action — the ticked
|
|
152
|
+
# rows, not a single row), or nil. Each action's path block closes over that
|
|
153
|
+
# object; the layout renders a :selection action as a select-form submitter
|
|
154
|
+
# and any other as a link/button.
|
|
155
|
+
def column_header_actions(field)
|
|
156
|
+
return nil unless field.header_actions.any?
|
|
157
|
+
|
|
158
|
+
(@column_header_actions ||= {})[field] ||=
|
|
159
|
+
Actions.new(view: view, subject: model, structure: structure,
|
|
160
|
+
actions: field.header_actions, owner: owner)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def record_link(record)
|
|
164
|
+
@record_links ||= {}
|
|
165
|
+
return @record_links[record.id] if @record_links.key?(record.id)
|
|
166
|
+
|
|
167
|
+
found = RouteResolver.record_path(view, record, owner: owner)
|
|
168
|
+
@record_links[record.id] = found&.first
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def label_link_present?(record)
|
|
172
|
+
fields.any? { |f| label_link_field?(f) } && record_link(record).present?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ── filtering ────────────────────────────────────────────────────────
|
|
176
|
+
def filterable?
|
|
177
|
+
!static? && query && filter_fields.any?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def filter_fields
|
|
181
|
+
@filter_fields ||= static? ? [] : query.filter_fields
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def filterable_field?(field)
|
|
185
|
+
filter_fields.include?(field)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def filter_form_id
|
|
189
|
+
suffix = param_prefix ? "_#{param_prefix}" : ''
|
|
190
|
+
"crud_filter_#{model.model_name.plural}#{suffix}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ── header search (?q=) and reset ──────────────────────────────────────
|
|
194
|
+
def searchable?
|
|
195
|
+
!static? && query && query.searchable?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def search_param_name
|
|
199
|
+
query.param_name('q')
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def search_value
|
|
203
|
+
query&.value('q')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def filtered?
|
|
207
|
+
!static? && query&.active?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Whether the toolbar (search + collection actions) has anything to show —
|
|
211
|
+
# lets a layout skip an empty header row.
|
|
212
|
+
def show_toolbar?
|
|
213
|
+
searchable? || collection_actions&.any? || selection_actions&.any?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Reset clears *this* collection's filter/search/sort/page params and
|
|
217
|
+
# keeps everyone else's (other prefixes, the page's own params).
|
|
218
|
+
def reset_url
|
|
219
|
+
kept = view.request.query_parameters.reject { |key, _| own_param_keys.include?(key) }
|
|
220
|
+
kept.any? ? "#{view.request.path}?#{kept.to_query}" : view.request.path
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Hidden inputs for the filter form: keep this collection's sort and
|
|
224
|
+
# every param that belongs to someone else (other prefixes, the page's
|
|
225
|
+
# own params). Drop our own filter/search params — the controls
|
|
226
|
+
# themselves resubmit those.
|
|
227
|
+
def preserved_params
|
|
228
|
+
own = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
|
|
229
|
+
own += [pn('q'), pn('page'), pn('per')]
|
|
230
|
+
view.request.query_parameters.reject { |key, _| own.include?(key) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Every param key this collection owns (for reset).
|
|
234
|
+
def own_param_keys
|
|
235
|
+
keys = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
|
|
236
|
+
keys + %w[q sort dir page per].map { |k| pn(k) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# ── sorting ──────────────────────────────────────────────────────────
|
|
240
|
+
def sortable_field?(field)
|
|
241
|
+
!static? && query && query.sortable_fields.include?(field)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def sort_url(field)
|
|
245
|
+
current, dir = query.sort_state
|
|
246
|
+
next_dir = current == field.name.to_s && dir == 'asc' ? 'desc' : 'asc'
|
|
247
|
+
params = view.request.query_parameters.merge(pn('sort') => field.name.to_s, pn('dir') => next_dir)
|
|
248
|
+
"#{view.request.path}?#{params.to_query}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Is this the column the result is currently sorted by?
|
|
252
|
+
def sort_active?(field)
|
|
253
|
+
!sort_direction(field).nil?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The active sort direction for a column — :asc / :desc, or nil when the
|
|
257
|
+
# result isn't sorted by it. The presenter holds no icon names: a layout
|
|
258
|
+
# turns this tri-state into whatever glyph it likes (see _table), pairing
|
|
259
|
+
# it with `sort_numeric?` to choose a numeric vs alphabetic icon.
|
|
260
|
+
def sort_direction(field)
|
|
261
|
+
current, dir = query&.sort_state
|
|
262
|
+
return nil unless current == field.name.to_s
|
|
263
|
+
|
|
264
|
+
dir.to_s == 'desc' ? :desc : :asc
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Whether this column sorts numerically (vs alphabetically) — numbers and
|
|
268
|
+
# dates do — so a layout can pick a sort-numeric vs sort-alpha glyph.
|
|
269
|
+
def sort_numeric?(field)
|
|
270
|
+
field.is_a?(Fields::NumericField) || field.is_a?(Fields::DateField)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ── grouping ─────────────────────────────────────────────────────────
|
|
274
|
+
# A run of records sharing one group value. `key` is URL-safe (for ?open=),
|
|
275
|
+
# `label` is for display.
|
|
276
|
+
Group = Struct.new(:key, :label, :records, keyword_init: true) do
|
|
277
|
+
def count = records.size
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def grouped? = !@group_by.nil?
|
|
281
|
+
|
|
282
|
+
# The records split into groups, in group order (the relation is ordered by
|
|
283
|
+
# the group key first, so consecutive records form each group).
|
|
284
|
+
def groups
|
|
285
|
+
@groups ||= records.group_by { |r| group_key_for(r) }.map do |key, recs|
|
|
286
|
+
Group.new(key: key, label: group_label_for(recs.first), records: recs)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Default: every group open below the collapse threshold, only the first
|
|
291
|
+
# above it. Once `?open=` is set it is authoritative (and may open several).
|
|
292
|
+
def group_open?(group)
|
|
293
|
+
if open_keys.nil?
|
|
294
|
+
records.size < config.group_collapse_threshold || group == groups.first
|
|
295
|
+
else
|
|
296
|
+
open_keys.include?(group.key)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Toggle this group in `?open=`, materializing the current open set so the
|
|
301
|
+
# first click on a default view keeps the others as they are.
|
|
302
|
+
def group_toggle_url(group)
|
|
303
|
+
current = groups.select { |g| group_open?(g) }.map(&:key)
|
|
304
|
+
toggled = current.include?(group.key) ? current - [group.key] : current + [group.key]
|
|
305
|
+
params = view.request.query_parameters.merge(pn('open') => toggled.join(','))
|
|
306
|
+
"#{view.request.path}?#{params.to_query}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ── pagination ─────────────────────────────────────────────────────────
|
|
310
|
+
# We render a footer pager only when the relation handed to us is already
|
|
311
|
+
# paginated — i.e. the host called `.page` (kaminari / will_paginate, which
|
|
312
|
+
# decorate the relation). The gem never paginates on its own: no records
|
|
313
|
+
# arrive limited unless you asked for it. pagy keeps its state in a
|
|
314
|
+
# separate object, not on the relation, so it can't be detected here —
|
|
315
|
+
# render `pagy_nav` yourself.
|
|
316
|
+
def paginated?
|
|
317
|
+
@relation.respond_to?(:current_page) && @relation.respond_to?(:total_pages)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether to draw the footer at all — a single page needs no pager.
|
|
321
|
+
def show_pager? = paginated? && total_pages > 1
|
|
322
|
+
|
|
323
|
+
def current_page = @relation.current_page
|
|
324
|
+
def total_pages = @relation.total_pages
|
|
325
|
+
def total_count = @relation.total_count
|
|
326
|
+
|
|
327
|
+
# The underlying (possibly paginated) relation, for custom layouts that
|
|
328
|
+
# would rather drive their own pager — e.g. hand it to kaminari's
|
|
329
|
+
# `paginate` helper instead of rendering the gem's _pager.
|
|
330
|
+
def page_scope = @relation
|
|
331
|
+
|
|
332
|
+
# A URL for page n that keeps this collection's filters/search/sort and
|
|
333
|
+
# every other collection's params (only our own `page` changes) — so the
|
|
334
|
+
# pager composes with everything and respects `param_prefix:`.
|
|
335
|
+
def page_url(n)
|
|
336
|
+
params = view.request.query_parameters.merge(pn('page') => n)
|
|
337
|
+
"#{view.request.path}?#{params.to_query}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Page numbers to show, with :gap markers for elided ranges:
|
|
341
|
+
# [1, :gap, 4, 5, 6, :gap, 10]. Always includes first/last and a window
|
|
342
|
+
# around the current page.
|
|
343
|
+
def pager_pages(window: 2)
|
|
344
|
+
return [] if total_pages <= 1
|
|
345
|
+
|
|
346
|
+
shown = ([1, total_pages] + ((current_page - window)..(current_page + window)).to_a)
|
|
347
|
+
.select { |p| p >= 1 && p <= total_pages }.uniq.sort
|
|
348
|
+
shown.each_with_index.flat_map do |p, i|
|
|
349
|
+
(i.positive? && p - shown[i - 1] > 1) ? [:gap, p] : [p]
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# ── actions ──────────────────────────────────────────────────────────
|
|
354
|
+
def actions_column?
|
|
355
|
+
@actions_enabled && (custom_actions_partial.present? || row_action_definitions.any?)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# The trailing column exists when there are row actions *or* a column
|
|
359
|
+
# picker (its gear lives in that column's header cell) — so the header,
|
|
360
|
+
# rows and width all agree even on a picker-only, action-less table.
|
|
361
|
+
def trailing_column?
|
|
362
|
+
actions_column? || column_picker?
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def custom_actions_partial
|
|
366
|
+
fieldset.custom_actions_partial
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def row_actions(record)
|
|
370
|
+
Actions.new(view: view, subject: record, structure: structure,
|
|
371
|
+
actions: row_action_definitions, owner: owner,
|
|
372
|
+
suppress_show: label_link_present?(record))
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def collection_actions
|
|
376
|
+
return nil unless @actions_enabled
|
|
377
|
+
|
|
378
|
+
@collection_actions ||= Actions.new(view: view, subject: model, structure: structure,
|
|
379
|
+
actions: structure.fieldset_actions(fieldset, on: :collection),
|
|
380
|
+
owner: owner)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ── selection (bulk actions) ──────────────────────────────────────────
|
|
384
|
+
# Selection actions (`action :x, on: :selection`) operate on the rows the
|
|
385
|
+
# user ticks. Rendered as submit buttons that post the checked
|
|
386
|
+
# `selected[]` slugs to the action path — no-JS works; the optional
|
|
387
|
+
# crud-select controller adds select-all / select-group / a live count.
|
|
388
|
+
def selection_actions
|
|
389
|
+
return nil unless @actions_enabled
|
|
390
|
+
|
|
391
|
+
@selection_actions ||= Actions.new(view: view, subject: model, structure: structure,
|
|
392
|
+
actions: structure.fieldset_actions(fieldset, on: :selection),
|
|
393
|
+
owner: owner)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def selectable?
|
|
397
|
+
return @selectable if defined?(@selectable)
|
|
398
|
+
|
|
399
|
+
@selectable = @actions_enabled && (selection_actions.any? || column_selection_actions?)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# A visible column may host an `on: :selection` action in its header (acting
|
|
403
|
+
# on the ticked rows × that column's object). Like a toolbar selection
|
|
404
|
+
# action, it submits the shared select-form — so it needs the checkbox
|
|
405
|
+
# column + select-form and thus makes the collection selectable.
|
|
406
|
+
def column_selection_actions?
|
|
407
|
+
fields.any? do |field|
|
|
408
|
+
column_header_actions(field)&.items&.any? { |item| item.action.selection? }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def select_form_id
|
|
413
|
+
suffix = param_prefix ? "_#{param_prefix}" : ''
|
|
414
|
+
"crud_select_#{model.model_name.plural}#{suffix}"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The checkbox param name (respects param_prefix) — value is each row's
|
|
418
|
+
# identify_by, resolved back with CrudComponents.selected.
|
|
419
|
+
def select_param_name = "#{pn('selected')}[]"
|
|
420
|
+
|
|
421
|
+
def select_value(record) = record.public_send(structure.identify_by).to_s
|
|
422
|
+
|
|
423
|
+
def columns_count
|
|
424
|
+
fields.size + (selectable? ? 1 : 0) + (trailing_column? ? 1 : 0)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
private
|
|
428
|
+
|
|
429
|
+
GROUP_NONE = 'none'.freeze
|
|
430
|
+
|
|
431
|
+
# Validate the group key and order the relation by it (groups contiguous),
|
|
432
|
+
# keeping the active sort as the secondary order.
|
|
433
|
+
def setup_grouping(group_by)
|
|
434
|
+
@group_by = group_by.to_sym
|
|
435
|
+
@group_field = structure.field(@group_by)
|
|
436
|
+
unless @group_field.is_a?(Fields::BelongsToField) || @group_field.column
|
|
437
|
+
raise ArgumentError,
|
|
438
|
+
"crud_collection group_by: :#{@group_by} must be a column, belongs_to or enum of " \
|
|
439
|
+
"#{model} — #{@group_field.class.name.split('::').last} can't be grouped (no SQL column to order by)."
|
|
440
|
+
end
|
|
441
|
+
return unless @relation.is_a?(ActiveRecord::Relation)
|
|
442
|
+
|
|
443
|
+
order_attr = @group_field.is_a?(Fields::BelongsToField) ? @group_field.reflection.foreign_key : @group_by
|
|
444
|
+
existing = @relation.order_values
|
|
445
|
+
@relation = @relation.reorder(model.arel_table[order_attr])
|
|
446
|
+
@relation = @relation.order(*existing) if existing.any?
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def group_key_for(record)
|
|
450
|
+
value = record.public_send(@group_by)
|
|
451
|
+
return GROUP_NONE if value.nil?
|
|
452
|
+
|
|
453
|
+
if @group_field.is_a?(Fields::BelongsToField)
|
|
454
|
+
value.public_send(Structure.for(value.class).identify_by).to_s
|
|
455
|
+
else
|
|
456
|
+
value.to_s
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def group_label_for(record)
|
|
461
|
+
value = record.public_send(@group_by)
|
|
462
|
+
return view.t('crud_components.group.none', default: '—') if value.nil?
|
|
463
|
+
|
|
464
|
+
if @group_field.is_a?(Fields::BelongsToField)
|
|
465
|
+
view.crud_label(value)
|
|
466
|
+
elsif @group_field.is_a?(Fields::EnumField)
|
|
467
|
+
@group_field.human_value(value)
|
|
468
|
+
else
|
|
469
|
+
value.to_s
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# nil when ?open= is absent (use the default open rule); an array (possibly
|
|
474
|
+
# empty) when present (authoritative).
|
|
475
|
+
def open_keys
|
|
476
|
+
return @open_keys if defined?(@open_keys)
|
|
477
|
+
|
|
478
|
+
raw = view.request.query_parameters[pn('open')]
|
|
479
|
+
@open_keys = raw.nil? ? nil : raw.to_s.split(',')
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def row_action_definitions
|
|
483
|
+
@row_action_definitions ||= structure.fieldset_actions(fieldset, on: :row)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def pn(key)
|
|
487
|
+
query ? query.param_name(key) : key
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def eager_load(relation)
|
|
491
|
+
return relation unless relation.is_a?(ActiveRecord::Relation)
|
|
492
|
+
|
|
493
|
+
specs = fields.flat_map(&:eager_load)
|
|
494
|
+
specs.any? ? relation.includes(*specs) : relation
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# Shared "which columns are shown" logic for any presenter that exposes
|
|
4
|
+
# `available_fields` (the permitted universe) and a `param_prefix`. Two knobs
|
|
5
|
+
# drive it (set as `@picker` and `@picked_columns` by the including presenter):
|
|
6
|
+
#
|
|
7
|
+
# @picker false → no picking (the fieldset governs); true → the view
|
|
8
|
+
# participates (a collection also renders the gear).
|
|
9
|
+
# @picked_columns :auto → read the `?cols=` submit; an Array → that exact
|
|
10
|
+
# selection, **without ever reading the param** (the backend
|
|
11
|
+
# already resolved it — from a persisted pref, or from the
|
|
12
|
+
# param via {CrudComponents.selected_columns}).
|
|
13
|
+
#
|
|
14
|
+
# The chosen selection is **always intersected with `available_fields`** — so a
|
|
15
|
+
# forged or stale selection can only hide or reorder columns, never reveal one
|
|
16
|
+
# the `if:` gate forbids. Mixed into both the collection and the record
|
|
17
|
+
# presenter, so a column picker drives a table and a detail view alike.
|
|
18
|
+
module ColumnSelection
|
|
19
|
+
# The columns actually rendered: the permitted set, narrowed and ordered
|
|
20
|
+
# by the user's selection when there is one.
|
|
21
|
+
def fields
|
|
22
|
+
@fields ||= select_visible(available_fields)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Is this column part of the current view (ticked in the picker)?
|
|
26
|
+
def column_visible?(field) = fields.include?(field)
|
|
27
|
+
|
|
28
|
+
# The column-picker universe grouped by source model (Pipedrive-style):
|
|
29
|
+
# `[[model, fields], …]` with this collection's own model first, then each
|
|
30
|
+
# associated model in first-appearance order. So `publisher`,
|
|
31
|
+
# `publisher.name` and `publisher.founded_on` cluster under Publisher.
|
|
32
|
+
def field_groups
|
|
33
|
+
by_model = available_fields.group_by(&:group_model)
|
|
34
|
+
ordered = [model, *(by_model.keys - [model])]
|
|
35
|
+
ordered.filter_map { |m| [m, by_model[m]] if by_model[m] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A picker group's heading text and icon (no prefix), for a grouped model.
|
|
39
|
+
def group_heading(group_model) = group_model.model_name.human
|
|
40
|
+
def group_icon(group_model) = Structure.for(group_model).icon
|
|
41
|
+
|
|
42
|
+
# The ordered column names to show, or nil for "all permitted". The selection
|
|
43
|
+
# is independent of the gear: a resolved **Array** always applies (the backend
|
|
44
|
+
# decided it — the gear may live elsewhere, e.g. a standalone picker or a
|
|
45
|
+
# detail view), verbatim and without reading the param. `:auto` reads the
|
|
46
|
+
# `?cols=` submit **only when a gear is rendered here** (`picker: true`); with
|
|
47
|
+
# no gear here, `:auto` means "don't narrow" (a stray `?cols=` is ignored).
|
|
48
|
+
def visible_columns
|
|
49
|
+
return @visible_columns if defined?(@visible_columns)
|
|
50
|
+
|
|
51
|
+
@visible_columns =
|
|
52
|
+
if @picked_columns.is_a?(Array) then @picked_columns
|
|
53
|
+
elsif @picker then cols_param
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Normalize the `picked_columns:` knob: `:auto`/nil → `:auto`; an Array →
|
|
60
|
+
# its symbols. Anything else is a mistake worth catching at the call site.
|
|
61
|
+
def normalize_picked_columns(value)
|
|
62
|
+
case value
|
|
63
|
+
when :auto, nil then :auto
|
|
64
|
+
when Array then value.map(&:to_sym)
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"picked_columns: expects :auto or an Array of column names, got #{value.inspect}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def select_visible(list)
|
|
72
|
+
names = visible_columns
|
|
73
|
+
return list unless names
|
|
74
|
+
|
|
75
|
+
names.filter_map { |name| list.find { |field| field.name == name } }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The picker submits `cols[]=a&cols[]=b` (no-JS) or, with the crud-columns
|
|
79
|
+
# controller, a single comma-joined `cols=a,b` (prettier URL). Both forms are
|
|
80
|
+
# parsed by {CrudComponents.selected_columns} (the same reader hosts use to
|
|
81
|
+
# persist a pick) — we just symbolize the result. nil when nothing was picked.
|
|
82
|
+
def cols_param
|
|
83
|
+
CrudComponents.selected_columns(column_request_params, param_prefix: param_prefix)&.map(&:to_sym)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column_request_params
|
|
87
|
+
view.respond_to?(:request) && view.request ? view.request.query_parameters : {}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# The single `filter` local of the standalone filter form partial.
|
|
4
|
+
# Renders the fieldset's filterable fields (including its `filters:`
|
|
5
|
+
# extension); never auto-submits — users compose several filters here.
|
|
6
|
+
class Filter < Base
|
|
7
|
+
attr_reader :model, :structure, :query
|
|
8
|
+
|
|
9
|
+
def initialize(view:, model:, fieldset: nil, query: nil, param_prefix: nil)
|
|
10
|
+
super(view: view)
|
|
11
|
+
@model = model.is_a?(Class) ? model : model.klass
|
|
12
|
+
@structure = Structure.for(@model)
|
|
13
|
+
@query = if query.is_a?(Query)
|
|
14
|
+
query
|
|
15
|
+
else
|
|
16
|
+
Query.new(@model, view.request.query_parameters,
|
|
17
|
+
fieldset: @structure.fieldset(fieldset || :index),
|
|
18
|
+
ability: ability, param_prefix: param_prefix)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fields = query.filter_fields
|
|
23
|
+
def searchable? = query.searchable?
|
|
24
|
+
def form_path = view.request.path
|
|
25
|
+
def reset_path = view.request.path
|
|
26
|
+
|
|
27
|
+
def param_name(key) = query.param_name(key)
|
|
28
|
+
def value(key) = query.value(key)
|
|
29
|
+
|
|
30
|
+
# Keep sort and foreign params; our own controls resubmit themselves.
|
|
31
|
+
def preserved_params
|
|
32
|
+
own = fields.flat_map { |f| [param_name(f.name.to_s), param_name("#{f.name}_geq"), param_name("#{f.name}_leq")] }
|
|
33
|
+
own += [param_name('q'), param_name('page'), param_name('per')]
|
|
34
|
+
view.request.query_parameters.reject { |key, _| own.include?(key) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Presenters
|
|
3
|
+
# The single `form` local of the form partial. Derives a form from the
|
|
4
|
+
# same field metadata everything else uses; the host app's controller
|
|
5
|
+
# owns saving (with the matching CrudComponents.permitted_attributes list).
|
|
6
|
+
#
|
|
7
|
+
# Field selection falls back: the action's fieldset → :form → :default.
|
|
8
|
+
# A visible field that isn't editable (by type or permission) renders
|
|
9
|
+
# read-only rather than vanishing.
|
|
10
|
+
class Form < Base
|
|
11
|
+
attr_reader :record, :model, :structure, :action
|
|
12
|
+
|
|
13
|
+
def initialize(view:, record:, fieldset: nil, action: nil, url: nil, method: nil)
|
|
14
|
+
super(view: view)
|
|
15
|
+
@record = record
|
|
16
|
+
@model = record.class
|
|
17
|
+
@structure = Structure.for(@model)
|
|
18
|
+
@action = (action || (record.persisted? ? :edit : :new)).to_sym
|
|
19
|
+
@fieldset = fieldset ? @structure.fieldset(fieldset) : @structure.form_fieldset(@action)
|
|
20
|
+
@url = url
|
|
21
|
+
@method = method
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Visible fields that have a form representation (computed/json skipped).
|
|
25
|
+
def fields
|
|
26
|
+
structure.fieldset_fields(@fieldset)
|
|
27
|
+
.select { |f| f.form_control && f.permitted?(permission_context, record) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def editable?(field)
|
|
31
|
+
field.editable? && field.editable_permitted?(permission_context, record)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def any_errors?
|
|
35
|
+
record.errors.any?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Errors not attached to a visible field — base errors, or errors on a
|
|
39
|
+
# column the form doesn't show. Rendered in the summary so "fix N errors"
|
|
40
|
+
# is never a dead end with nothing to fix.
|
|
41
|
+
def summary_errors
|
|
42
|
+
shown = fields.map(&:name)
|
|
43
|
+
record.errors.reject { |error| shown.include?(error.attribute) }.map(&:full_message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# form_with options; nil url/method let Rails infer from the record.
|
|
47
|
+
def form_options
|
|
48
|
+
{ url: @url, method: @method }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Read-only display reuses the value renderer (record surface).
|
|
52
|
+
def display(field)
|
|
53
|
+
render_cell(field, record, surface: :record)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|