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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 300e08f93a98c2af440d843de88c42796428591c842cf037a6969c76f0bbfce6
4
+ data.tar.gz: 65b817f9e2663d64159142b157b6326887f52f5bf93e684294d1288cfb4c5693
5
+ SHA512:
6
+ metadata.gz: 1ad9372ab031db1343c6afccf5f2f8525e593e00e23da8ee3b9a868b2e11a0dbbb5971dea96d348d2689517de69ea0da1aa9f9e589fa64dc825767f66717c72d
7
+ data.tar.gz: 4b5f31a8f4e4b77a3fcef9ca7a27ce31ee4e254c071c5710b1b9816e1d59d5412db40551ffba641b3e50a928386d91fcb4ead63e09ad0013d284d67dffbd0f55
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ Gemfile.lock
2
+ *.gem
3
+ test/dummy/log/
4
+ test/dummy/tmp/
5
+ test/dummy/db/*.sqlite3
6
+ test/dummy/db/*.sqlite3-*
7
+ # Active Storage blobs — regenerated by db:seed
8
+ test/dummy/storage/
9
+ .DS_Store
data/Gemfile ADDED
@@ -0,0 +1,23 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ # The CI matrix sets RAILS_VERSION to pin a minor (e.g. 7.1, 7.2, 8.0) and
6
+ # resolves a fresh lockfile per combo; unset, dev just uses the latest.
7
+ rails_version = ENV['RAILS_VERSION']
8
+
9
+ group :development, :test do
10
+ gem 'image_processing' # playground only: lets the attachment renderer preview PDFs (with poppler)
11
+ gem 'ruby-vips' # playground only: the libvips backend image_processing uses to make previews
12
+ gem 'kaminari' # playground only: the gem ships no pager; the demo brings one
13
+ # playground only: soft-dependency renderers (as: :markdown / :asciidoc / :json highlight).
14
+ # crud_components feature-detects these — it never requires them itself.
15
+ gem 'commonmarker' # as: :markdown
16
+ gem 'asciidoctor' # as: :asciidoc
17
+ gem 'rouge' # :json cell syntax highlighting
18
+ gem 'minitest'
19
+ gem 'puma'
20
+ gem 'rails', rails_version ? "~> #{rails_version}.0" : '>= 7.1'
21
+ gem 'rake'
22
+ gem 'sqlite3' # unconstrained: bundler picks a version compatible with the Rails above
23
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anatoly Zelenin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,511 @@
1
+ # CrudComponents
2
+
3
+ > **AI disclaimer:** This Gem is developed with the help of Claude. It is architected and reviewed by me, but the code is generated by Claude.
4
+
5
+ Declarative CRUD UI for ActiveRecord models, primarily for admin backends — rendered
6
+ **inside your app**: your layout, your routes, your authorization, your styling, mixed
7
+ freely with hand-written pages. Not an admin island with its own theme and navigation.
8
+
9
+ The core promise: **zero configuration already works.** A bare ActiveRecord model
10
+ renders as a usable, filterable, sortable table in one line. Configuration only ever
11
+ *improves* what you get — it never has to *enable* it, and removing any line of config
12
+ falls back to a sensible default.
13
+
14
+ ```erb
15
+ <%= crud_collection Book.all %>
16
+ ```
17
+
18
+ That line gives you a table with type-appropriate cells, sortable headers, an inline
19
+ filter row, and working URL params — `?genre=scifi&price_leq=20&sort=title&dir=desc`
20
+ filters and sorts it, with or without JavaScript. No controller code, no model code.
21
+
22
+ **▶ Live demo: [crud-components.zelenin.de](https://crud-components.zelenin.de)** — a
23
+ browsable playground where every feature has a running page with the exact DSL behind it.
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ gem 'crud_components'
30
+ ```
31
+
32
+ Requires Rails >= 7.1 and Ruby >= 3.2.
33
+
34
+ Optionally, run the installer for an initializer for simple styling
35
+ and optional Stimulus (JavaScript) controllers. (progressive enhancement; everything works without them):
36
+
37
+ ```sh
38
+ bin/rails generate crud_components:install
39
+ ```
40
+
41
+ **Styling.** The gem renders plain Bootstrap 5 utility classes (swap them in the
42
+ [class map](docs/extending.md#styling)), plus one tiny stylesheet for the column-picker
43
+ dropdown. Load it pipeline-agnostically by dropping the helper in your layout `<head>`:
44
+
45
+ ```erb
46
+ <%= crud_components_styles %> <%# inlines the gem's CSS as a <style> tag, CSP-nonce aware %>
47
+ ```
48
+
49
+ It needs no asset compilation, so it works the same under importmap, sprockets, propshaft or
50
+ cssbundling. Hosts whose pipeline already serves engine assets can instead
51
+ `stylesheet_link_tag "crud_components"`.
52
+
53
+ ## The running example
54
+
55
+ A small bookstore, used throughout the docs:
56
+
57
+ ```ruby
58
+ class Book < ApplicationRecord
59
+ belongs_to :publisher
60
+ has_many :reviews
61
+ has_and_belongs_to_many :authors
62
+ has_one_attached :cover
63
+ enum :genre, { fiction: 0, scifi: 1, nonfiction: 2 }
64
+ # columns: title, subtitle, slug, blurb (text), price (decimal),
65
+ # pages (integer), published_on (date), active (boolean)
66
+ end
67
+
68
+ class Publisher < ApplicationRecord; has_many :books; end # name, slug, founded_on
69
+ class Author < ApplicationRecord; has_and_belongs_to_many :books; end # name, email
70
+ class Review < ApplicationRecord; belongs_to :book; end # rating (int), body (text)
71
+ ```
72
+
73
+ ## A table in one line
74
+
75
+ ![A derived collection table — cover thumbnails, a genre badge, currency, publisher links and a boolean icon, with header search, an inline filter row, sortable columns, row actions and a standalone filter sidebar](docs/screenshots/table.png)
76
+
77
+ ```erb
78
+ <%= crud_collection Book.all %>
79
+ ```
80
+
81
+ With zero configuration, derived from what Rails already knows:
82
+
83
+ - every column gets a type-appropriate cell and filter control (see the
84
+ [combination table](#the-combination-table));
85
+ - `genre` renders as a badge and filters as a select of the enum keys;
86
+ - associations become columns automatically — `publisher` (belongs_to) as a
87
+ nil-safe link, `authors` / `reviews` (has_many / habtm) as a truncated list
88
+ ("Tolkien, Lewis +3 more"); Active Storage attachments render by content type —
89
+ images inline, a PDF/other file as a preview or an icon + filename (`cover`,
90
+ `has_many_attached` sets, …);
91
+ - headers come from `human_attribute_name`, so your existing model i18n applies;
92
+ - filtering, search and sorting are read from the request params automatically — every
93
+ state is a plain GET URL, shareable and bookmark-safe.
94
+
95
+ A single record and a standalone filter form work the same way:
96
+
97
+ ```erb
98
+ <%= crud_record @book %> <%# definition list, same cell renderers %>
99
+ <%= crud_filter Book %> <%# labelled filter form, e.g. for a modal or sidebar %>
100
+ ```
101
+
102
+ The rest of this README is a short "now I want to…" tour. Every step is optional; nothing below is
103
+ required for any view above to work. Each section links to its in-depth reference.
104
+
105
+ ## The tour
106
+
107
+ ### Choose the columns
108
+
109
+ Include the DSL and curate with a **fieldset** — a named selection of fields:
110
+
111
+ ```ruby
112
+ class Book < ApplicationRecord
113
+ include CrudComponents::Model
114
+ crud_structure do
115
+ fieldset :default, %i[cover title genre price publisher] # [] is the off switch
116
+ end
117
+ end
118
+ ```
119
+
120
+ Curation happens *only* in fieldsets — declaring or customizing an attribute never adds
121
+ or removes a column. → [Views & fieldsets](docs/views.md)
122
+
123
+ ### Nicer cells
124
+
125
+ ![A record view as a definition list, each value rendered type-aware: currency, a boolean check, a genre badge, pretty-printed JSON and association links](docs/screenshots/record.png)
126
+
127
+ ```ruby
128
+ crud_structure do
129
+ # …
130
+ attribute :price, as: :number, unit: '€', digits: 2
131
+ attribute :blurb, as: :markdown # uses a markdown gem if present
132
+ attribute(:badge) { |book| tag.span(book.status, class: 'badge') } # custom markup
133
+ # …
134
+ end
135
+ ```
136
+
137
+ Built-in renderers are surface-aware; soft-dependency ones (`:markdown`, `:asciidoc`,
138
+ `:json` highlighting) use a gem when present. A name Rails doesn't
139
+ know falls back to a public model method. → [Fields & rendering](docs/fields.md)
140
+
141
+ ### Filter / sort a computed column
142
+
143
+ Filtering and sorting happen in SQL, so computed fields declare how — facets live
144
+ together in one block:
145
+
146
+ ```ruby
147
+ attribute :author_names do
148
+ render { |book| book.authors.map(&:name).to_sentence }
149
+ filter :authors # delegate to Author's search_in; or spell it out: filter authors: :name
150
+ sort { |scope, dir| scope.left_joins(:authors).order('authors.name' => dir) }
151
+ end
152
+ ```
153
+
154
+ The **search spec** — the mini-language `filter` and `search_in` share — builds joins +
155
+ parameterized, wildcard-escaped ILIKE from column/association names, nothing to sanitize.
156
+ An association name alone *delegates* to that model's `search_in`. →
157
+ [Fields → the search spec](docs/fields.md#the-search-spec)
158
+
159
+ ### Columns the model doesn't know about
160
+
161
+ ![A table with the declared columns (title, price) followed by four user-defined columns — Shelf (string), Weight (number with a unit), Signed (a boolean check) and Acquired (date) — each sortable and with its own filter input](docs/screenshots/custom-fields.png)
162
+
163
+ User-defined properties that live in a separate store (a definitions/values pair, JSONB,
164
+ an API) become extra columns without touching the model — build a `DynamicColumn` per
165
+ request and pass `extra_columns:`. A `preload:` lambda batch-loads the page (no N+1); add
166
+ `filter:`/`sort:` to make it query like a real column. A column that *is* a domain object
167
+ (a mail, a resource) can carry its own header link and bulk actions right in the `<th>` via
168
+ `header:` / `header_actions:`. A header action declared `on: :selection` acts on the **ticked
169
+ rows** × that column's object (it submits the shared select-form and makes the table
170
+ selectable); `on: :collection` is a plain "for all rows" button.
171
+
172
+ ```erb
173
+ <%= crud_collection @books, extra_columns: current_account.custom_property_columns %>
174
+ ```
175
+
176
+ ```ruby
177
+ CrudComponents::DynamicColumn.new(:mail_42, label: 'Welcome mail',
178
+ header: -> { link_to mail.name, mail },
179
+ header_actions: [CrudComponents::Action.new(:send, on: :selection, icon: 'send', method: :post) { send_path(mail) }],
180
+ preload: ->(records) { … }) { |record, loaded| loaded[record.id] }
181
+ ```
182
+
183
+ → [Fields → dynamic columns](docs/fields.md#dynamic-columns) ·
184
+ [custom headers & column actions](docs/fields.md#custom-headers-and-column-actions)
185
+
186
+ ### Let users pick their columns
187
+
188
+ ![The same table with the column-picker gear open in the header — a checklist of every column the user may see (declared and dynamic), each toggleable, with Apply and Reset](docs/screenshots/column-picker.png)
189
+
190
+ Pass `picker: true` and a **gear** appears in the header row: users hide and reorder
191
+ the columns they may see, grouped by source model (Pipedrive-style). It submits `?cols[]=` to
192
+ the same URL — like sort and filter — so it needs no endpoint, opens and works without
193
+ JavaScript (native `<details>`), and is always intersected with the permitted set (a forged
194
+ param can't reveal a gated column). By default (`picked_columns: :auto`) the gem reads the
195
+ param for you — ephemeral, nothing stored. To persist, resolve the selection in your
196
+ controller and pass it back as an Array; the gem then shows exactly that and never re-reads
197
+ the param. It's also a standalone helper (`crud_column_picker`) you can drop above a
198
+ `crud_record` detail view.
199
+
200
+ ```erb
201
+ <%# ephemeral — the gem reads ?cols= %>
202
+ <%= crud_collection @books, picker: true %>
203
+
204
+ <%# persisted — controller stores the pick, view replays it (nil → :auto until the first pick) %>
205
+ <%= crud_collection @books, picker: true, picked_columns: current_user.book_columns %>
206
+ ```
207
+
208
+ → [Views → column picker](docs/views.md#column-picker)
209
+
210
+ ### Global search, slugs, display names
211
+
212
+ ```ruby
213
+ label :title # default: name → title → first string column, else "Book #42"
214
+ identify_by :slug # Use slug in the URLs
215
+ search_in :title, :subtitle, :publisher # :publisher delegates to Publisher's search_in
216
+ ```
217
+
218
+ `label` + `identify_by` + `search_in` are a model's **identity** — and they define how
219
+ *other* models render, link and filter it through their associations. Declare Order's
220
+ identity once and every `belongs_to :order` column gets it for free. →
221
+ [Fields → identity](docs/fields.md#identity-label-identify_by-search_in)
222
+
223
+ ### Buttons / Actions
224
+
225
+ `:new`, `:show`, `:edit`, `:destroy` exist by default, **self-disabling** when a route
226
+ doesn't resolve or the user isn't permitted. Declare more; the block is the path:
227
+
228
+ ```ruby
229
+ action :preview, icon: 'eye' do |book| book_preview_path(book) end
230
+ action :import, on: :collection do import_books_path end
231
+ ```
232
+
233
+ → [Views → actions](docs/views.md#actions)
234
+
235
+ ### Different tables on different pages
236
+
237
+ ![Two independent collections (Books and Reviews) side by side on one page, each with its own search, filters and sort, isolated by param_prefix](docs/screenshots/dashboard.png)
238
+
239
+ Use fieldsets to display different sets of fields in different places.
240
+
241
+ ```ruby
242
+ fieldset :index, %i[cover title genre price publisher], actions: %i[preview edit destroy]
243
+ fieldset :playground, %i[cover title authors price published_on active]
244
+ ```
245
+
246
+ `crud_collection` can be configured with different fieldsets and layouts.
247
+
248
+ ```erb
249
+ <%= crud_collection @books, fieldset: :playground %>
250
+ <%= crud_collection @books, fieldset: :playground, layout: :cards %> <%# layout is a separate axis %>
251
+ ```
252
+
253
+ Filterability follows the fieldset — **you can only filter and sort what you can see** (a
254
+ security property, [below](#security)); `filters:` extends it when a surface wants more
255
+ filters than columns. → [Views & fieldsets](docs/views.md#fieldsets)
256
+
257
+ ### Only admins see purchase prices
258
+
259
+ We use [CanCanCan](https://github.com/CanCanCommunity/cancancan) by default for authorization.
260
+
261
+ ```ruby
262
+ attribute :purchase_price, if: :manage # invisible AND unfilterable for non-managers
263
+ ```
264
+
265
+ `if:` takes a symbol (`can?` sugar) or a lambda. A hidden field is hidden *everywhere,
266
+ including the query layer*. → [Security → permissions](docs/security.md#permissions)
267
+
268
+ If you want to use a different authorization library, use a lambda:
269
+
270
+ ```ruby
271
+ attribute :purchase_price, if: ->(record) { record.draft? }
272
+ ```
273
+
274
+ ### Create and edit records
275
+
276
+ ![A derived edit form via simple_form: required marks and valid-state inputs, read-only fields as compact labels, and the habtm association as a removable-chip picker with an "add" dropdown](docs/screenshots/form.png)
277
+
278
+ The gem uses [simple_form](https://github.com/heartcombo/simple_form) to render forms. You can override the form rendering by overriding the `_form.html.erb` partial.
279
+
280
+ ```erb
281
+ <%= crud_form @book %> <%# edit if persisted, new if not %>
282
+ ```
283
+
284
+ The gem does not own any controllers so it is your responsibility to handle the actions. But the gem provides a permit list for the controller you can use so the two can't drift:
285
+
286
+ ```ruby
287
+ params.require(:book).permit(*CrudComponents.permitted_attributes(Book, action: action_name.to_sym, ability: current_ability))
288
+ ```
289
+
290
+ `editable:` adds a second permission dimension (you may *see* a field but not *change*
291
+ it). → [Forms](docs/forms.md)
292
+
293
+ ### I have 50,000 books
294
+
295
+ ![A paginated table with the footer pager seated in the table's tfoot — "Page 3 of 15 · 120 total" on the left, a windowed page control on the right](docs/screenshots/pagination.png)
296
+
297
+ Take the query into your own hands to paginate or compose with a scope:
298
+
299
+ ```ruby
300
+ @query = CrudComponents::Query.new(Book, params, ability: current_ability)
301
+ @books = @query.apply(Book.accessible_by(current_ability)).page(params[:page]) # kaminari
302
+ ```
303
+
304
+ ```erb
305
+ <%= crud_collection @books, query: @query %> # If you use kaminari, the pagination is rendered automatically
306
+ ```
307
+
308
+ `query: :static` and `param_prefix:` handle several collections on one page. →
309
+ [Views → the manual query](docs/views.md#the-manual-query-pagination-and-big-tables)
310
+
311
+ ## Mental model
312
+
313
+ **Rule zero: everything works untouched; declarations only improve.** The field set is
314
+ always *all* derived columns/associations plus declared computed fields. Curation is
315
+ exclusively the job of fieldsets.
316
+
317
+ The whole DSL:
318
+
319
+
320
+ | Declaration | Role |
321
+ | ---------------------------- | -------------------------------------------------------------------------- |
322
+ | `attribute` / `attributes` | improve one/several fields (model-global) |
323
+ | `render` / `filter` / `sort` | facets inside an `attribute` block — override exactly one derived behavior |
324
+ | `label`, `identify_by` | identity: display name; the column URL params resolve |
325
+ | `search_in` | the model's text identity (`?q=`, and what delegation expands to) |
326
+ | `action` | buttons, per row or per collection |
327
+ | `fieldset` | a named *selection* of fields and actions |
328
+
329
+
330
+ Three ideas organize it:
331
+
332
+ 1. **Derived vs. declared — per facet.** Everything Rails knows is derived. A declared
333
+ facet overrides that facet only; the rest stays derived.
334
+ 2. **Definition vs. selection.** `attribute`/`action` define once, model-globally;
335
+ `fieldset` selects per surface. Never visibility flags on definitions.
336
+ 3. **Identity composes through associations.** `label` + `identify_by` + `search_in`
337
+ define how other models render, link and search this one. Declare once, correct
338
+ everywhere.
339
+
340
+ And one uniform query rule:
341
+
342
+ > **A URL param is applied iff it names a filterable field of the fieldset in play that
343
+ > the current user may see (or one of the reserved params `q`, `sort`, `dir`, `page`,
344
+ > `per`). Everything else never reaches SQL.**
345
+
346
+ ### The combination table
347
+
348
+ Keyed by what a field *is* — with zero config, every row applies without declarations.
349
+
350
+
351
+ | Field kind | Rendered as | Filter control | Query behavior | Sortable |
352
+ | ------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------- | ----------- |
353
+ | string / text column | text (truncated in tables) | text input | `ILIKE %v%`, wildcards escaped | yes |
354
+ | numeric column | number (`as: :number` for `unit:`/`digits:`) | min–max pair | `_geq`/`_leq` ranges + `?f=v` exact; non-finite ignored | yes |
355
+ | date / datetime column | localized | from–to pair | whole-day-inclusive ranges + exact day | yes |
356
+ | boolean column | ✓/✗ icon (click to filter; `—` if null) | any/yes/no select (+ "not set" if nullable) | cast & validated; invalid ignored; "not set" → IS NULL | yes |
357
+ | enum | i18n'd badge (click to filter; `—` if null) | select of enum keys (+ "not set" if nullable) | validated against the enum; "not set" → IS NULL | yes |
358
+ | json column | pretty `<pre>` (rouge if present) | — | — | no |
359
+ | Active Storage attachment | image / preview / icon by content type | — | — | no |
360
+ | `belongs_to` | nil-safe link via target's `label` | select valued by target's `identify_by` (text over `search_in` above `select_limit`) | `where(assoc: Target.where(identify_by => v))` | v2 |
361
+ | `has_many` / habtm | "a, b +n more" links | opt-in via `filter` facet | — | no |
362
+ | public model method | by value type | — | — | — |
363
+ | `render` block / `as:` | as declared | — *(unless `filter` facet)* | — *(unless `sort` facet)* | facet-gated |
364
+
365
+
366
+ The bottom rows have empty query cells for a principled reason: filtering and sorting run
367
+ in SQL, and a Ruby-computed value has no SQL meaning until a facet gives it one. Full
368
+ per-flavor detail: [Fields & rendering](docs/fields.md#field-flavors-in-depth).
369
+
370
+ ## Security
371
+
372
+ The gem turns untrusted URL params into SQL safely; the guarantees are encoded as tests:
373
+ permission-aware whitelisting (you can only filter/sort what you can see), no injection
374
+ through `sort`/`dir`/values/specs, escaped LIKE wildcards, validated enum/boolean/numeric
375
+ casts, `identify_by`-only belongs_to resolution. The full list, with how each is enforced,
376
+ is in [Security & the URL model](docs/security.md).
377
+
378
+ ## No JavaScript required
379
+
380
+ Everything works with JS disabled: filtering and sorting are plain GET forms and links;
381
+ the inline filter row binds to an external form via the HTML `form` attribute; forms are
382
+ plain (simple_form) markup. Niceties layer on as **one mechanism, not a fork**: the
383
+ markup is always the plain baseline, and Stimulus controllers enhance it *in place* via
384
+ `data-controller` (no parallel template trees; framework choice lives in the class map).
385
+ The gem ships four optional Stimulus controllers — `crud-filter` (strips empty inputs for
386
+ clean URLs), `crud-multiselect` (a habtm `<select multiple>` → chips-list + "add" picker),
387
+ `crud-columns` (drag-to-reorder + tidy `?cols=` in the column picker) and `crud-select`
388
+ ("select all" / per-group master checkbox + live count for selectable tables) — and
389
+ depends on no JS. →
390
+ [Extending → progressive enhancement](docs/extending.md#progressive-enhancement)
391
+
392
+ ## Turbo Streams
393
+
394
+ Rows carry `dom_id`s and render independently, so the markup is morph- and
395
+ stream-friendly out of the box. Add Rails' `broadcasts_refreshes` + a `turbo_stream_from`
396
+ subscription and a collection updates live — only changed rows morph. The gem ships no
397
+ streaming machinery; it just guarantees the markup a broadcast needs. (The dummy app's
398
+ "Live" page demonstrates it.)
399
+
400
+ ## Styling
401
+
402
+ ![A custom cards layout: the same collection presenter rendered as a responsive card grid (cover pulled out, fields below), reusing the gem's search, filter sidebar and row actions](docs/screenshots/cards.png)
403
+
404
+ No CSS shipped; Bootstrap 5 class names by default, concentrated in one overridable class
405
+ map (`CrudComponents.configure { |c| c.css.table = … }`). Swapping frameworks is a class
406
+ map plus a few partials, never a fork. → [Extending → styling](docs/extending.md#styling)
407
+
408
+ ## API reference
409
+
410
+ ### Helpers (the everyday API)
411
+
412
+ ```ruby
413
+ crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
414
+ actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
415
+ crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto)
416
+ crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
417
+ crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form)
418
+ crud_actions(record_or_model, fieldset: nil)
419
+ ```
420
+
421
+ `crud_collection` takes a **relation** (`@books`, `Book.all`, or an authorized scope),
422
+ so your authorization runs in the controller, before the gem renders.
423
+ `crud_actions` takes a record (→ row actions) or a model class (→ collection actions). `query:` is a
424
+ tri-state: `:auto` (default) builds from params, a `Query` = manual (already filtered),
425
+ `:static` = no filter row or sort links. The column picker is two knobs: `picker:` toggles the
426
+ gear, `picked_columns:` seeds it (`:auto` reads `?cols=`; an Array is verbatim, no param read).
427
+
428
+ ### DSL (inside `crud_structure do … end`)
429
+
430
+ ```ruby
431
+ label(method = nil, &block)
432
+ identify_by(column) # default :id
433
+ search_in(*spec) | search_in { |scope, q| … } # default: own string/text columns
434
+ attribute(name, as: nil, form_as: nil, if: nil, editable: nil, **renderer_options, &block)
435
+ attributes(*names, **shared_options)
436
+ # bare block (arity 1) = render markup
437
+ # facet block (arity 0) = render / filter / sort declarations
438
+ # as: — display renderer (the read-only / cell partial)
439
+ # form_as: — form-input partial (defaults to the field's type)
440
+ # if: — visibility (everywhere: column, filter, sort, form)
441
+ # editable: — writability in forms (read-only when false / unpermitted)
442
+ action(name, icon:, title:, class:, confirm:, method:, on:, if:, &path_block)
443
+ fieldset(name, fields = :all, actions: nil, filters: nil)
444
+ ```
445
+
446
+ The DSL is defined in [`lib/crud_components/builder.rb`](lib/crud_components/builder.rb).
447
+
448
+ Raises at boot, each with a message that says what to do instead: `crud_structure`
449
+ declared twice; a name that is no column/enum/association/method without a render facet;
450
+ duplicate `attribute`/`action`/`fieldset` names; fieldsets referencing unknown
451
+ fields/actions; fields named `q`/`sort`/`dir`/`page`/`per`; `filter` given both a spec and
452
+ a block; an `as:` renderer with no matching partial or a missing gem.
453
+
454
+ ### Runtime
455
+
456
+ ```ruby
457
+ CrudComponents::Query.new(model, params, fieldset: :default, ability: nil, param_prefix: nil)
458
+ # #apply(scope) → relation; #active?
459
+ CrudComponents.permitted_attributes(model, action: :update, ability: nil) # strong-params list
460
+ CrudComponents.configure { |config| … } # css/icon maps, select_limit, defaults
461
+ ```
462
+
463
+ ## Documentation
464
+
465
+
466
+ | Doc | What |
467
+ | ------------------------------------------ | -------------------------------------------------------------------------------- |
468
+ | [docs/fields.md](docs/fields.md) | Fields, renderers, facets, the search spec, identity, per-flavor behavior |
469
+ | [docs/views.md](docs/views.md) | The helpers, fieldsets, actions & route resolution, the manual query, pagination |
470
+ | [docs/forms.md](docs/forms.md) | `crud_form`, the permit list, `editable:`, form controls, attachments |
471
+ | [docs/security.md](docs/security.md) | Permissions (`if:`/`editable:`), the whitelist, and the injection-safe URL model |
472
+ | [docs/extending.md](docs/extending.md) | Partials/renderers/layouts, progressive enhancement, styling, i18n |
473
+ | [docs/performance.md](docs/performance.md) | Eager-loading, the belongs_to select→text threshold, pagination |
474
+
475
+
476
+ ## Dependencies
477
+
478
+
479
+ | Gem | Why |
480
+ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
481
+ | `activerecord` (>= 7.1) | deriving structure from AR reflection is the whole point |
482
+ | `activesupport` (>= 7.1) | core extensions, i18n |
483
+ | `actionview` (>= 7.1) | rendering (partials, helpers) |
484
+ | `simple_form` (>= 5.0) | form rendering — its wrappers make form markup match your design system across frameworks; deferring to it is less code and a better fit than reinventing wrappers. Light + ubiquitous |
485
+
486
+
487
+ The first three ship with Rails; simple_form is the one deliberate third-party dependency, and only the form surface uses it. Everything else stays integration-by-detection: CanCanCan, turbo-rails, Stimulus, Bootstrap, kaminari, markdown and highlighting gems are feature-detected or documented integration points, never required. Development: `minitest`, `rake`, `sqlite3`, plus a minimal dummy Rails app.
488
+
489
+ ## Development
490
+
491
+ ```sh
492
+ bundle install
493
+ bundle exec rake test
494
+
495
+ # the live playground (test/dummy doubles as a seeded demo app)
496
+ cd test/dummy && bin/rails db:schema:load db:seed && bin/rails server # → :3000
497
+
498
+ ruby script/demo.rb # query-side walkthrough in the terminal
499
+ ```
500
+
501
+ The playground is **living documentation** — browse it live at
502
+ **[crud-components.zelenin.de](https://crud-components.zelenin.de)**: its landing page
503
+ indexes every feature, and each demo page carries a "How this page is built" panel with
504
+ the exact DSL and ERB.
505
+
506
+ [![The playground landing page: a feature index grouped into Basics, Identity & associations, Power features and Integration — each card links to a running demo and shows the one line of code behind it](docs/screenshots/home.png)](https://crud-components.zelenin.de)
507
+
508
+ Tests mirror the design: `dsl_validation_test.rb` for every raising combination,
509
+ `query_security_test.rb` for the [security model](docs/security.md), `structure_test.rb`
510
+ / `like_spec_test.rb` / `form_test.rb` for the units, and `full_integration_test.rb`
511
+ against the dummy app.
data/RELEASING.md ADDED
@@ -0,0 +1,44 @@
1
+ # Releasing
2
+
3
+ Releases are automated: **publish a GitHub Release and CI pushes the gem to
4
+ RubyGems** via [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/)
5
+ (OIDC — no API key stored anywhere). See `.github/workflows/gem-push.yml`.
6
+
7
+ ## One-time setup
8
+
9
+ On [rubygems.org](https://rubygems.org) → the `crud_components` gem → **Trusted
10
+ Publishers** → *Add* a GitHub Actions publisher:
11
+
12
+ - Repository: `itadventurer/crud_components`
13
+ - Workflow: `gem-push.yml`
14
+
15
+ For the **very first** publish (the gem doesn't exist on RubyGems yet), use
16
+ RubyGems' *“Create a pending trusted publisher”* flow instead, then push the
17
+ first release — or do one manual `gem push` to create the gem, then add the
18
+ publisher above. After that, every release is hands-off.
19
+
20
+ (The old `RUBYGEMS_API_KEY` secret is no longer used and can be deleted.)
21
+
22
+ ## Cutting a release
23
+
24
+ 1. **Bump the version** in `lib/crud_components/version.rb`. Open it as a normal
25
+ PR; merge to `main`.
26
+
27
+ 2. **Publish the release** for the matching tag — the workflow expects `vX.Y.Z`
28
+ to equal the version in `version.rb`:
29
+
30
+ ```bash
31
+ gh release create v0.1.0 --title v0.1.0 --generate-notes
32
+ ```
33
+
34
+ (or use the GitHub UI → *Draft a new release* → pick the tag →
35
+ *Generate release notes*). Publishing fires the workflow, which runs the
36
+ tests, builds the gem, attaches a build-provenance attestation, and pushes it.
37
+
38
+ That's it — `gem-push.yml` does the build + push; you never touch credentials.
39
+
40
+ ### Versioning
41
+
42
+ [SemVer](https://semver.org): new backward-compatible features → minor (`0.x`),
43
+ bug-fixes → patch. Release notes are generated from the merged PRs
44
+ (`--generate-notes`); there is no hand-maintained changelog.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # Defines the `build`/`install`/`release` tasks from the gemspec. The
2
+ # rubygems/release-gem CI action runs `rake release` to push the gem.
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task default: :test