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
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
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
[](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
|