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,246 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# The DSL evaluated inside `crud_structure do … end`. Its instance methods —
|
|
3
|
+
# {#attribute}, {#attributes}, {#action}, {#fieldset}, {#label},
|
|
4
|
+
# {#identify_by}, {#search_in} — are the public declaration API; the block
|
|
5
|
+
# runs against a Builder instance. It only collects declarations; {Structure}
|
|
6
|
+
# resolves and validates them against what Rails already knows.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class Book < ApplicationRecord
|
|
10
|
+
# include CrudComponents::Model
|
|
11
|
+
# crud_structure do
|
|
12
|
+
# label :title
|
|
13
|
+
# identify_by :slug
|
|
14
|
+
# attribute :title { filter :title; sort :title }
|
|
15
|
+
# fieldset :index, %i[title author], actions: %i[edit destroy]
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
class Builder
|
|
19
|
+
attr_reader :model, :declarations, :actions, :fieldsets,
|
|
20
|
+
:label_decl, :identify_by_decl, :search_decl,
|
|
21
|
+
:label_preload_decl, :preload_decl, :icon_decl
|
|
22
|
+
|
|
23
|
+
# @param model [Class] the ActiveRecord model being described.
|
|
24
|
+
# @yield the `crud_structure` block, evaluated against this Builder.
|
|
25
|
+
# @api private
|
|
26
|
+
def initialize(model, &block)
|
|
27
|
+
@model = model
|
|
28
|
+
@declarations = {}
|
|
29
|
+
@actions = {}
|
|
30
|
+
@fieldsets = {}
|
|
31
|
+
instance_exec(&block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# How a record is titled (links, headings). Give a method name or a block.
|
|
35
|
+
# @param method [Symbol, nil] a method on the record returning its label.
|
|
36
|
+
# @param preload [Array<Symbol>, Symbol, nil] associations the label reaches
|
|
37
|
+
# into (`label :full_title, preload: %i[customer training]`). They're
|
|
38
|
+
# eager-loaded automatically whenever this model is shown as another
|
|
39
|
+
# model's association column — declare once, no N+1 anywhere.
|
|
40
|
+
# @yield [record] computes the label; receives the record.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def label(method = nil, preload: nil, &block)
|
|
43
|
+
raise DefinitionError, "#{model}: label declared twice" if defined?(@label_decl) && @label_decl
|
|
44
|
+
raise DefinitionError, "#{model}: label takes a method name or a block, not both" if method && block
|
|
45
|
+
raise DefinitionError, "#{model}: label needs a method name or a block" unless method || block
|
|
46
|
+
|
|
47
|
+
@label_decl = block || method.to_sym
|
|
48
|
+
@label_preload_decl = preload_list(preload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Associations to eager-load whenever this model is rendered (as a row or as
|
|
52
|
+
# another model's association cell) — for label/render dependencies the gem
|
|
53
|
+
# can't infer. Additive with `label …, preload:`; declare more than once to
|
|
54
|
+
# accumulate. e.g. `preload :customer, :training`.
|
|
55
|
+
# @param names [Array<Symbol>] association names (nested hashes allowed).
|
|
56
|
+
# @return [void]
|
|
57
|
+
def preload(*names)
|
|
58
|
+
@preload_decl = (@preload_decl || []) + names
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The column used in URLs (`to_param`) and to resolve a bulk selection.
|
|
62
|
+
# @param column [Symbol] e.g. `:slug`. Defaults to `:id` when undeclared.
|
|
63
|
+
# @return [void]
|
|
64
|
+
def identify_by(column)
|
|
65
|
+
raise DefinitionError, "#{model}: identify_by declared twice" if @identify_by_decl
|
|
66
|
+
|
|
67
|
+
@identify_by_decl = column.to_sym
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The icon (no library prefix — paired with `css.icon_prefix`) that badges
|
|
71
|
+
# this model wherever it appears: column-picker groups, association links,
|
|
72
|
+
# path-column cells. Overrides the name-based guess in `config.model_icons`.
|
|
73
|
+
# @param name [String, Symbol] a Bootstrap-icon name, e.g. `'building'`.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def icon(name)
|
|
76
|
+
raise DefinitionError, "#{model}: icon declared twice" if @icon_decl
|
|
77
|
+
|
|
78
|
+
@icon_decl = name.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The columns/associations full-text search (`?q=`) spans, in the same
|
|
82
|
+
# mini-language as the positional `filter` spec.
|
|
83
|
+
# @param spec [Array<Symbol, Hash>] e.g. `:title, authors: %i[name email]`.
|
|
84
|
+
# @yield [scope, term] a custom search; receives the scope and the term.
|
|
85
|
+
# @return [void]
|
|
86
|
+
def search_in(*spec, &block)
|
|
87
|
+
raise DefinitionError, "#{model}: search_in declared twice" if @search_decl
|
|
88
|
+
raise DefinitionError, "#{model}: search_in takes a spec or a block, not both" if spec.any? && block
|
|
89
|
+
raise DefinitionError, "#{model}: search_in needs a spec or a block" if spec.empty? && !block
|
|
90
|
+
|
|
91
|
+
@search_decl = block || spec
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Declare (or refine) one attribute. The optional block sets facets: a
|
|
95
|
+
# one-arity block is the render facet; a zero-arity block declares
|
|
96
|
+
# `filter`/`sort`/`render` (see {FacetCollector}).
|
|
97
|
+
# @param name [Symbol] the attribute/column/association name.
|
|
98
|
+
# @param options [Hash] e.g. `as:` (renderer), `form_as:`, `if:`,
|
|
99
|
+
# `editable:`, `label:`, `null:`.
|
|
100
|
+
# @yield optional facet block.
|
|
101
|
+
# @return [void]
|
|
102
|
+
def attribute(name, **options, &block)
|
|
103
|
+
name = name.to_sym
|
|
104
|
+
if @declarations.key?(name)
|
|
105
|
+
raise DefinitionError, "#{model}: attribute :#{name} declared twice — merge the declarations"
|
|
106
|
+
end
|
|
107
|
+
if RESERVED_PARAMS.include?(name.to_s)
|
|
108
|
+
raise DefinitionError, "#{model}: :#{name} is a reserved param name " \
|
|
109
|
+
"(#{RESERVED_PARAMS.join(', ')}) and cannot be a field"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@declarations[name] = { options: options, facets: parse_facets(name, block) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Declare several attributes that share the same options.
|
|
116
|
+
# @param names [Array<Symbol>] one or more attribute names.
|
|
117
|
+
# @param options [Hash] applied to each (see {#attribute}).
|
|
118
|
+
# @return [void]
|
|
119
|
+
def attributes(*names, **options)
|
|
120
|
+
raise DefinitionError, "#{model}: attributes needs at least one name" if names.empty?
|
|
121
|
+
|
|
122
|
+
names.each { |name| attribute(name, **options) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Declare a custom action button. The block returns its path, evaluated in
|
|
126
|
+
# the view context (and given the record for a row action).
|
|
127
|
+
# @param name [Symbol] the action name (also the i18n/route key).
|
|
128
|
+
# @param options [Hash] `on:` (`:row`/`:collection`/`:selection`), `icon:`,
|
|
129
|
+
# `title:`, `class:`, `confirm:`, `method:`, `if:`.
|
|
130
|
+
# @yield the path block.
|
|
131
|
+
# @return [void]
|
|
132
|
+
def action(name, **options, &path_block)
|
|
133
|
+
name = name.to_sym
|
|
134
|
+
raise DefinitionError, "#{model}: action :#{name} declared twice" if @actions.key?(name)
|
|
135
|
+
|
|
136
|
+
@actions[name] = Action.new(name, **options, &path_block)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A named selection of fields + actions for a surface (index/show/form/…).
|
|
140
|
+
# @param name [Symbol] the fieldset name.
|
|
141
|
+
# @param fields [Array<Symbol>, :all] which fields, in order (`:all` = every
|
|
142
|
+
# declared/derived field).
|
|
143
|
+
# @param actions [Array<Symbol>, nil] curate the actions (per kind); nil keeps
|
|
144
|
+
# the derived defaults.
|
|
145
|
+
# @param filters [Array<Symbol>, nil] filterable fields beyond the visible ones.
|
|
146
|
+
# @return [void]
|
|
147
|
+
def fieldset(name, fields = :all, actions: nil, filters: nil)
|
|
148
|
+
name = name.to_sym
|
|
149
|
+
raise DefinitionError, "#{model}: fieldset :#{name} declared twice" if @fieldsets.key?(name)
|
|
150
|
+
|
|
151
|
+
@fieldsets[name] = Fieldset.new(name, fields, actions: actions, filters: filters)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Normalize a preload value to an array of includes-specs, leaving a nested
|
|
157
|
+
# hash (`{ customer: :company }`) intact (Array() would split it).
|
|
158
|
+
def preload_list(value)
|
|
159
|
+
case value
|
|
160
|
+
when nil then []
|
|
161
|
+
when Array then value
|
|
162
|
+
else [value]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Bare block taking the record (arity 1, including `it`/`_1`) is the
|
|
167
|
+
# render facet; a zero-arity block declares facets.
|
|
168
|
+
def parse_facets(name, block)
|
|
169
|
+
return {} unless block
|
|
170
|
+
return { render: block } unless block.arity.zero?
|
|
171
|
+
|
|
172
|
+
FacetCollector.new(model, name).collect(&block)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class FacetCollector
|
|
176
|
+
NONE = Object.new
|
|
177
|
+
|
|
178
|
+
def initialize(model, name)
|
|
179
|
+
@model = model
|
|
180
|
+
@name = name
|
|
181
|
+
@facets = {}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def collect(&block)
|
|
185
|
+
instance_exec(&block)
|
|
186
|
+
@facets
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def render(*args, &block)
|
|
190
|
+
once!(:render)
|
|
191
|
+
unless block && args.empty?
|
|
192
|
+
raise DefinitionError, "#{where}: the render facet takes only a block — " \
|
|
193
|
+
'named renderers are picked with the as: keyword on attribute'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@facets[:render] = block
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A like-spec passed positionally (same mini-language as `search_in`):
|
|
200
|
+
# filter :title own column
|
|
201
|
+
# filter :title, :subtitle several columns, OR-combined
|
|
202
|
+
# filter authors: %i[name email] join, explicit columns
|
|
203
|
+
# filter :publisher join, delegate to the target's search_in
|
|
204
|
+
# filter :title, { authors: :name } mixed
|
|
205
|
+
# plus `filter false` (off) and `filter { |scope, value| ... }` (block).
|
|
206
|
+
def filter(*spec, **assoc, &block)
|
|
207
|
+
once!(:filter)
|
|
208
|
+
spec << assoc unless assoc.empty?
|
|
209
|
+
|
|
210
|
+
case
|
|
211
|
+
when spec == [false] && !block then @facets[:filter] = false
|
|
212
|
+
when block && spec.empty? then @facets[:filter] = block
|
|
213
|
+
when !spec.empty? && !block then @facets[:filter] = spec.size == 1 ? spec.first : spec
|
|
214
|
+
else
|
|
215
|
+
raise DefinitionError, "#{where}: filter takes `false`, a column/association spec " \
|
|
216
|
+
'(e.g. `filter :title` or `filter authors: %i[name email]`), or a block'
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def sort(arg = NONE, &block)
|
|
221
|
+
once!(:sort)
|
|
222
|
+
case
|
|
223
|
+
when arg == false && !block then @facets[:sort] = false
|
|
224
|
+
when block && arg.equal?(NONE) then @facets[:sort] = block
|
|
225
|
+
when arg.is_a?(Symbol) && !block then @facets[:sort] = arg
|
|
226
|
+
else
|
|
227
|
+
raise DefinitionError, "#{where}: sort takes `false`, an own-column symbol, or a block"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def method_missing(name, *)
|
|
232
|
+
raise DefinitionError, "#{where}: unknown facet '#{name}' — facets are render, filter and sort"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def respond_to_missing?(_name, _include_private = false) = true
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def once!(facet)
|
|
240
|
+
raise DefinitionError, "#{where}: #{facet} facet declared twice" if @facets.key?(facet)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def where = "#{@model} attribute :#{@name}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require 'active_support/ordered_options'
|
|
2
|
+
|
|
3
|
+
module CrudComponents
|
|
4
|
+
class Config
|
|
5
|
+
# When changed, add it to initializer.rb
|
|
6
|
+
DEFAULT_CSS = {
|
|
7
|
+
table: 'table align-middle',
|
|
8
|
+
thead: '',
|
|
9
|
+
toolbar: 'd-flex justify-content-between align-items-center gap-2 mb-2',
|
|
10
|
+
search_form: 'd-flex gap-1',
|
|
11
|
+
filter_row: 'crud-filter-row',
|
|
12
|
+
sort_link: 'text-reset text-decoration-none',
|
|
13
|
+
record_link: 'fw-medium',
|
|
14
|
+
filter_link: 'text-reset text-decoration-none',
|
|
15
|
+
actions_cell: 'text-end',
|
|
16
|
+
button_group: 'btn-group btn-group-sm',
|
|
17
|
+
button: 'btn btn-sm btn-outline-secondary',
|
|
18
|
+
button_primary: 'btn btn-sm btn-primary',
|
|
19
|
+
button_danger: 'btn btn-sm btn-outline-danger',
|
|
20
|
+
pagination: 'pagination pagination-sm',
|
|
21
|
+
badge: 'badge text-bg-secondary',
|
|
22
|
+
badge_muted: 'badge text-bg-light',
|
|
23
|
+
input: 'form-control',
|
|
24
|
+
input_sm: 'form-control form-control-sm',
|
|
25
|
+
# named *_input to avoid OrderedOptions#select (Hash#select) collisions
|
|
26
|
+
select_input: 'form-select',
|
|
27
|
+
select_input_sm: 'form-select form-select-sm',
|
|
28
|
+
form_label: 'form-label',
|
|
29
|
+
form_summary: 'alert alert-danger',
|
|
30
|
+
filter_grid: 'row row-cols-1 g-2',
|
|
31
|
+
input_group: 'input-group flex-nowrap',
|
|
32
|
+
boolean_true: 'text-success',
|
|
33
|
+
boolean_false: 'text-danger',
|
|
34
|
+
muted: 'text-muted',
|
|
35
|
+
# icon font base + name prefix; built-in icon names are Bootstrap Icons.
|
|
36
|
+
# Swap the whole library here, e.g. 'fa fa-' for Font Awesome.
|
|
37
|
+
icon_prefix: 'bi bi-',
|
|
38
|
+
dl: 'row',
|
|
39
|
+
dt: 'col-sm-3',
|
|
40
|
+
dd: 'col-sm-9'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Icon name (no library prefix — paired with css.icon_prefix) for each
|
|
44
|
+
# derived action. Override a glyph, or set one to nil for no icon:
|
|
45
|
+
# config.action_icons[:destroy] = 'trash-fill'
|
|
46
|
+
DEFAULT_ACTION_ICONS = {
|
|
47
|
+
new: 'plus-lg', show: 'eye', edit: 'pencil', destroy: 'trash'
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Map of file extension → icon name (no library prefix — paired with
|
|
51
|
+
# css.icon_prefix) for the attachment icon fallback; an unmapped extension
|
|
52
|
+
# uses file_fallback_icon. Full names (not a prefix) so non-conforming ones
|
|
53
|
+
# fit too — e.g. yaml→filetype-yml, zip→file-earmark-zip. Bootstrap Icons'
|
|
54
|
+
# whole `filetype-*` family is included; override/extend per your set.
|
|
55
|
+
DEFAULT_FILE_ICONS = %w[
|
|
56
|
+
aac ai bmp cs css csv doc docx exe gif heic html java jpg js json jsx key
|
|
57
|
+
m4p md mdx mov mp3 mp4 otf pdf php png ppt pptx psd py raw rb sass scss sh
|
|
58
|
+
sql svg tiff tsx ttf txt wav woff xls xlsx xml yml
|
|
59
|
+
].to_h { |ext| [ext, "filetype-#{ext}"] }.merge(
|
|
60
|
+
'yaml' => 'filetype-yml', # alias of yml
|
|
61
|
+
'jpeg' => 'filetype-jpg', # alias of jpg
|
|
62
|
+
'zip' => 'file-earmark-zip' # no filetype- glyph exists
|
|
63
|
+
).freeze
|
|
64
|
+
|
|
65
|
+
# A guessed icon (no library prefix — paired with css.icon_prefix) per model,
|
|
66
|
+
# keyed by the model's singular underscored name (`model_name.element`, so
|
|
67
|
+
# `Admin::User` → "user"). Used wherever a model is badged: column-picker
|
|
68
|
+
# groups, association links, path-column cells. A model can override with
|
|
69
|
+
# `icon 'building'` in its `crud_structure`; an unmapped model with no
|
|
70
|
+
# declaration falls back to model_fallback_icon (nil = no icon). Extend it:
|
|
71
|
+
# config.model_icons['widget'] = 'box-seam'
|
|
72
|
+
DEFAULT_MODEL_ICONS = {
|
|
73
|
+
'user' => 'person', 'person' => 'person', 'author' => 'person',
|
|
74
|
+
'member' => 'person', 'customer' => 'person', 'contact' => 'person-lines-fill',
|
|
75
|
+
'account' => 'person-circle', 'profile' => 'person-badge', 'admin' => 'person-gear',
|
|
76
|
+
'participant' => 'person', 'student' => 'mortarboard', 'teacher' => 'easel',
|
|
77
|
+
'team' => 'people', 'group' => 'people', 'role' => 'person-badge',
|
|
78
|
+
'organization' => 'building', 'company' => 'building', 'publisher' => 'building',
|
|
79
|
+
'department' => 'building', 'vendor' => 'shop', 'supplier' => 'shop',
|
|
80
|
+
'store' => 'shop', 'shop' => 'shop',
|
|
81
|
+
'book' => 'book', 'article' => 'file-earmark-text', 'post' => 'file-earmark-post',
|
|
82
|
+
'page' => 'file-earmark', 'document' => 'file-earmark', 'file' => 'file-earmark',
|
|
83
|
+
'attachment' => 'paperclip', 'report' => 'file-earmark-bar-graph',
|
|
84
|
+
'order' => 'cart', 'cart' => 'cart', 'product' => 'box-seam', 'item' => 'box',
|
|
85
|
+
'invoice' => 'receipt', 'receipt' => 'receipt', 'payment' => 'credit-card',
|
|
86
|
+
'transaction' => 'credit-card', 'subscription' => 'arrow-repeat',
|
|
87
|
+
'comment' => 'chat', 'message' => 'chat-dots', 'review' => 'star', 'rating' => 'star',
|
|
88
|
+
'notification' => 'bell', 'email' => 'envelope', 'mail' => 'envelope',
|
|
89
|
+
'tag' => 'tag', 'label' => 'tag', 'category' => 'collection', 'genre' => 'collection',
|
|
90
|
+
'project' => 'kanban', 'task' => 'check2-square', 'todo' => 'check2-square',
|
|
91
|
+
'ticket' => 'ticket', 'event' => 'calendar-event', 'appointment' => 'calendar-check',
|
|
92
|
+
'booking' => 'calendar-check', 'course' => 'mortarboard', 'lesson' => 'easel',
|
|
93
|
+
'address' => 'geo-alt', 'location' => 'geo-alt', 'place' => 'geo-alt',
|
|
94
|
+
'country' => 'globe', 'city' => 'geo-alt',
|
|
95
|
+
'image' => 'image', 'photo' => 'image', 'video' => 'camera-video', 'media' => 'collection-play',
|
|
96
|
+
'setting' => 'gear', 'permission' => 'shield-lock'
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
attr_accessor :select_limit, :group_collapse_threshold, :action_icons,
|
|
100
|
+
:file_icons, :file_fallback_icon, :fast_cells, :max_path_depth,
|
|
101
|
+
:model_icons, :model_fallback_icon
|
|
102
|
+
attr_reader :css
|
|
103
|
+
|
|
104
|
+
def initialize
|
|
105
|
+
@select_limit = 250
|
|
106
|
+
# Grouped collections open every group when the total row count is below
|
|
107
|
+
# this, and only the first group above it (the rest collapse).
|
|
108
|
+
@group_collapse_threshold = 50
|
|
109
|
+
# How many associations a path column (e.g. `publisher.country.name`) may
|
|
110
|
+
# chain through. A guard rail against runaway joins — raise it if you have
|
|
111
|
+
# legitimately deeper paths. (Crossing more than one *to-many* association
|
|
112
|
+
# is forbidden regardless of this, since that yields a list-of-lists.)
|
|
113
|
+
@max_path_depth = 3
|
|
114
|
+
# Render built-in cell types inline (in Ruby) instead of one partial per
|
|
115
|
+
# cell — an order of magnitude faster on big tables. A host override of a
|
|
116
|
+
# field partial is still honored; set false to force partials everywhere.
|
|
117
|
+
@fast_cells = true
|
|
118
|
+
@css = ActiveSupport::OrderedOptions.new.merge!(DEFAULT_CSS)
|
|
119
|
+
@action_icons = DEFAULT_ACTION_ICONS.dup
|
|
120
|
+
@file_icons = DEFAULT_FILE_ICONS.dup
|
|
121
|
+
@file_fallback_icon = 'file-earmark-text'
|
|
122
|
+
@model_icons = DEFAULT_MODEL_ICONS.dup
|
|
123
|
+
# No generic badge for an unmapped, undeclared model — set a glyph here to
|
|
124
|
+
# icon every model (e.g. 'box') if you prefer.
|
|
125
|
+
@model_fallback_icon = nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# A column whose data lives *outside* the model's own table — a user-defined
|
|
3
|
+
# property kept in a separate store (definition + value tables, a JSONB blob,
|
|
4
|
+
# an external API). The model knows nothing about it; you build one per request
|
|
5
|
+
# from wherever your custom properties live and hand it to `crud_collection`
|
|
6
|
+
# via `extra_columns:`.
|
|
7
|
+
#
|
|
8
|
+
# CrudComponents::DynamicColumn.new(:priority,
|
|
9
|
+
# label: 'Priority', as: :number,
|
|
10
|
+
# if: -> { can?(:read, prop) }, # same gate as a field's if:
|
|
11
|
+
# preload: ->(records) { # batch-load once per page (no N+1)
|
|
12
|
+
# PropertyValue.where(definition: prop, subject: records).index_by(&:subject_id)
|
|
13
|
+
# },
|
|
14
|
+
# sort: ->(scope, dir) { ... }, # optional — omit for display-only
|
|
15
|
+
# filter: ->(scope, value) { ... }) { |record, loaded| loaded[record.id]&.value }
|
|
16
|
+
#
|
|
17
|
+
# The block is the value resolver: `|record|` or `|record, loaded|`, where
|
|
18
|
+
# `loaded` is whatever `preload:` returned. It returns a plain value that the
|
|
19
|
+
# `as:` renderer (or, with no `as:`, the value's type) displays — exactly like
|
|
20
|
+
# a computed field. `filter:`/`sort:` are the same facet blocks the DSL takes;
|
|
21
|
+
# supply them only when the data is reachable in SQL, otherwise the column is
|
|
22
|
+
# display-only and never reaches the query layer.
|
|
23
|
+
#
|
|
24
|
+
# A dynamic column often *is* a domain object (a mail, a resource), so its
|
|
25
|
+
# header can carry a link and its own bulk actions, rendered in the `<th>`:
|
|
26
|
+
#
|
|
27
|
+
# CrudComponents::DynamicColumn.new(:mail_42,
|
|
28
|
+
# label: 'Welcome mail',
|
|
29
|
+
# header: -> { link_to mail.name, mail }, # HTML-safe String or a view-context block
|
|
30
|
+
# header_actions: [ # the same Action API as row/collection actions
|
|
31
|
+
# CrudComponents::Action.new(:send_all, icon: 'send', method: :post) { send_all_path(mail) },
|
|
32
|
+
# CrudComponents::Action.new(:unschedule_all, method: :post) { unschedule_all_path(mail) }
|
|
33
|
+
# ],
|
|
34
|
+
# preload: ->(records) { ... }) { |record, loaded| loaded[record.id] }
|
|
35
|
+
#
|
|
36
|
+
# `header:` replaces the plain `human_name` text (a String is rendered as-is —
|
|
37
|
+
# mark it `html_safe` if it carries markup; a block is `instance_exec`ed in the
|
|
38
|
+
# view, so it may call `link_to` and friends). `header_actions:` renders after
|
|
39
|
+
# the header; an `on: :selection` action acts on the ticked rows (it submits the
|
|
40
|
+
# shared select-form, so its path closes over the column's object × the
|
|
41
|
+
# selection), while any other action renders as a plain link/button.
|
|
42
|
+
#
|
|
43
|
+
# header:/header_actions: are not consumed here — they ride along in `options`
|
|
44
|
+
# and are read off the field by Fields::Base, exactly like a declared
|
|
45
|
+
# attribute's `attribute :x, header_actions: […]`.
|
|
46
|
+
class DynamicColumn
|
|
47
|
+
attr_reader :name, :options, :facets, :preload_block, :value_block
|
|
48
|
+
|
|
49
|
+
# Keys consumed here; everything else (as:, if:, label:, header:,
|
|
50
|
+
# header_actions:, unit:, digits:, …) flows into `options` just like a
|
|
51
|
+
# declared attribute's options.
|
|
52
|
+
FACET_KEYS = %i[filter sort render].freeze
|
|
53
|
+
|
|
54
|
+
def initialize(name, preload: nil, **opts, &value_block)
|
|
55
|
+
@name = name.to_sym
|
|
56
|
+
@value_block = value_block
|
|
57
|
+
@preload_block = preload
|
|
58
|
+
@facets = opts.slice(*FACET_KEYS).compact
|
|
59
|
+
@options = opts.except(*FACET_KEYS)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A request-scoped field bound to `model`. Not memoized on the Structure, so
|
|
63
|
+
# it may carry the per-request value cache (see Fields::DynamicField).
|
|
64
|
+
def to_field(model)
|
|
65
|
+
Fields::DynamicField.new(self, model)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'simple_form' # form rendering delegates to simple_form (a runtime dep)
|
|
2
|
+
require_relative 'route_resolver'
|
|
3
|
+
require_relative 'markup'
|
|
4
|
+
require_relative 'presenters/base'
|
|
5
|
+
require_relative 'presenters/column_selection'
|
|
6
|
+
require_relative 'presenters/cells'
|
|
7
|
+
require_relative 'presenters/cell_context'
|
|
8
|
+
require_relative 'presenters/actions'
|
|
9
|
+
require_relative 'presenters/collection'
|
|
10
|
+
require_relative 'presenters/record'
|
|
11
|
+
require_relative 'presenters/filter'
|
|
12
|
+
require_relative 'presenters/form'
|
|
13
|
+
require_relative 'helpers'
|
|
14
|
+
|
|
15
|
+
module CrudComponents
|
|
16
|
+
# Dependency-free engine: adds the gem's view path (partials under
|
|
17
|
+
# app/views/crud_components/), the helpers, and the generators.
|
|
18
|
+
class Engine < ::Rails::Engine
|
|
19
|
+
initializer 'crud_components.helpers' do
|
|
20
|
+
ActiveSupport.on_load(:action_view) do
|
|
21
|
+
include CrudComponents::Helpers
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# DSL misuse, detected when the structure is built (boot / first use).
|
|
5
|
+
class DefinitionError < Error; end
|
|
6
|
+
|
|
7
|
+
# An explicitly requested fieldset that the model does not declare.
|
|
8
|
+
class UnknownFieldsetError < Error; end
|
|
9
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Fields
|
|
3
|
+
# Active Storage attachment: rendered by content type — image inline,
|
|
4
|
+
# previewable (e.g. PDF) as a preview, otherwise an icon + filename link.
|
|
5
|
+
class AttachmentField < Base
|
|
6
|
+
def default_renderer = :attachment
|
|
7
|
+
|
|
8
|
+
def many?
|
|
9
|
+
@many ||= model.reflect_on_attachment(name).macro == :has_many_attached
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def eager_load
|
|
13
|
+
[many? ? :"#{name}_attachments" : :"#{name}_attachment", *declared_preloads]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# ── forms ────────────────────────────────────────────────────────────
|
|
17
|
+
def default_editable? = true
|
|
18
|
+
def form_control = :file
|
|
19
|
+
def permit_param = many? ? { name => [] } : name
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|