compony 0.11.8 → 0.11.9
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 +4 -4
- data/.yardopts +36 -1
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +85 -0
- data/Gemfile.lock +1 -1
- data/README.md +13 -3
- data/VERSION +1 -1
- data/compony.gemspec +3 -3
- data/doc/ComponentGenerator.html +1 -1
- data/doc/Components.html +1 -1
- data/doc/ComponentsGenerator.html +1 -1
- data/doc/Compony/Component.html +54 -54
- data/doc/Compony/ComponentMixins/Default/Labelling.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +109 -70
- data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +64 -28
- data/doc/Compony/ComponentMixins/Default/Standalone.html +1 -1
- data/doc/Compony/ComponentMixins/Default.html +1 -1
- data/doc/Compony/ComponentMixins/Resourceful.html +213 -74
- data/doc/Compony/ComponentMixins.html +1 -1
- data/doc/Compony/Components/Buttons/CssButton.html +1 -1
- data/doc/Compony/Components/Buttons/Link.html +1 -1
- data/doc/Compony/Components/Buttons.html +1 -1
- data/doc/Compony/Components/Destroy.html +83 -29
- data/doc/Compony/Components/Edit.html +110 -38
- data/doc/Compony/Components/Form.html +551 -208
- data/doc/Compony/Components/Index.html +1 -1
- data/doc/Compony/Components/List.html +3 -3
- data/doc/Compony/Components/New.html +110 -38
- data/doc/Compony/Components/Show.html +1 -1
- data/doc/Compony/Components/WithForm.html +194 -47
- data/doc/Compony/Components.html +1 -1
- data/doc/Compony/ControllerMixin.html +1 -1
- data/doc/Compony/Engine.html +1 -1
- data/doc/Compony/Intent.html +2 -2
- data/doc/Compony/ManageIntentsDsl.html +1 -1
- data/doc/Compony/MethodAccessibleHash.html +1 -1
- data/doc/Compony/ModelFields/Anchormodel.html +1 -1
- data/doc/Compony/ModelFields/Association.html +1 -1
- data/doc/Compony/ModelFields/Attachment.html +1 -1
- data/doc/Compony/ModelFields/Base.html +1 -1
- data/doc/Compony/ModelFields/Boolean.html +1 -1
- data/doc/Compony/ModelFields/Color.html +1 -1
- data/doc/Compony/ModelFields/Currency.html +1 -1
- data/doc/Compony/ModelFields/Date.html +1 -1
- data/doc/Compony/ModelFields/Datetime.html +1 -1
- data/doc/Compony/ModelFields/Decimal.html +1 -1
- data/doc/Compony/ModelFields/Email.html +1 -1
- data/doc/Compony/ModelFields/Float.html +1 -1
- data/doc/Compony/ModelFields/Integer.html +1 -1
- data/doc/Compony/ModelFields/Percentage.html +1 -1
- data/doc/Compony/ModelFields/Phone.html +1 -1
- data/doc/Compony/ModelFields/RichText.html +1 -1
- data/doc/Compony/ModelFields/String.html +1 -1
- data/doc/Compony/ModelFields/Text.html +1 -1
- data/doc/Compony/ModelFields/Time.html +1 -1
- data/doc/Compony/ModelFields/Url.html +1 -1
- data/doc/Compony/ModelFields.html +1 -1
- data/doc/Compony/ModelMixin.html +1 -1
- data/doc/Compony/NaturalOrdering.html +1 -1
- data/doc/Compony/RequestContext.html +1 -1
- data/doc/Compony/Version.html +1 -1
- data/doc/Compony/ViewHelpers.html +1 -1
- data/doc/Compony/VirtualModel.html +1 -1
- data/doc/Compony.html +1 -1
- data/doc/ComponyController.html +1 -1
- data/doc/_index.html +97 -1
- data/doc/file.CHANGELOG.html +758 -0
- data/doc/file.README.html +25 -4
- data/doc/file.basic_component.html +314 -0
- data/doc/file.cookbook.html +189 -0
- data/doc/file.destroy.html +105 -0
- data/doc/file.dsl_reference.html +672 -0
- data/doc/file.edit.html +109 -0
- data/doc/file.example.html +291 -0
- data/doc/file.example_advanced.html +257 -0
- data/doc/file.feasibility.html +115 -0
- data/doc/file.form.html +195 -0
- data/doc/file.generators.html +89 -0
- data/doc/file.glossary.html +217 -0
- data/doc/file.gotchas.html +222 -0
- data/doc/file.index.html +135 -0
- data/doc/file.inheritance.html +136 -0
- data/doc/file.installation.html +115 -0
- data/doc/file.integrations.html +218 -0
- data/doc/file.intents.html +265 -0
- data/doc/file.internal_datastructures.html +129 -0
- data/doc/file.list.html +253 -0
- data/doc/file.maintaining.html +127 -0
- data/doc/file.model_fields.html +137 -0
- data/doc/file.nesting.html +237 -0
- data/doc/file.new.html +109 -0
- data/doc/file.ownership.html +98 -0
- data/doc/file.patterns.html +669 -0
- data/doc/file.pre_built_components.html +99 -0
- data/doc/file.resourceful.html +181 -0
- data/doc/file.show.html +158 -0
- data/doc/file.standalone.html +233 -0
- data/doc/file.virtual_models.html +117 -0
- data/doc/file.with_form.html +157 -0
- data/doc/file_list.html +160 -0
- data/doc/guide/cookbook.md +41 -0
- data/doc/guide/dsl_reference.md +155 -0
- data/doc/guide/example_advanced.md +209 -0
- data/doc/guide/generators.md +1 -1
- data/doc/guide/glossary.md +42 -0
- data/doc/guide/gotchas.md +125 -0
- data/doc/guide/maintaining.md +64 -0
- data/doc/guide/patterns.md +681 -0
- data/doc/guide/pre_built_components/edit.md +1 -1
- data/doc/guide/pre_built_components/index.md +64 -1
- data/doc/guide/pre_built_components/list.md +111 -7
- data/doc/guide/pre_built_components/show.md +57 -2
- data/doc/guide/pre_built_components/with_form.md +56 -9
- data/doc/guide/pre_built_components.md +7 -2
- data/doc/guide/standalone.md +16 -1
- data/doc/index.html +25 -4
- data/doc/integrations.md +61 -0
- data/doc/llms.txt +62 -0
- data/doc/top-level-namespace.html +1 -1
- data/lib/compony/component.rb +8 -3
- data/lib/compony/component_mixins/default/standalone/standalone_dsl.rb +32 -15
- data/lib/compony/component_mixins/default/standalone/verb_dsl.rb +11 -3
- data/lib/compony/component_mixins/resourceful.rb +30 -16
- data/lib/compony/components/destroy.rb +21 -1
- data/lib/compony/components/edit.rb +25 -1
- data/lib/compony/components/form.rb +63 -21
- data/lib/compony/components/list.rb +1 -1
- data/lib/compony/components/new.rb +25 -1
- data/lib/compony/components/with_form.rb +20 -5
- data/lib/compony/intent.rb +1 -1
- metadata +43 -1
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
[Back to the guide](/README.md#guide--documentation)
|
|
2
|
+
|
|
3
|
+
# Real-world patterns
|
|
4
|
+
|
|
5
|
+
Conventions distilled from a range of production Compony apps. These are *idioms*, not
|
|
6
|
+
framework requirements — but they recur consistently and are worth adopting. Every example
|
|
7
|
+
uses a neutral domain (`Account`, `Order`, `LineItem`, `Document`). Where a pattern relies
|
|
8
|
+
on a companion gem (CanCanCan, ActiveType, simple_form, a date/select input) that is
|
|
9
|
+
called out.
|
|
10
|
+
|
|
11
|
+
For exact method signatures see [dsl_reference.md](/doc/guide/dsl_reference.md); for
|
|
12
|
+
footguns see [gotchas.md](/doc/guide/gotchas.md).
|
|
13
|
+
|
|
14
|
+
## 1. The app base-component layer
|
|
15
|
+
|
|
16
|
+
Almost every non-trivial app inserts one abstract layer between Compony's pre-built
|
|
17
|
+
components and the concrete ones. Concrete components inherit from the app layer, never
|
|
18
|
+
from Compony directly. This centralizes layout, button styling, and chrome so the whole
|
|
19
|
+
app's look changes in one place.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# app/components/base_components/show.rb (a common location; app/compony/ is also used)
|
|
23
|
+
module BaseComponents
|
|
24
|
+
class Show < Compony::Components::Show
|
|
25
|
+
setup do
|
|
26
|
+
standalone { layout :backend } # app-wide Rails layout for all non-publicly accessible components
|
|
27
|
+
button(:icon) { :eye }
|
|
28
|
+
content :main, hidden: true # concrete comps fill :main…
|
|
29
|
+
content :wrapper do # …chrome lives here, inherited
|
|
30
|
+
div class: 'card card-body' do
|
|
31
|
+
content :main
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# app/components/orders/show.rb
|
|
39
|
+
class Components::Orders::Show < BaseComponents::Show
|
|
40
|
+
end # fully functional, empty body
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Recurring forms of this layer: `BaseComponents::{Index,Show,New,Edit,Destroy,List}`. The
|
|
44
|
+
`content :main, hidden: true` + `content :wrapper` pair is the standard way to let
|
|
45
|
+
children override the inner content while inheriting the outer chrome (see
|
|
46
|
+
[basic_component.md](/doc/guide/basic_component.md#nesting-content-blocks-calling-a-content-block-from-another)).
|
|
47
|
+
|
|
48
|
+
Teams sometimes add their own helper DSL on top of this layer (CSV/PDF helpers, archive
|
|
49
|
+
toggles, etc.). Keep such helpers in the app base layer, not in concrete components.
|
|
50
|
+
|
|
51
|
+
## 2. Thin leaf components
|
|
52
|
+
|
|
53
|
+
Concrete CRUD components are usually empty — all behavior is inherited. Add a `setup` block
|
|
54
|
+
only to deviate.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class Components::Orders::Destroy < BaseComponents::Destroy; end
|
|
58
|
+
class Components::Orders::New < BaseComponents::New; end
|
|
59
|
+
class Components::Orders::Edit < BaseComponents::Edit; end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This is the single most common pattern. Prefer it over hand-written endpoints
|
|
63
|
+
([gotchas.md #15](/doc/guide/gotchas.md#15-hand-rolled-endpoint-where-a-pre-built-crud-component-exists)).
|
|
64
|
+
|
|
65
|
+
## 3. Index = `load_data` scope + nested `:list`
|
|
66
|
+
|
|
67
|
+
Index components rarely render rows themselves; they load a scope and embed the family's
|
|
68
|
+
List via `render_sub_comp`.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class Components::Orders::Index < BaseComponents::Index
|
|
72
|
+
setup do
|
|
73
|
+
load_data { @data = Order.accessible_by(current_ability).order(created_at: :desc) }
|
|
74
|
+
content do
|
|
75
|
+
h1 Order.model_name.human(count: 2)
|
|
76
|
+
concat render_sub_comp(:list, @data)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `accessible_by(current_ability)` is the CanCanCan scoping idiom — pair it with the
|
|
83
|
+
`authorize` block so list and access rules agree.
|
|
84
|
+
- `concat` is mandatory around `render_sub_comp`/`render_intent`
|
|
85
|
+
([gotchas.md #2](/doc/guide/gotchas.md#2-render_intent--render_sub_comp-output-not-appearing)).
|
|
86
|
+
|
|
87
|
+
## 4. List customization
|
|
88
|
+
|
|
89
|
+
This pattern is typically combined with a customized `BaseComponents::List` that adds styling and
|
|
90
|
+
features to the pre-built list component.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class Components::Orders::List < BaseComponents::List
|
|
94
|
+
setup do
|
|
95
|
+
columns :number, :customer, as_title: true # as_title -> card title on mobile
|
|
96
|
+
columns :total, :created_at
|
|
97
|
+
column :status do |order| # computed/custom cell
|
|
98
|
+
span order.status.label, class: "badge bg-#{order.status.key}"
|
|
99
|
+
end
|
|
100
|
+
filters :number, :status
|
|
101
|
+
sorts :number, :created_at
|
|
102
|
+
default_sorting 'created_at desc'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Embedding a child list inside a Show, dropping the redundant FK column and preserving the
|
|
108
|
+
active tab across filter submits:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
concat render_sub_comp(:list, @data.line_items, skip_columns: [:order],
|
|
112
|
+
params_in_filter: [param_name('tab')])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`skip_*` options (`skip_pagination:`, `skip_filtering:`, `skip_columns:`, …) are
|
|
116
|
+
constructor kwargs passed through `render_sub_comp`, useful for read-only embeds.
|
|
117
|
+
|
|
118
|
+
## 5. Custom form + Schemacop, kept in sync
|
|
119
|
+
|
|
120
|
+
`form_fields` (rendering) and `schema_*` (param whitelist) must mirror each other.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class Components::Orders::Form < Compony::Components::Form
|
|
124
|
+
setup do
|
|
125
|
+
form_fields do
|
|
126
|
+
concat field(:number)
|
|
127
|
+
concat field(:customer, as: :tom_select) # association name, not _id
|
|
128
|
+
concat field(:placed_at, as: :flatpickr_datetime)
|
|
129
|
+
concat pw_field(:access_code)
|
|
130
|
+
concat field(:internal_ref, hidden: true) # submitted, not shown
|
|
131
|
+
div class: 'row' do # arbitrary Dyny layout
|
|
132
|
+
div field(:first_name), class: 'col'
|
|
133
|
+
div field(:last_name), class: 'col'
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
schema_fields :number, :customer, :placed_at, :internal_ref
|
|
138
|
+
schema_pw_field :access_code
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `as: :tom_select` / `as: :flatpickr_date(time)` are app-registered simple_form inputs
|
|
144
|
+
(TomSelect, Flatpickr) — a good choice for selects and date pickers.
|
|
145
|
+
- Use the **association name** in `field`/`schema_field`; `_id` is added automatically
|
|
146
|
+
([gotchas.md #4](/doc/guide/gotchas.md#4-schema_field-with-the-foreign-key-name)).
|
|
147
|
+
- Nested attributes: `f.simple_fields_for(:line_items)` in `form_fields` plus a raw
|
|
148
|
+
`schema_line { ary? :line_items_attributes do ... end }`.
|
|
149
|
+
- Multilang fields: `field(:title, multilang: true).each { |i| concat i }` paired with
|
|
150
|
+
`schema_field :title, multilang: true`.
|
|
151
|
+
|
|
152
|
+
Wire a non-default form into New/Edit with `form_comp_class`:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class Components::Orders::QuickAdd < Compony::Components::New
|
|
156
|
+
setup { form_comp_class Components::Orders::QuickAddForm }
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 6. Autocomplete form (app-level subclass)
|
|
161
|
+
|
|
162
|
+
Compony does not ship autocomplete, but a very common app pattern is an
|
|
163
|
+
`AutocompleteForm` base (subclass of `Compony::Components::Form`) exposing an extra
|
|
164
|
+
`standalone` JSON endpoint for an ajax select. Shape:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class BaseComponents::AutocompleteForm < Compony::Components::Form
|
|
168
|
+
# class-level `autocomplete(field) { |query, ability| ...collection... }` that
|
|
169
|
+
# registers an extra `standalone :autocomplete_<field>` returning
|
|
170
|
+
# [{ text:, value:, icon: }] JSON, consumed by a TomSelect Stimulus controller.
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class Components::Orders::Form < BaseComponents::AutocompleteForm
|
|
174
|
+
setup do
|
|
175
|
+
form_fields { concat field(:customer, as: :tom_select) }
|
|
176
|
+
schema_field :customer
|
|
177
|
+
autocomplete(:customer) { |q, ability| Customer.accessible_by(ability).search(q) }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
If you need autocomplete, build this base once and reuse it.
|
|
183
|
+
|
|
184
|
+
## 7. Tabbed Show via a mixin
|
|
185
|
+
|
|
186
|
+
Detail pages are split into tabs with a small app mixin that adds a `tab` DSL and renders
|
|
187
|
+
a tab bar into `:main`. Each tab body typically renders `content :data` or a nested list.
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
class Components::Orders::Show < BaseComponents::Show
|
|
191
|
+
include ComponentMixins::Tabs
|
|
192
|
+
|
|
193
|
+
setup do
|
|
194
|
+
tab(:overview, _('Overview')) { content :data }
|
|
195
|
+
tab(:items, _('Items')) { concat render_sub_comp(:list, @data.line_items,
|
|
196
|
+
skip_columns: [:order]) }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The mixin keys the active tab off a prefixed param (`param_name('tab')`) so multiple
|
|
202
|
+
tabbed components can coexist. Compony has no built-in tabs — copy the mixin per app.
|
|
203
|
+
|
|
204
|
+
## 8. Lifecycle hooks for derived data
|
|
205
|
+
|
|
206
|
+
- **`after_assign_attributes`** — fill defaults / context after params are assigned,
|
|
207
|
+
before validation: `@data.account_id ||= current_user.account_id`.
|
|
208
|
+
- **`before_render`** — verb-independent guards and precomputation. Redirect and the
|
|
209
|
+
content chain is skipped:
|
|
210
|
+
```ruby
|
|
211
|
+
before_render do
|
|
212
|
+
redirect_to Compony.path(:show, @data) if @data.locked?
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
- **`load_data`** — narrow the scope (`accessible_by`, `includes`, ordering).
|
|
216
|
+
- **`store_data`** — override persistence (virtual models, file handling, bulk import).
|
|
217
|
+
- **`on_{created,updated,destroyed}_redirect_path`** — control where success lands, e.g.
|
|
218
|
+
`Compony.path(:show, @data.parent)` for owned records.
|
|
219
|
+
|
|
220
|
+
## 9. Exposed intents as the action toolbar
|
|
221
|
+
|
|
222
|
+
Concrete components tailor the header toolbar by `add`/`remove` on inherited intents.
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
exposed_intents do
|
|
226
|
+
remove :destroy
|
|
227
|
+
add :show, @data, label: 'PDF', name: :pdf, path: { format: :pdf },
|
|
228
|
+
feasibility_action: :pdf
|
|
229
|
+
add :archive, @data, method: :patch, before: :destroy
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- `path: { format: :pdf }` points a button at a format endpoint (see pattern 10).
|
|
234
|
+
- `feasibility_action:` ties the button's enabled state to a model `prevent`
|
|
235
|
+
([feasibility.md](/doc/guide/feasibility.md)).
|
|
236
|
+
- State-dependent toolbars (archived vs active) are done by branching inside the
|
|
237
|
+
`exposed_intents` block on `@data`.
|
|
238
|
+
- Generating one intent per enum value is common:
|
|
239
|
+
`Period.all.each { |p| add :new, :prices, name: :"new_#{p.key}", path: { price: { period: p.key } } }`.
|
|
240
|
+
|
|
241
|
+
## 10. CSV / PDF via `respond :format`
|
|
242
|
+
|
|
243
|
+
A format export is the same component with an extra `respond` branch and an exposed intent
|
|
244
|
+
pointing at it. Because overriding `respond` skips the default `authorize`, re-check there
|
|
245
|
+
([gotchas.md #3](/doc/guide/gotchas.md#3-overriding-respond-skips-authorization)).
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
standalone path: 'orders' do
|
|
249
|
+
verb :get do
|
|
250
|
+
authorize { can?(:read, Order) }
|
|
251
|
+
respond :csv do
|
|
252
|
+
can?(:read, Order) or raise CanCan::AccessDenied
|
|
253
|
+
send_data(OrderCsv.new(@data).to_csv, filename: 'orders.csv', type: 'text/csv')
|
|
254
|
+
end
|
|
255
|
+
respond :pdf do
|
|
256
|
+
can?(:read, @data) or raise CanCan::AccessDenied
|
|
257
|
+
send_data(OrderPdf.new(@data).render, filename: @data.pdf_name,
|
|
258
|
+
type: 'application/pdf')
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
# exposed_intents { add :index, :orders, label: 'CSV', path: { format: :csv } }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## 11. Non-CRUD: job dispatch, toggles, clone
|
|
266
|
+
|
|
267
|
+
**Job dispatch** — POST-only custom component, enqueue, flash, redirect:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
class Components::Orders::ScheduleSync < Compony::Component
|
|
271
|
+
setup do
|
|
272
|
+
standalone path: 'orders/schedule_sync' do
|
|
273
|
+
verb :post do
|
|
274
|
+
authorize { can?(:create, Order) }
|
|
275
|
+
respond do
|
|
276
|
+
SyncOrdersJob.perform_later
|
|
277
|
+
flash.notice = _('Queued — give it a few minutes.')
|
|
278
|
+
redirect_to Compony.path(:index, :orders)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
label(:all) { _('Sync now') }
|
|
283
|
+
button(:icon) { :rotate }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Expose it from Index: `exposed_intents { add :schedule_sync, :orders, method: :post }`.
|
|
289
|
+
|
|
290
|
+
**State toggle** — inherit `Edit`, flip in `after_assign_attributes`, dynamic label:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
class Components::Accounts::ToggleActive < Compony::Components::Edit
|
|
294
|
+
setup do
|
|
295
|
+
standalone path: 'accounts/:id/toggle_active' do
|
|
296
|
+
verb :patch do authorize { can?(:toggle_active, @data) } end
|
|
297
|
+
end
|
|
298
|
+
label(:long) { |a| a.active? ? _('Deactivate') : _('Activate') }
|
|
299
|
+
after_assign_attributes { @data.active = !@data.active }
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Clone** — inherit `New`, load + dup the source in `load_data`, redirect to the copy:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
class Components::Orders::Clone < Compony::Components::New
|
|
308
|
+
setup do
|
|
309
|
+
standalone path: 'orders/:id/clone'
|
|
310
|
+
load_data do
|
|
311
|
+
source = Order.find(params[:id])
|
|
312
|
+
authorize!(:read, source) # CanCanCan bang form
|
|
313
|
+
@data = source.dup
|
|
314
|
+
end
|
|
315
|
+
on_created_redirect_path { Compony.path(:show, @data) }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## 12. Virtual model for non-persistent / upload forms
|
|
321
|
+
|
|
322
|
+
Inherit `New`, back it with a `Compony::VirtualModel`, take over the response. `@data.save`
|
|
323
|
+
is a no-op so business logic goes in `on_created_respond` (or `store_data`).
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
class Components::Documents::Import < Compony::Components::New
|
|
327
|
+
class VirtualModel < Compony::VirtualModel
|
|
328
|
+
attribute :id, :bigint
|
|
329
|
+
belongs_to :account
|
|
330
|
+
has_one_attached :file
|
|
331
|
+
field :account, :association
|
|
332
|
+
field :file, :attachment
|
|
333
|
+
validates :file, presence: true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
setup do
|
|
337
|
+
standalone path: 'documents/import'
|
|
338
|
+
data_class VirtualModel
|
|
339
|
+
form_comp_class Components::Documents::ImportForm
|
|
340
|
+
|
|
341
|
+
# ActiveStorage on a virtual model: validate only, read the tempfile yourself.
|
|
342
|
+
store_data do
|
|
343
|
+
@create_succeeded = @data.validate
|
|
344
|
+
next unless @create_succeeded
|
|
345
|
+
tempfile = params.dig(:documents_virtual_model, :file)&.tempfile
|
|
346
|
+
DocumentImporter.call(account: @data.account, io: tempfile)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
on_created_respond do
|
|
350
|
+
flash.notice = _('Imported.')
|
|
351
|
+
redirect_to Compony.path(:index, :documents)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
See [virtual_models.md](/doc/guide/virtual_models.md) and
|
|
358
|
+
[gotchas.md #12](/doc/guide/gotchas.md#12-activestorage-attachment-on-a-virtual-model).
|
|
359
|
+
|
|
360
|
+
## 13. Public endpoints & webhooks
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
class Components::Public::Webhook < Compony::Component
|
|
364
|
+
setup do
|
|
365
|
+
standalone path: '/webhooks/orders' do
|
|
366
|
+
skip_authentication!
|
|
367
|
+
skip_forgery_protection!
|
|
368
|
+
verb :post do
|
|
369
|
+
authorize { true } # still mandatory
|
|
370
|
+
respond do
|
|
371
|
+
expected = "Bearer #{ENV.fetch('WEBHOOK_TOKEN')}"
|
|
372
|
+
got = request.headers['Authorization'].to_s
|
|
373
|
+
unless ActiveSupport::SecurityUtils.secure_compare(got, expected)
|
|
374
|
+
sleep 1 # crude timing equalization
|
|
375
|
+
next controller.head(:unauthorized)
|
|
376
|
+
end
|
|
377
|
+
OrderWebhook.process!(request.params)
|
|
378
|
+
controller.head :accepted
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
A login-aware redirect splitter is the same shape with `verb :get` + `before_render`
|
|
387
|
+
choosing a `Compony.path` by `current_user`.
|
|
388
|
+
|
|
389
|
+
## 14. Custom button style
|
|
390
|
+
|
|
391
|
+
Register one app button style and refer to it everywhere via `style:`.
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
class Components::Commons::BootstrapButton < Compony::Components::Buttons::Link
|
|
395
|
+
protected
|
|
396
|
+
def prepare_opts!
|
|
397
|
+
super
|
|
398
|
+
classes = (@comp_opts[:class] || '').split
|
|
399
|
+
classes << 'btn' << "btn-#{@comp_opts[:color] || :primary}"
|
|
400
|
+
@comp_opts[:class] = classes.join(' ')
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
# config/initializers/compony.rb
|
|
404
|
+
# Compony.register_button_style :bootstrap, '::Components::Commons::BootstrapButton'
|
|
405
|
+
# Compony.default_button_style = :bootstrap
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Make a separate style per visual kind (dropdown item, pill, compact) and select with
|
|
409
|
+
`render_intent(:show, @data, style: :compact)`.
|
|
410
|
+
|
|
411
|
+
## 15. Inline-edit card with a Turbo Frame
|
|
412
|
+
|
|
413
|
+
A Show panel where the Edit form swaps in place (no full-page nav) and swaps back on save.
|
|
414
|
+
Wrap both the Show content and the Edit form in a **same-named** `turbo_frame_tag`; Turbo
|
|
415
|
+
Drive then scopes navigation to that frame. Distinct from the `render_sub_comp(:list, …,
|
|
416
|
+
turbo_frame:)` use in [nesting.md](/doc/guide/nesting.md) (there the frame isolates a
|
|
417
|
+
nested list's own search/filter params; here it is the inline-edit boundary for one
|
|
418
|
+
record's Show/Edit pair).
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
# One frame name shared by the Show panel and the Edit form.
|
|
422
|
+
def card_frame(record) = :"#{record.model_name.singular}_#{record.id}_card"
|
|
423
|
+
|
|
424
|
+
class Components::Accounts::Show < Compony::Components::Show
|
|
425
|
+
setup do
|
|
426
|
+
content :data do
|
|
427
|
+
turbo_frame_tag card_frame(@data) do # Dyny: Rails view helper
|
|
428
|
+
# …render fields…
|
|
429
|
+
concat render_intent(:edit, @data, label: { format: :short })
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
class Components::Accounts::Edit < Compony::Components::Edit
|
|
436
|
+
setup do
|
|
437
|
+
content do
|
|
438
|
+
turbo_frame_tag card_frame(@data) do # same frame name
|
|
439
|
+
concat form_comp.render(controller, data: @data)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
# Default on_updated_redirect_path → Show; Turbo replaces just the frame.
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- Frame name must match exactly; deriving it from the record id keeps it unique when
|
|
448
|
+
several cards render on one page.
|
|
449
|
+
- A failed save re-renders Edit with HTTP 422 — keep the `turbo_frame_tag` wrapper in the
|
|
450
|
+
Edit content so errors render in-frame too.
|
|
451
|
+
|
|
452
|
+
## 16. Multi-step wizard across components
|
|
453
|
+
|
|
454
|
+
A create/edit flow split over several steps, each its own component, advancing on save.
|
|
455
|
+
Chain steps with `on_updated_redirect_path` (or `on_created_redirect_path`) and render a
|
|
456
|
+
step indicator via a shared mixin (same mechanism as the tabs mixin in §7).
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
module OrderWizard
|
|
460
|
+
extend ActiveSupport::Concern
|
|
461
|
+
STEPS = %i[details_edit shipping_edit confirm_edit].freeze
|
|
462
|
+
|
|
463
|
+
included do
|
|
464
|
+
setup do
|
|
465
|
+
content :wizard_nav, before: :main do
|
|
466
|
+
ol class: 'wizard' do
|
|
467
|
+
OrderWizard::STEPS.each do |step|
|
|
468
|
+
li step.to_s.delete_suffix('_edit'),
|
|
469
|
+
class: (component.comp_name.to_sym == step ? 'active' : nil)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
class Components::Orders::DetailsEdit < Compony::Components::Edit
|
|
478
|
+
include OrderWizard
|
|
479
|
+
setup do
|
|
480
|
+
standalone path: 'orders/:id/details'
|
|
481
|
+
on_updated_redirect_path { Compony.path(:shipping_edit, @data) } # → next step
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
class Components::Orders::ShippingEdit < Compony::Components::Edit
|
|
486
|
+
include OrderWizard
|
|
487
|
+
setup do
|
|
488
|
+
standalone path: 'orders/:id/shipping'
|
|
489
|
+
on_updated_redirect_path { Compony.path(:confirm_edit, @data) }
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
# …ConfirmEdit redirects to Show when done.
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- Each step is a normal resourceful component on the same model — partial validation per
|
|
496
|
+
step is just per-step `schema_field`s in each step's Form.
|
|
497
|
+
- For a *non-persistent* wizard (nothing saved until the end), back the components with a
|
|
498
|
+
[VirtualModel](/doc/guide/virtual_models.md) and carry state in its attributes (§12).
|
|
499
|
+
- `comp_name` drives the active-step highlight, so the mixin needs no per-step config.
|
|
500
|
+
|
|
501
|
+
## 17. Inline PATCH without a form (reorder / quick toggle)
|
|
502
|
+
|
|
503
|
+
A JS front-end (drag-to-sort, an inline checkbox) issues a small PATCH that mutates state
|
|
504
|
+
and returns no body. Add a **named** extra `standalone` with `verb :patch`, validate with
|
|
505
|
+
Schemacop directly, and `head :ok`. No Form component involved.
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
class Components::Orders::Show < Compony::Components::Show
|
|
509
|
+
setup do
|
|
510
|
+
# Main route inherited from Show. Companion endpoint for reordering line items:
|
|
511
|
+
standalone :reorder, path: 'orders/:id/reorder' do
|
|
512
|
+
verb :patch do
|
|
513
|
+
authorize { can?(:update, @data) }
|
|
514
|
+
respond do # overriding respond skips default authorize…
|
|
515
|
+
can?(:update, @data) or raise CanCan::AccessDenied # …so re-check here
|
|
516
|
+
params = Schemacop::Schema3.new(:hash) do
|
|
517
|
+
ary! :ordered_ids do
|
|
518
|
+
list :integer
|
|
519
|
+
end
|
|
520
|
+
end.validate!(controller.request.params)
|
|
521
|
+
@data.line_items.reorder_by!(params[:ordered_ids])
|
|
522
|
+
controller.head :ok
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The route is `reorder_show_orders_comp` (see
|
|
531
|
+
[standalone naming](/doc/guide/standalone.md#naming-of-exposed-routes)); point your
|
|
532
|
+
Stimulus controller's PATCH at
|
|
533
|
+
`Compony.path(:show, @data, standalone_name: :reorder)`.
|
|
534
|
+
|
|
535
|
+
- This is the [gotchas.md #3](/doc/guide/gotchas.md#3-overriding-respond-skips-authorization)
|
|
536
|
+
case: the custom `respond` replaces the default that runs `authorize`, so authorize
|
|
537
|
+
again inside it.
|
|
538
|
+
- Keep companion endpoints in the *same* component as the screen they serve — what extra
|
|
539
|
+
named `standalone`s are for
|
|
540
|
+
([standalone.md](/doc/guide/standalone.md#exposing-multiple-paths-in-the-same-component-calling-standalone-multiple-times)),
|
|
541
|
+
not a reason for a new component.
|
|
542
|
+
- Return `head :ok` (or small JSON) — no Compony content to render for an ajax-only verb.
|
|
543
|
+
|
|
544
|
+
## 18. Signed-token capability links (auth-less onboarding / magic links)
|
|
545
|
+
|
|
546
|
+
Goal: an emailed link that lets an unauthenticated visitor perform one bounded action —
|
|
547
|
+
invite acceptance, magic login, password reset, email confirmation — without a session.
|
|
548
|
+
The trick: override Compony's `path do … end` to **mint a signed JWT** and carry it as a
|
|
549
|
+
`token` query param, then gate a `skip_authentication!` standalone with
|
|
550
|
+
`authorize { token_valid?(params) }`. A small mixin centralizes encode/decode.
|
|
551
|
+
|
|
552
|
+
> **Security — read before copying.** Such a link *is* the capability; anyone holding the
|
|
553
|
+
> URL can perform the action. It is only safe if every one of these holds:
|
|
554
|
+
> - **Expiry is mandatory.** Put `exp` in the payload and verify it. A capability link
|
|
555
|
+
> without a TTL is a permanent account-takeover primitive (it leaks via referrer
|
|
556
|
+
> headers, proxy logs, mail forwarding, browser history). Pair short TTLs with a resend
|
|
557
|
+
> flow.
|
|
558
|
+
> - **Pin the algorithm and verify the signature** — `JWT.decode(token, secret, true,
|
|
559
|
+
> { algorithm: 'HS512' })`. Never accept `alg: none`; never leave verification off.
|
|
560
|
+
> - **Fail closed.** Rescue `JWT::DecodeError` (its subclasses cover bad signature,
|
|
561
|
+
> malformed token and expiry) and return `nil`/`false` so `authorize` denies with 403 —
|
|
562
|
+
> not a 500.
|
|
563
|
+
> - **Use a dedicated signing secret**, not `secret_key_base`, so rotating it doesn't also
|
|
564
|
+
> invalidate every session (and vice-versa).
|
|
565
|
+
> - Still provide an `authorize` block: `skip_authentication!` removes *authentication*,
|
|
566
|
+
> not authorization ([gotchas.md #14](/doc/guide/gotchas.md#14-public-endpoint-still-401redirecting)).
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
# app/component_mixins/with_token.rb
|
|
570
|
+
module WithToken
|
|
571
|
+
extend ActiveSupport::Concern
|
|
572
|
+
TOKEN_TTL = 14.days
|
|
573
|
+
|
|
574
|
+
def encode_token(payload)
|
|
575
|
+
JWT.encode(payload.merge(exp: TOKEN_TTL.from_now.to_i), token_secret, 'HS512')
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Memoized; returns the payload (indifferent access) or nil. Fails closed.
|
|
579
|
+
def token_data(params = nil)
|
|
580
|
+
return @token_data if @token_data
|
|
581
|
+
return nil if params.blank?
|
|
582
|
+
@token = params[:token]
|
|
583
|
+
return nil if @token.blank?
|
|
584
|
+
@token_data = JWT.decode(@token, token_secret, true, { algorithm: 'HS512' })
|
|
585
|
+
.first.with_indifferent_access
|
|
586
|
+
rescue JWT::DecodeError # bad sig / malformed / expired — all subclasses
|
|
587
|
+
nil
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def token_secret
|
|
591
|
+
Rails.application.credentials.capability_token_secret.presence ||
|
|
592
|
+
Rails.application.credentials.secret_key_base # fallback until set
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Override `path` so links self-mint a token (callers pass the subject, not the token):
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
class Components::Invites::Accept < Compony::Components::New
|
|
601
|
+
include WithToken
|
|
602
|
+
|
|
603
|
+
class VirtualModel < Compony::VirtualModel
|
|
604
|
+
attribute :password, :string
|
|
605
|
+
attribute :account_id # carried for validation only
|
|
606
|
+
field :password, :string
|
|
607
|
+
def label = 'Invite'
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
setup do
|
|
611
|
+
# Building a path to this component mints the token from the given account.
|
|
612
|
+
path do |*args, account: nil, token: nil, **kwargs|
|
|
613
|
+
if token.blank?
|
|
614
|
+
fail('Missing kwarg :account in path') if account.nil?
|
|
615
|
+
token = encode_token(account_id: account.id)
|
|
616
|
+
end
|
|
617
|
+
next Rails.application.routes.url_helpers
|
|
618
|
+
.send("#{path_helper_name}_path", *args, token:, **kwargs)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
standalone path: '/invites/accept' do
|
|
622
|
+
skip_authentication!
|
|
623
|
+
verb :get do authorize { token_valid?(params) } end
|
|
624
|
+
verb :post do authorize { token_valid?(params) } end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
data_class VirtualModel
|
|
628
|
+
form_cancancan_action nil
|
|
629
|
+
submit_path { Compony.path(self.class, @data, token: @token) }
|
|
630
|
+
after_assign_attributes { @data.account_id = token_data(params)[:account_id] }
|
|
631
|
+
|
|
632
|
+
store_data do
|
|
633
|
+
@create_succeeded = @data.validate
|
|
634
|
+
next unless @create_succeeded
|
|
635
|
+
Account.find(token_data[:account_id]).update!(password: @data.password)
|
|
636
|
+
end
|
|
637
|
+
on_created_respond { redirect_to Compony.path(:show, :sessions) }
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Shape-check the decoded payload; anything off → false → 403 (never 500).
|
|
641
|
+
def token_valid?(params)
|
|
642
|
+
data = token_data(params)
|
|
643
|
+
return false if data.blank?
|
|
644
|
+
Schemacop::Schema3.new(:hash) do
|
|
645
|
+
int! :account_id, cast_str: true
|
|
646
|
+
int? :exp
|
|
647
|
+
end.validate!(data)
|
|
648
|
+
true
|
|
649
|
+
rescue Schemacop::Exceptions::ValidationError
|
|
650
|
+
false # token signed for a different flow
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Notes:
|
|
656
|
+
|
|
657
|
+
- `Compony.path(:accept, :invites, account: some_account)` returns the full tokenized URL —
|
|
658
|
+
email that. The token, not a session, authorizes the request.
|
|
659
|
+
- `path do` runs outside the request context; build URLs via
|
|
660
|
+
`Rails.application.routes.url_helpers`, not `controller`/`helpers` (see
|
|
661
|
+
[standalone.md](/doc/guide/standalone.md#customizing-path-generation)).
|
|
662
|
+
- Reuse the mixin for every link flow (magic login, password reset, email confirm); encode
|
|
663
|
+
a flow discriminator or rely on the per-component payload shape-check to stop a token
|
|
664
|
+
minted for one flow being replayed against another.
|
|
665
|
+
- One signed boolean in the payload (e.g. `confirmed: true`) is tamper-proof since the
|
|
666
|
+
client cannot re-sign — handy for multi-hop confirm flows.
|
|
667
|
+
|
|
668
|
+
## Good habits
|
|
669
|
+
|
|
670
|
+
- **CanCanCan everywhere:** `authorize { can?(...) }`, scope with
|
|
671
|
+
`Model.accessible_by(current_ability)`, bang form `authorize!(:read, record)` for
|
|
672
|
+
ad-hoc checks in `load_data`.
|
|
673
|
+
- **Always `Compony.path` / `render_intent`,** never hardcoded routes or `button_to`
|
|
674
|
+
([gotchas.md #11](/doc/guide/gotchas.md#11-redirect_to-with-a-hardcoded-path), [#15](/doc/guide/gotchas.md#15-hand-rolled-endpoint-where-a-pre-built-crud-component-exists)).
|
|
675
|
+
- **Place a resourceful component in the family of the model it acts on,** not the family
|
|
676
|
+
it is reached from; pass parent context via path params.
|
|
677
|
+
- **Keep virtual/form-only fields off models** — use ActiveType/VirtualModel
|
|
678
|
+
([gotchas.md #16](/doc/guide/gotchas.md#16-attr_accessor-on-a-model-for-form-only-fields)).
|
|
679
|
+
- **`concat`** around every `render_intent`/`render_sub_comp`/`field` in a block.
|
|
680
|
+
|
|
681
|
+
[Guide index](/README.md#guide--documentation)
|
|
@@ -21,7 +21,7 @@ In case you overwrite `store_data`, make sure to set `@update_succeeded` to true
|
|
|
21
21
|
|
|
22
22
|
The following DSL calls are implemented to allow for convenient overrides of default logic:
|
|
23
23
|
|
|
24
|
-
- The block `
|
|
24
|
+
- The block `on_update_failed` is run if `@update_succeeded` is not true. By default, it logs all error messages with level `warn` and renders the component again through HTTP 422, causing Turbo to correctly display the page. Error messages are displayed by the form inputs.
|
|
25
25
|
- The block `on_updated` is evaluated between successful record creation and responding. By default, it is not implemented and doing so is optional. This would be a suitable location for hooks that update state after a resource was updated (like an `after_update` hook, but only executed if a record was updated by this component). Do not redirect or render here, use the next blocks instead.
|
|
26
26
|
- The block given in `on_updated_respond` is evaluated after successful creation and by default shows a flash, then redirects. Overwrite this block if you need to completely customize all logic that happens after creation. If this block is overwritten, `on_updated_redirect_path` will not be called.
|
|
27
27
|
- `on_updated_redirect_path` is evaluated as the second step of `on_updated_respond` and redirects to the resource's Show, its owner's Show, or its own Index component as described above. Overwrite this block in order to redirect ot another component instead, while keeping the default flash provided by `on_updated_respond`.
|