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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +36 -1
  3. data/CHANGELOG.md +31 -0
  4. data/CLAUDE.md +85 -0
  5. data/Gemfile.lock +1 -1
  6. data/README.md +13 -3
  7. data/VERSION +1 -1
  8. data/compony.gemspec +3 -3
  9. data/doc/ComponentGenerator.html +1 -1
  10. data/doc/Components.html +1 -1
  11. data/doc/ComponentsGenerator.html +1 -1
  12. data/doc/Compony/Component.html +54 -54
  13. data/doc/Compony/ComponentMixins/Default/Labelling.html +1 -1
  14. data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +1 -1
  15. data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +109 -70
  16. data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +64 -28
  17. data/doc/Compony/ComponentMixins/Default/Standalone.html +1 -1
  18. data/doc/Compony/ComponentMixins/Default.html +1 -1
  19. data/doc/Compony/ComponentMixins/Resourceful.html +213 -74
  20. data/doc/Compony/ComponentMixins.html +1 -1
  21. data/doc/Compony/Components/Buttons/CssButton.html +1 -1
  22. data/doc/Compony/Components/Buttons/Link.html +1 -1
  23. data/doc/Compony/Components/Buttons.html +1 -1
  24. data/doc/Compony/Components/Destroy.html +83 -29
  25. data/doc/Compony/Components/Edit.html +110 -38
  26. data/doc/Compony/Components/Form.html +551 -208
  27. data/doc/Compony/Components/Index.html +1 -1
  28. data/doc/Compony/Components/List.html +3 -3
  29. data/doc/Compony/Components/New.html +110 -38
  30. data/doc/Compony/Components/Show.html +1 -1
  31. data/doc/Compony/Components/WithForm.html +194 -47
  32. data/doc/Compony/Components.html +1 -1
  33. data/doc/Compony/ControllerMixin.html +1 -1
  34. data/doc/Compony/Engine.html +1 -1
  35. data/doc/Compony/Intent.html +2 -2
  36. data/doc/Compony/ManageIntentsDsl.html +1 -1
  37. data/doc/Compony/MethodAccessibleHash.html +1 -1
  38. data/doc/Compony/ModelFields/Anchormodel.html +1 -1
  39. data/doc/Compony/ModelFields/Association.html +1 -1
  40. data/doc/Compony/ModelFields/Attachment.html +1 -1
  41. data/doc/Compony/ModelFields/Base.html +1 -1
  42. data/doc/Compony/ModelFields/Boolean.html +1 -1
  43. data/doc/Compony/ModelFields/Color.html +1 -1
  44. data/doc/Compony/ModelFields/Currency.html +1 -1
  45. data/doc/Compony/ModelFields/Date.html +1 -1
  46. data/doc/Compony/ModelFields/Datetime.html +1 -1
  47. data/doc/Compony/ModelFields/Decimal.html +1 -1
  48. data/doc/Compony/ModelFields/Email.html +1 -1
  49. data/doc/Compony/ModelFields/Float.html +1 -1
  50. data/doc/Compony/ModelFields/Integer.html +1 -1
  51. data/doc/Compony/ModelFields/Percentage.html +1 -1
  52. data/doc/Compony/ModelFields/Phone.html +1 -1
  53. data/doc/Compony/ModelFields/RichText.html +1 -1
  54. data/doc/Compony/ModelFields/String.html +1 -1
  55. data/doc/Compony/ModelFields/Text.html +1 -1
  56. data/doc/Compony/ModelFields/Time.html +1 -1
  57. data/doc/Compony/ModelFields/Url.html +1 -1
  58. data/doc/Compony/ModelFields.html +1 -1
  59. data/doc/Compony/ModelMixin.html +1 -1
  60. data/doc/Compony/NaturalOrdering.html +1 -1
  61. data/doc/Compony/RequestContext.html +1 -1
  62. data/doc/Compony/Version.html +1 -1
  63. data/doc/Compony/ViewHelpers.html +1 -1
  64. data/doc/Compony/VirtualModel.html +1 -1
  65. data/doc/Compony.html +1 -1
  66. data/doc/ComponyController.html +1 -1
  67. data/doc/_index.html +97 -1
  68. data/doc/file.CHANGELOG.html +758 -0
  69. data/doc/file.README.html +25 -4
  70. data/doc/file.basic_component.html +314 -0
  71. data/doc/file.cookbook.html +189 -0
  72. data/doc/file.destroy.html +105 -0
  73. data/doc/file.dsl_reference.html +672 -0
  74. data/doc/file.edit.html +109 -0
  75. data/doc/file.example.html +291 -0
  76. data/doc/file.example_advanced.html +257 -0
  77. data/doc/file.feasibility.html +115 -0
  78. data/doc/file.form.html +195 -0
  79. data/doc/file.generators.html +89 -0
  80. data/doc/file.glossary.html +217 -0
  81. data/doc/file.gotchas.html +222 -0
  82. data/doc/file.index.html +135 -0
  83. data/doc/file.inheritance.html +136 -0
  84. data/doc/file.installation.html +115 -0
  85. data/doc/file.integrations.html +218 -0
  86. data/doc/file.intents.html +265 -0
  87. data/doc/file.internal_datastructures.html +129 -0
  88. data/doc/file.list.html +253 -0
  89. data/doc/file.maintaining.html +127 -0
  90. data/doc/file.model_fields.html +137 -0
  91. data/doc/file.nesting.html +237 -0
  92. data/doc/file.new.html +109 -0
  93. data/doc/file.ownership.html +98 -0
  94. data/doc/file.patterns.html +669 -0
  95. data/doc/file.pre_built_components.html +99 -0
  96. data/doc/file.resourceful.html +181 -0
  97. data/doc/file.show.html +158 -0
  98. data/doc/file.standalone.html +233 -0
  99. data/doc/file.virtual_models.html +117 -0
  100. data/doc/file.with_form.html +157 -0
  101. data/doc/file_list.html +160 -0
  102. data/doc/guide/cookbook.md +41 -0
  103. data/doc/guide/dsl_reference.md +155 -0
  104. data/doc/guide/example_advanced.md +209 -0
  105. data/doc/guide/generators.md +1 -1
  106. data/doc/guide/glossary.md +42 -0
  107. data/doc/guide/gotchas.md +125 -0
  108. data/doc/guide/maintaining.md +64 -0
  109. data/doc/guide/patterns.md +681 -0
  110. data/doc/guide/pre_built_components/edit.md +1 -1
  111. data/doc/guide/pre_built_components/index.md +64 -1
  112. data/doc/guide/pre_built_components/list.md +111 -7
  113. data/doc/guide/pre_built_components/show.md +57 -2
  114. data/doc/guide/pre_built_components/with_form.md +56 -9
  115. data/doc/guide/pre_built_components.md +7 -2
  116. data/doc/guide/standalone.md +16 -1
  117. data/doc/index.html +25 -4
  118. data/doc/integrations.md +61 -0
  119. data/doc/llms.txt +62 -0
  120. data/doc/top-level-namespace.html +1 -1
  121. data/lib/compony/component.rb +8 -3
  122. data/lib/compony/component_mixins/default/standalone/standalone_dsl.rb +32 -15
  123. data/lib/compony/component_mixins/default/standalone/verb_dsl.rb +11 -3
  124. data/lib/compony/component_mixins/resourceful.rb +30 -16
  125. data/lib/compony/components/destroy.rb +21 -1
  126. data/lib/compony/components/edit.rb +25 -1
  127. data/lib/compony/components/form.rb +63 -21
  128. data/lib/compony/components/list.rb +1 -1
  129. data/lib/compony/components/new.rb +25 -1
  130. data/lib/compony/components/with_form.rb +20 -5
  131. data/lib/compony/intent.rb +1 -1
  132. 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 `on_update_failed_respond` 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.
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`.