crud_components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. metadata +209 -0
@@ -0,0 +1,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