view_component 2.17.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.

Potentially problematic release.


This version of view_component might be problematic. Click here for more details.

Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +582 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1000 -0
  5. data/app/controllers/view_components_controller.rb +71 -0
  6. data/app/views/view_components/index.html.erb +8 -0
  7. data/app/views/view_components/preview.html.erb +1 -0
  8. data/app/views/view_components/previews.html.erb +6 -0
  9. data/lib/rails/generators/component/USAGE +13 -0
  10. data/lib/rails/generators/component/component_generator.rb +42 -0
  11. data/lib/rails/generators/component/templates/component.rb.tt +7 -0
  12. data/lib/rails/generators/erb/component_generator.rb +30 -0
  13. data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
  14. data/lib/rails/generators/haml/component_generator.rb +30 -0
  15. data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
  16. data/lib/rails/generators/rspec/component_generator.rb +19 -0
  17. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
  18. data/lib/rails/generators/slim/component_generator.rb +30 -0
  19. data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
  20. data/lib/rails/generators/test_unit/component_generator.rb +20 -0
  21. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
  22. data/lib/view_component.rb +14 -0
  23. data/lib/view_component/base.rb +458 -0
  24. data/lib/view_component/collection.rb +41 -0
  25. data/lib/view_component/compile_cache.rb +24 -0
  26. data/lib/view_component/engine.rb +101 -0
  27. data/lib/view_component/preview.rb +111 -0
  28. data/lib/view_component/preview_template_error.rb +6 -0
  29. data/lib/view_component/previewable.rb +38 -0
  30. data/lib/view_component/render_component_helper.rb +9 -0
  31. data/lib/view_component/render_component_to_string_helper.rb +9 -0
  32. data/lib/view_component/render_monkey_patch.rb +13 -0
  33. data/lib/view_component/render_to_string_monkey_patch.rb +13 -0
  34. data/lib/view_component/rendering_component_helper.rb +9 -0
  35. data/lib/view_component/rendering_monkey_patch.rb +13 -0
  36. data/lib/view_component/slot.rb +7 -0
  37. data/lib/view_component/slotable.rb +121 -0
  38. data/lib/view_component/template_error.rb +9 -0
  39. data/lib/view_component/test_case.rb +9 -0
  40. data/lib/view_component/test_helpers.rb +49 -0
  41. data/lib/view_component/version.rb +11 -0
  42. metadata +244 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 GitHub
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.
@@ -0,0 +1,1000 @@
1
+ # ViewComponent
2
+ ViewComponent is a framework for building view components that are reusable, testable & encapsulated, in Ruby on Rails.
3
+
4
+ ## Design philosophy
5
+ ViewComponent is designed to integrate as seamlessly as possible [with Rails](https://rubyonrails.org/doctrine/), with the [least surprise](https://www.artima.com/intv/ruby4.html).
6
+
7
+ ## Compatibility
8
+ ViewComponent is [supported natively](https://edgeguides.rubyonrails.org/layouts_and_rendering.html#rendering-objects) in Rails 6.1, and compatible with Rails 5.0+ via an included [monkey patch](https://github.com/github/view_component/blob/master/lib/view_component/render_monkey_patch.rb).
9
+
10
+ ViewComponent is tested for compatibility [with combinations of](https://github.com/github/view_component/blob/22e3d4ccce70d8f32c7375e5a5ccc3f70b22a703/.github/workflows/ruby_on_rails.yml#L10-L11) Ruby 2.4+ and Rails 5+.
11
+
12
+ ## Installation
13
+
14
+ In `Gemfile`, add:
15
+
16
+ ```ruby
17
+ gem "view_component"
18
+ ```
19
+
20
+ In `config/application.rb`, add:
21
+
22
+ ```bash
23
+ require "view_component/engine"
24
+ ```
25
+
26
+ ## Guide
27
+
28
+ ### What are components?
29
+
30
+ ViewComponents are Ruby objects that output HTML. Think of them as an evolution of the presenter pattern, inspired by [React](https://reactjs.org/docs/react-component.html).
31
+
32
+ Components are most effective in cases where view code is reused or benefits from being tested directly.
33
+
34
+ ### Why should I use components?
35
+
36
+ #### Testing
37
+
38
+ Unlike traditional Rails views, ViewComponents can be unit-tested. In the GitHub codebase, component unit tests take around 25 milliseconds each, compared to about six seconds for controller tests.
39
+
40
+ Rails views are typically tested with slow integration tests that also exercise the routing and controller layers in addition to the view. This cost often discourages thorough test coverage.
41
+
42
+ With ViewComponent, integration tests can be reserved for end-to-end assertions, with permutations and corner cases covered at the unit level.
43
+
44
+ #### Data Flow
45
+
46
+ Traditional Rails views have an implicit interface, making it hard to reason about what information is needed to render, leading to subtle bugs when rendering the same view in different contexts.
47
+
48
+ ViewComponents use a standard Ruby initializer that clearly defines what is needed to render, making them easier (and safer) to reuse than partials.
49
+
50
+ #### Standards
51
+
52
+ Views often fail basic Ruby code quality standards: long methods, deep conditional nesting, and mystery guests abound.
53
+
54
+ ViewComponents are Ruby objects, making it easy to follow (and enforce) code quality standards.
55
+
56
+ ### Building components
57
+
58
+ #### Conventions
59
+
60
+ Components are subclasses of `ViewComponent::Base` and live in `app/components`. It's common practice to create and inherit from an `ApplicationComponent` that is a subclass of `ViewComponent::Base`.
61
+
62
+ Component names end in -`Component`.
63
+
64
+ Component module names are plural, as for controllers and jobs: `Users::AvatarComponent`
65
+
66
+ #### Quick start
67
+
68
+ Use the component generator to create a new ViewComponent.
69
+
70
+ The generator accepts a component name and a list of arguments:
71
+
72
+ ```bash
73
+ bin/rails generate component Example title content
74
+ invoke test_unit
75
+ create test/components/example_component_test.rb
76
+ create app/components/example_component.rb
77
+ create app/components/example_component.html.erb
78
+ ```
79
+
80
+ ViewComponent includes template generators for the `erb`, `haml`, and `slim` template engines and will default to the template engine specified in `config.generators.template_engine`.
81
+
82
+ The template engine can also be passed as an option to the generator:
83
+
84
+ ```bash
85
+ bin/rails generate component Example title content --template-engine slim
86
+ ```
87
+
88
+ #### Implementation
89
+
90
+ A ViewComponent is a Ruby file and corresponding template file with the same base name:
91
+
92
+ `app/components/test_component.rb`:
93
+ ```ruby
94
+ class TestComponent < ViewComponent::Base
95
+ def initialize(title:)
96
+ @title = title
97
+ end
98
+ end
99
+ ```
100
+
101
+ `app/components/test_component.html.erb`:
102
+ ```erb
103
+ <span title="<%= @title %>"><%= content %></span>
104
+ ```
105
+
106
+ Rendered in a view as:
107
+
108
+ ```erb
109
+ <%= render(TestComponent.new(title: "my title")) do %>
110
+ Hello, World!
111
+ <% end %>
112
+ ```
113
+
114
+ Returning:
115
+
116
+ ```html
117
+ <span title="my title">Hello, World!</span>
118
+ ```
119
+
120
+ #### Content Areas
121
+
122
+ Content passed to a ViewComponent as a block is captured and assigned to the `content` accessor.
123
+
124
+ ViewComponents can declare additional content areas. For example:
125
+
126
+ `app/components/modal_component.rb`:
127
+ ```ruby
128
+ class ModalComponent < ViewComponent::Base
129
+ with_content_areas :header, :body
130
+ end
131
+ ```
132
+
133
+ `app/components/modal_component.html.erb`:
134
+ ```erb
135
+ <div class="modal">
136
+ <div class="header"><%= header %></div>
137
+ <div class="body"><%= body %></div>
138
+ </div>
139
+ ```
140
+
141
+ Rendered in a view as:
142
+
143
+ ```erb
144
+ <%= render(ModalComponent.new) do |component| %>
145
+ <% component.with(:header) do %>
146
+ Hello Jane
147
+ <% end %>
148
+ <% component.with(:body) do %>
149
+ <p>Have a great day.</p>
150
+ <% end %>
151
+ <% end %>
152
+ ```
153
+
154
+ Returning:
155
+
156
+ ```html
157
+ <div class="modal">
158
+ <div class="header">Hello Jane</div>
159
+ <div class="body"><p>Have a great day.</p></div>
160
+ </div>
161
+ ```
162
+
163
+ #### Slots (experimental)
164
+
165
+ _Slots are currently under development as a successor to Content Areas. The Slot APIs should be considered unfinished and subject to breaking changes in non-major releases of ViewComponent._
166
+
167
+ Slots enable multiple blocks of content to be passed to a single ViewComponent.
168
+
169
+ Slots exist in two forms: normal slots and collection slots.
170
+
171
+ Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of `ViewComponent::Slot`, etc.
172
+
173
+ Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (`#rows`), which is an Array of `ViewComponent::Slot` instances.
174
+
175
+ To learn more about the design of the Slots API, see https://github.com/github/view_component/pull/348.
176
+
177
+ ##### Defining Slots
178
+
179
+ Slots are defined by the `with_slot` macro:
180
+
181
+ `with_slot :header`
182
+
183
+ To define a collection slot, add `collection: true`:
184
+
185
+ `with_slot :row, collection: true`
186
+
187
+ To define a slot with a custom class, pass `class_name`:
188
+
189
+ `with_slot :body, class_name: 'BodySlot`
190
+
191
+ Slot classes should be subclasses of `ViewComponent::Slot`.
192
+
193
+ ##### Example ViewComponent with Slots
194
+
195
+ `# box_component.rb`
196
+ ```ruby
197
+ class BoxComponent < ViewComponent::Base
198
+ include ViewComponent::Slotable
199
+
200
+ with_slot :body, :footer
201
+ with_slot :header, class_name: "Header"
202
+ with_slot :row, collection: true, class_name: "Row"
203
+
204
+ class Header < ViewComponent::Slot
205
+ def initialize(class_names: "")
206
+ @class_names = class_names
207
+ end
208
+
209
+ def class_names
210
+ "Box-header #{@class_names}"
211
+ end
212
+ end
213
+
214
+ class Row < ViewComponent::Slot
215
+ def initialize(theme: :gray)
216
+ @theme = theme
217
+ end
218
+
219
+ def theme_class_name
220
+ case @theme
221
+ when :gray
222
+ "Box-row--gray"
223
+ when :hover_gray
224
+ "Box-row--hover-gray"
225
+ when :yellow
226
+ "Box-row--yellow"
227
+ when :blue
228
+ "Box-row--blue"
229
+ when :hover_blue
230
+ "Box-row--hover-blue"
231
+ else
232
+ "Box-row--gray"
233
+ end
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ `# box_component.html.erb`
240
+ ```erb
241
+ <div class="Box">
242
+ <% if header %>
243
+ <div class="<%= header.class_names %>">
244
+ <%= header.content %>
245
+ </div>
246
+ <% end %>
247
+ <% if body %>
248
+ <div class="Box-body">
249
+ <%= body.content %>
250
+ </div>
251
+ <% end %>
252
+ <% if rows.any? %>
253
+ <ul>
254
+ <% rows.each do |row| %>
255
+ <li class="Box-row <%= row.theme_class_name %>">
256
+ <%= row.content %>
257
+ </li>
258
+ <% end %>
259
+ </ul>
260
+ <% end %>
261
+ <% if footer %>
262
+ <div class="Box-footer">
263
+ <%= footer %>
264
+ </div>
265
+ <% end %>
266
+ </div>
267
+ ```
268
+
269
+ `# index.html.erb`
270
+ ```erb
271
+ <%= render(BoxComponent.new) do |component| %>
272
+ <% component.slot(:header, class_names: "my-class-name") do %>
273
+ This is my header!
274
+ <% end %>
275
+ <% component.slot(:body) do %>
276
+ This is the body.
277
+ <% end %>
278
+ <% component.slot(:row) do %>
279
+ Row one
280
+ <% end %>
281
+ <% component.slot(:row, theme: :yellow) do %>
282
+ Yellow row
283
+ <% end %>
284
+ <% component.slot(:footer) do %>
285
+ This is the footer.
286
+ <% end %>
287
+ <% end %>
288
+ ```
289
+
290
+ ### Inline Component
291
+
292
+ ViewComponents can render without a template file, by defining a `call` method:
293
+
294
+ `app/components/inline_component.rb`:
295
+ ```ruby
296
+ class InlineComponent < ViewComponent::Base
297
+ def call
298
+ if active?
299
+ link_to "Cancel integration", integration_path, method: :delete
300
+ else
301
+ link_to "Integrate now!", integration_path
302
+ end
303
+ end
304
+ end
305
+ ```
306
+
307
+ It is also possible to define methods for variants:
308
+
309
+ ```ruby
310
+ class InlineVariantComponent < ViewComponent::Base
311
+ def call_phone
312
+ link_to "Phone", phone_path
313
+ end
314
+
315
+ def call
316
+ link_to "Default", default_path
317
+ end
318
+ end
319
+ ```
320
+
321
+ ### Sidecar Assets
322
+
323
+ ViewComponents supports two options for defining view files.
324
+
325
+ #### Sidecar view
326
+
327
+ The simplest option is to place the view next to the Ruby component:
328
+
329
+ ```
330
+ app/components
331
+ ├── ...
332
+ ├── test_component.rb
333
+ ├── test_component.html.erb
334
+ ├── ...
335
+ ```
336
+
337
+ #### Sidecar directory
338
+
339
+ As an alternative, views and other assets can be placed in a sidecar directory with the same name as the component, which can be useful for organizing views alongside other assets like Javascript and CSS.
340
+
341
+ ```
342
+ app/components
343
+ ├── ...
344
+ ├── example_component.rb
345
+ ├── example_component
346
+ | ├── example_component.css
347
+ | ├── example_component.html.erb
348
+ | └── example_component.js
349
+ ├── ...
350
+
351
+ ```
352
+
353
+ To generate a component with a sidecar directory, use the `--sidecar` flag:
354
+
355
+ ```
356
+ bin/rails generate component Example title content --sidecar
357
+ invoke test_unit
358
+ create test/components/example_component_test.rb
359
+ create app/components/example_component.rb
360
+ create app/components/example_component/example_component.html.erb
361
+ ```
362
+
363
+ ### Conditional Rendering
364
+
365
+ Components can implement a `#render?` method to be called after initialization to determine if the component should render.
366
+
367
+ Traditionally, the logic for whether to render a view could go in either the component template:
368
+
369
+ `app/components/confirm_email_component.html.erb`
370
+ ```
371
+ <% if user.requires_confirmation? %>
372
+ <div class="alert">Please confirm your email address.</div>
373
+ <% end %>
374
+ ```
375
+
376
+ or the view that renders the component:
377
+
378
+ `app/views/_banners.html.erb`
379
+ ```erb
380
+ <% if current_user.requires_confirmation? %>
381
+ <%= render(ConfirmEmailComponent.new(user: current_user)) %>
382
+ <% end %>
383
+ ```
384
+
385
+ Using the `#render?` hook simplifies the view:
386
+
387
+ `app/components/confirm_email_component.rb`
388
+ ```ruby
389
+ class ConfirmEmailComponent < ViewComponent::Base
390
+ def initialize(user:)
391
+ @user = user
392
+ end
393
+
394
+ def render?
395
+ @user.requires_confirmation?
396
+ end
397
+ end
398
+ ```
399
+
400
+ `app/components/confirm_email_component.html.erb`
401
+ ```
402
+ <div class="banner">
403
+ Please confirm your email address.
404
+ </div>
405
+ ```
406
+
407
+ `app/views/_banners.html.erb`
408
+ ```erb
409
+ <%= render(ConfirmEmailComponent.new(user: current_user)) %>
410
+ ```
411
+
412
+ _To assert that a component has not been rendered, use `refute_component_rendered` from `ViewComponent::TestHelpers`._
413
+
414
+ ### `before_render`
415
+
416
+ Components can define a `before_render` method to be called before a component is rendered, when `helpers` is able to be used:
417
+
418
+ `app/components/confirm_email_component.rb`
419
+ ```ruby
420
+ class MyComponent < ViewComponent::Base
421
+ def before_render
422
+ @my_icon = helpers.star_icon
423
+ end
424
+ end
425
+ ```
426
+
427
+ ### Rendering collections
428
+
429
+ Use `with_collection` to render a ViewComponent with a collection:
430
+
431
+ `app/view/products/index.html.erb`
432
+ ``` erb
433
+ <%= render(ProductComponent.with_collection(@products)) %>
434
+ ```
435
+
436
+ `app/components/product_component.rb`
437
+ ``` ruby
438
+ class ProductComponent < ViewComponent::Base
439
+ def initialize(product:)
440
+ @product = product
441
+ end
442
+ end
443
+ ```
444
+
445
+ [By default](https://github.com/github/view_component/blob/89f8fab4609c1ef2467cf434d283864b3c754473/lib/view_component/base.rb#L249), the component name is used to define the parameter passed into the component from the collection.
446
+
447
+ #### `with_collection_parameter`
448
+
449
+ Use `with_collection_parameter` to change the name of the collection parameter:
450
+
451
+ `app/components/product_component.rb`
452
+ ``` ruby
453
+ class ProductComponent < ViewComponent::Base
454
+ with_collection_parameter :item
455
+
456
+ def initialize(item:)
457
+ @item = item
458
+ end
459
+ end
460
+ ```
461
+
462
+ #### Additional arguments
463
+
464
+ Additional arguments besides the collection are passed to each component instance:
465
+
466
+ `app/view/products/index.html.erb`
467
+ ``` erb
468
+ <%= render(ProductComponent.with_collection(@products, notice: "hi")) %>
469
+ ```
470
+
471
+ `app/components/product_component.rb`
472
+ ``` ruby
473
+ class ProductComponent < ViewComponent::Base
474
+ with_collection_parameter :item
475
+
476
+ def initialize(item:, notice:)
477
+ @item = item
478
+ @notice = notice
479
+ end
480
+ end
481
+ ```
482
+
483
+ `app/components/product_component.html.erb`
484
+ ``` erb
485
+ <li>
486
+ <h2><%= @item.name %></h2>
487
+ <span><%= @notice %></span>
488
+ </li>
489
+ ```
490
+
491
+ #### Collection counter
492
+
493
+ ViewComponent defines a counter variable matching the parameter name above, followed by `_counter`. To access the variable, add it to `initialize` as an argument:
494
+
495
+ `app/components/product_component.rb`
496
+ ``` ruby
497
+ class ProductComponent < ViewComponent::Base
498
+ def initialize(product:, product_counter:)
499
+ @product = product
500
+ @counter = product_counter
501
+ end
502
+ end
503
+ ```
504
+
505
+ `app/components/product_component.html.erb`
506
+ ``` erb
507
+ <li>
508
+ <%= @counter %> <%= @product.name %>
509
+ </li>
510
+ ```
511
+
512
+ ### Using helpers
513
+
514
+ Helper methods can be used through the `helpers` proxy:
515
+
516
+ ```ruby
517
+ module IconHelper
518
+ def icon(name)
519
+ tag.i data: { feather: name.to_s.dasherize }
520
+ end
521
+ end
522
+
523
+ class UserComponent < ViewComponent::Base
524
+ def profile_icon
525
+ helpers.icon :user
526
+ end
527
+ end
528
+ ```
529
+
530
+ Which can be used with `delegate`:
531
+
532
+ ```ruby
533
+ class UserComponent < ViewComponent::Base
534
+ delegate :icon, to: :helpers
535
+
536
+ def profile_icon
537
+ icon :user
538
+ end
539
+ end
540
+ ```
541
+
542
+ Helpers can also be used by including the helper:
543
+
544
+ ```ruby
545
+ class UserComponent < ViewComponent::Base
546
+ include IconHelper
547
+
548
+ def profile_icon
549
+ icon :user
550
+ end
551
+ end
552
+ ```
553
+
554
+ ### Testing
555
+
556
+ Unit test components directly, using the `render_inline` test helper, asserting against the rendered output.
557
+
558
+ Capybara matchers are available if the gem is installed:
559
+
560
+ ```ruby
561
+ require "view_component/test_case"
562
+
563
+ class MyComponentTest < ViewComponent::TestCase
564
+ test "render component" do
565
+ render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }
566
+
567
+ assert_selector("span[title='my title']", text: "Hello, World!")
568
+ end
569
+ end
570
+ ```
571
+
572
+ In the absence of `capybara`, assert against the return value of `render_inline`, which is an instance of `Nokogiri::HTML::DocumentFragment`:
573
+
574
+ ```ruby
575
+ test "render component" do
576
+ result = render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }
577
+
578
+ assert_includes result.css("span[title='my title']").to_html, "Hello, World!"
579
+ end
580
+ ```
581
+
582
+ Alternatively, assert against the raw output of the component, which is exposed as `rendered_component`:
583
+
584
+ ```ruby
585
+ test "render component" do
586
+ render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }
587
+
588
+ assert_includes rendered_component, "Hello, World!"
589
+ end
590
+ ```
591
+
592
+ To test components that use `with_content_areas`:
593
+
594
+ ```ruby
595
+ test "renders content_areas template with content " do
596
+ render_inline(ContentAreasComponent.new(footer: "Bye!")) do |component|
597
+ component.with(:title, "Hello!")
598
+ component.with(:body) { "Have a nice day." }
599
+ end
600
+
601
+ assert_selector(".title", text: "Hello!")
602
+ assert_selector(".body", text: "Have a nice day.")
603
+ assert_selector(".footer", text: "Bye!")
604
+ end
605
+ ```
606
+
607
+ #### Action Pack Variants
608
+
609
+ Use the `with_variant` helper to test specific variants:
610
+
611
+ ```ruby
612
+ test "render component for tablet" do
613
+ with_variant :tablet do
614
+ render_inline(TestComponent.new(title: "my title")) { "Hello, tablets!" }
615
+
616
+ assert_selector("span[title='my title']", text: "Hello, tablets!")
617
+ end
618
+ end
619
+ ```
620
+
621
+ ### Previewing Components
622
+ `ViewComponent::Preview`, like `ActionMailer::Preview`, provides a way to preview components in isolation:
623
+
624
+ `test/components/previews/test_component_preview.rb`
625
+ ```ruby
626
+ class TestComponentPreview < ViewComponent::Preview
627
+ def with_default_title
628
+ render(TestComponent.new(title: "Test component default"))
629
+ end
630
+
631
+ def with_long_title
632
+ render(TestComponent.new(title: "This is a really long title to see how the component renders this"))
633
+ end
634
+
635
+ def with_content_block
636
+ render(TestComponent.new(title: "This component accepts a block of content")) do
637
+ tag.div do
638
+ content_tag(:span, "Hello")
639
+ end
640
+ end
641
+ end
642
+ end
643
+ ```
644
+
645
+ Which generates <http://localhost:3000/rails/view_components/test_component/with_default_title>,
646
+ <http://localhost:3000/rails/view_components/test_component/with_long_title>,
647
+ and <http://localhost:3000/rails/view_components/test_component/with_content_block>.
648
+
649
+ It's also possible to set dynamic values from the params by setting them as arguments:
650
+
651
+ `test/components/previews/test_component_preview.rb`
652
+ ```ruby
653
+ class TestComponentPreview < ViewComponent::Preview
654
+ def with_dynamic_title(title: "Test component default")
655
+ render(TestComponent.new(title: title))
656
+ end
657
+ end
658
+ ```
659
+
660
+ Which enables passing in a value with <http://localhost:3000/rails/components/test_component/with_dynamic_title?title=Custom+title>.
661
+
662
+ The `ViewComponent::Preview` base class includes
663
+ [`ActionView::Helpers::TagHelper`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html), which provides the [`tag`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-tag)
664
+ and [`content_tag`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag) view helper methods.
665
+
666
+ Previews use the application layout by default, but can use a specific layout with the `layout` option:
667
+
668
+ `test/components/previews/test_component_preview.rb`
669
+ ```ruby
670
+ class TestComponentPreview < ViewComponent::Preview
671
+ layout "admin"
672
+
673
+ ...
674
+ end
675
+ ```
676
+
677
+ Preview classes live in `test/components/previews`, which can be configured using the `preview_paths` option:
678
+
679
+ `config/application.rb`
680
+ ```ruby
681
+ config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"
682
+ ```
683
+
684
+ Previews are served from <http://localhost:3000/rails/view_components> by default. To use a different endpoint, set the `preview_route` option:
685
+
686
+ `config/application.rb`
687
+ ```ruby
688
+ config.view_component.preview_route = "/previews"
689
+ ```
690
+
691
+ This example will make the previews available from <http://localhost:3000/previews>.
692
+
693
+ #### Preview templates
694
+
695
+ Given a preview `test/components/previews/cell_component_preview.rb`, template files can be defined at `test/components/previews/cell_component_preview/`:
696
+
697
+ `test/components/previews/cell_component_preview.rb`
698
+ ```ruby
699
+ class CellComponentPreview < ViewComponent::Preview
700
+ def default
701
+ end
702
+ end
703
+ ```
704
+
705
+ `test/components/previews/cell_component_preview/default.html.erb`
706
+ ```erb
707
+ <table class="table">
708
+ <tbody>
709
+ <tr>
710
+ <%= render CellComponent.new %>
711
+ </tr>
712
+ </tbody>
713
+ </div>
714
+ ```
715
+
716
+ To use a different location for preview templates, pass the `template` argument:
717
+ (the path should be relative to `config.view_component.preview_path`):
718
+
719
+ `test/components/previews/cell_component_preview.rb`
720
+ ```ruby
721
+ class CellComponentPreview < ViewComponent::Preview
722
+ def default
723
+ render_with_template(template: 'custom_cell_component_preview/my_preview_template')
724
+ end
725
+ end
726
+ ```
727
+
728
+ Values from `params` can be accessed through `locals`:
729
+
730
+ `test/components/previews/cell_component_preview.rb`
731
+ ```ruby
732
+ class CellComponentPreview < ViewComponent::Preview
733
+ def default(title: "Default title", subtitle: "A subtitle")
734
+ render_with_template(locals: {
735
+ title: title,
736
+ subtitle: subtitle
737
+ })
738
+ end
739
+ end
740
+ ```
741
+
742
+ Which enables passing in a value with <http://localhost:3000/rails/components/cell_component/default?title=Custom+title&subtitle=Another+subtitle>.
743
+
744
+ #### Configuring TestController
745
+
746
+ Component tests and previews assume the existence of an `ApplicationController` class, which be can be configured using the `test_controller` option:
747
+
748
+ `config/application.rb`
749
+ ```ruby
750
+ config.view_component.test_controller = "BaseController"
751
+ ```
752
+
753
+ ### Setting up RSpec
754
+
755
+ To use RSpec, add the following:
756
+
757
+ `spec/rails_helper.rb`
758
+ ```ruby
759
+ require "view_component/test_helpers"
760
+
761
+ RSpec.configure do |config|
762
+ config.include ViewComponent::TestHelpers, type: :component
763
+ end
764
+ ```
765
+
766
+ Specs created by the generator have access to test helpers like `render_inline`.
767
+
768
+ To use component previews:
769
+
770
+ `config/application.rb`
771
+ ```ruby
772
+ config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"
773
+ ```
774
+
775
+ ### Disabling the render monkey patch (Rails < 6.1)
776
+
777
+ In order to [avoid conflicts](https://github.com/github/view_component/issues/288) between ViewComponent and other gems that also monkey patch the `render` method, it is possible to configure ViewComponent to not include the render monkey patch:
778
+
779
+ `config.view_component.render_monkey_patch_enabled = false # defaults to true`
780
+
781
+ With the monkey patch disabled, use `render_component` (or `render_component_to_string`) instead:
782
+
783
+ ```
784
+ <%= render_component Component.new(message: "bar") %>
785
+ ```
786
+
787
+ ### Sidecar assets (experimental)
788
+
789
+ It’s possible to include Javascript and CSS alongside components, sometimes called "sidecar" assets or files.
790
+
791
+ To use the Webpacker gem to compile sidecar assets located in `app/components`:
792
+
793
+ 1. In `config/webpacker.yml`, add `"app/components"` to the `resolved_paths` array (e.g. `resolved_paths: ["app/components"]`).
794
+ 2. In the Webpack entry file (often `app/javascript/packs/application.js`), add an import statement to a helper file, and in the helper file, import the components' Javascript:
795
+
796
+ ```js
797
+ import "../components"
798
+ ```
799
+
800
+ Then, in `app/javascript/components.js`, add:
801
+
802
+ ```js
803
+ function importAll(r) {
804
+ r.keys().forEach(r)
805
+ }
806
+
807
+ importAll(require.context("../components", true, /_component.js$/))
808
+ ```
809
+
810
+ Any file with the `_component.js` suffix (such as `app/components/widget_component.js`) will be compiled into the Webpack bundle. If that file itself imports another file, for example `app/components/widget_component.css`, it will also be compiled and bundled into Webpack's output stylesheet if Webpack is being used for styles.
811
+
812
+ #### Encapsulating sidecar assets
813
+
814
+ Ideally, sidecar Javascript/CSS should not "leak" out of the context of its associated component.
815
+
816
+ One approach is to use Web Components, which contain all Javascript functionality, internal markup, and styles within the shadow root of the Web Component.
817
+
818
+ For example:
819
+
820
+ `app/components/comment_component.rb`
821
+ ```ruby
822
+ class CommentComponent < ViewComponent::Base
823
+ def initialize(comment:)
824
+ @comment = comment
825
+ end
826
+
827
+ def commenter
828
+ @comment.user
829
+ end
830
+
831
+ def commenter_name
832
+ commenter.name
833
+ end
834
+
835
+ def avatar
836
+ commenter.avatar_image_url
837
+ end
838
+
839
+ def formatted_body
840
+ simple_format(@comment.body)
841
+ end
842
+
843
+ private
844
+
845
+ attr_reader :comment
846
+ end
847
+ ```
848
+
849
+ `app/components/comment_component.html.erb`
850
+ ```erb
851
+ <my-comment comment-id="<%= comment.id %>">
852
+ <time slot="posted" datetime="<%= comment.created_at.iso8601 %>"><%= comment.created_at.strftime("%b %-d") %></time>
853
+
854
+ <div slot="avatar"><img src="<%= avatar %>" /></div>
855
+
856
+ <div slot="author"><%= commenter_name %></div>
857
+
858
+ <div slot="body"><%= formatted_body %></div>
859
+ </my-comment>
860
+ ```
861
+
862
+ `app/components/comment_component.js`
863
+ ```js
864
+ class Comment extends HTMLElement {
865
+ styles() {
866
+ return `
867
+ :host {
868
+ display: block;
869
+ }
870
+ ::slotted(time) {
871
+ float: right;
872
+ font-size: 0.75em;
873
+ }
874
+ .commenter { font-weight: bold; }
875
+ .body { … }
876
+ `
877
+ }
878
+
879
+ constructor() {
880
+ super()
881
+ const shadow = this.attachShadow({mode: 'open'});
882
+ shadow.innerHTML = `
883
+ <style>
884
+ ${this.styles()}
885
+ </style>
886
+ <slot name="posted"></slot>
887
+ <div class="commenter">
888
+ <slot name="avatar"></slot> <slot name="author"></slot>
889
+ </div>
890
+ <div class="body">
891
+ <slot name="body"></slot>
892
+ </div>
893
+ `
894
+ }
895
+ }
896
+ customElements.define('my-comment', Comment)
897
+ ```
898
+
899
+ ##### Stimulus
900
+
901
+ In Stimulus, create a 1:1 mapping between a Stimulus controller and a component. In order to load in Stimulus controllers from the `app/components` tree, amend the Stimulus boot code in `app/javascript/packs/application.js`:
902
+
903
+ ```js
904
+ const application = Application.start()
905
+ const context = require.context("controllers", true, /.js$/)
906
+ const context_components = require.context("../../components", true, /_controller.js$/)
907
+ application.load(
908
+ definitionsFromContext(context).concat(
909
+ definitionsFromContext(context_components)
910
+ )
911
+ )
912
+ ```
913
+
914
+ This enables the creation of files such as `app/components/widget_controller.js`, where the controller identifier matches the `data-controller` attribute in the component's HTML template.
915
+
916
+ ## Frequently Asked Questions
917
+
918
+ ### Can I use other templating languages besides ERB?
919
+
920
+ Yes. ViewComponent is tested against ERB, Haml, and Slim, but it should support most Rails template handlers.
921
+
922
+ ### Isn't this just like X library?
923
+
924
+ ViewComponent is far from a novel idea! Popular implementations of view components in Ruby include, but are not limited to:
925
+
926
+ - [trailblazer/cells](https://github.com/trailblazer/cells)
927
+ - [dry-rb/dry-view](https://github.com/dry-rb/dry-view)
928
+ - [komposable/komponent](https://github.com/komposable/komponent)
929
+ - [activeadmin/arbre](https://github.com/activeadmin/arbre)
930
+
931
+ ## Resources
932
+
933
+ - [Encapsulating Views, RailsConf 2020](https://youtu.be/YVYRus_2KZM)
934
+ - [Rethinking the View Layer with Components, Ruby Rogues Podcast](https://devchat.tv/ruby-rogues/rr-461-rethinking-the-view-layer-with-components-with-joel-hawksley/)
935
+ - [ViewComponents in Action with Andrew Mason, Ruby on Rails Podcast](https://5by5.tv/rubyonrails/320)
936
+ - [ViewComponent at GitHub with Joel Hawksley](https://the-ruby-blend.fireside.fm/9)
937
+ - [Components, HAML vs ERB, and Design Systems](https://the-ruby-blend.fireside.fm/4)
938
+ - [Choosing the Right Tech Stack with Dave Paola](https://5by5.tv/rubyonrails/307)
939
+ - [Rethinking the View Layer with Components, RailsConf 2019](https://www.youtube.com/watch?v=y5Z5a6QdA-M)
940
+ - [Introducing ActionView::Component with Joel Hawksley, Ruby on Rails Podcast](http://5by5.tv/rubyonrails/276)
941
+ - [Rails to Introduce View Components, Dev.to](https://dev.to/andy/rails-to-introduce-view-components-3ome)
942
+ - [ActionView::Components in Rails 6.1, Drifting Ruby](https://www.driftingruby.com/episodes/actionview-components-in-rails-6-1)
943
+ - [Demo repository, view-component-demo](https://github.com/joelhawksley/view-component-demo)
944
+
945
+ ## Contributing
946
+
947
+ Bug reports and pull requests are welcome on GitHub at https://github.com/github/view_component. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. We recommend reading the [contributing guide](./CONTRIBUTING.md) as well.
948
+
949
+ ## Contributors
950
+
951
+ ViewComponent is built by:
952
+
953
+ |<img src="https://avatars.githubusercontent.com/joelhawksley?s=256" alt="joelhawksley" width="128" />|<img src="https://avatars.githubusercontent.com/tenderlove?s=256" alt="tenderlove" width="128" />|<img src="https://avatars.githubusercontent.com/jonspalmer?s=256" alt="jonspalmer" width="128" />|<img src="https://avatars.githubusercontent.com/juanmanuelramallo?s=256" alt="juanmanuelramallo" width="128" />|<img src="https://avatars.githubusercontent.com/vinistock?s=256" alt="vinistock" width="128" />|
954
+ |:---:|:---:|:---:|:---:|:---:|
955
+ |@joelhawksley|@tenderlove|@jonspalmer|@juanmanuelramallo|@vinistock|
956
+ |Denver|Seattle|Boston||Toronto|
957
+
958
+ |<img src="https://avatars.githubusercontent.com/metade?s=256" alt="metade" width="128" />|<img src="https://avatars.githubusercontent.com/asgerb?s=256" alt="asgerb" width="128" />|<img src="https://avatars.githubusercontent.com/xronos-i-am?s=256" alt="xronos-i-am" width="128" />|<img src="https://avatars.githubusercontent.com/dylnclrk?s=256" alt="dylnclrk" width="128" />|<img src="https://avatars.githubusercontent.com/kaspermeyer?s=256" alt="kaspermeyer" width="128" />|
959
+ |:---:|:---:|:---:|:---:|:---:|
960
+ |@metade|@asgerb|@xronos-i-am|@dylnclrk|@kaspermeyer|
961
+ |London|Copenhagen|Russia, Kirov|Berkeley, CA|Denmark|
962
+
963
+ |<img src="https://avatars.githubusercontent.com/rdavid1099?s=256" alt="rdavid1099" width="128" />|<img src="https://avatars.githubusercontent.com/kylefox?s=256" alt="kylefox" width="128" />|<img src="https://avatars.githubusercontent.com/traels?s=256" alt="traels" width="128" />|<img src="https://avatars.githubusercontent.com/rainerborene?s=256" alt="rainerborene" width="128" />|<img src="https://avatars.githubusercontent.com/jcoyne?s=256" alt="jcoyne" width="128" />|
964
+ |:---:|:---:|:---:|:---:|:---:|
965
+ |@rdavid1099|@kylefox|@traels|@rainerborene|@jcoyne|
966
+ |Los Angeles|Edmonton|Odense, Denmark|Brazil|Minneapolis|
967
+
968
+ |<img src="https://avatars.githubusercontent.com/elia?s=256" alt="elia" width="128" />|<img src="https://avatars.githubusercontent.com/cesariouy?s=256" alt="cesariouy" width="128" />|<img src="https://avatars.githubusercontent.com/spdawson?s=256" alt="spdawson" width="128" />|<img src="https://avatars.githubusercontent.com/rmacklin?s=256" alt="rmacklin" width="128" />|<img src="https://avatars.githubusercontent.com/michaelem?s=256" alt="michaelem" width="128" />|
969
+ |:---:|:---:|:---:|:---:|:---:|
970
+ |@elia|@cesariouy|@spdawson|@rmacklin|@michaelem|
971
+ |Milan||United Kingdom||Berlin|
972
+
973
+ |<img src="https://avatars.githubusercontent.com/mellowfish?s=256" alt="mellowfish" width="128" />|<img src="https://avatars.githubusercontent.com/horacio?s=256" alt="horacio" width="128" />|<img src="https://avatars.githubusercontent.com/dukex?s=256" alt="dukex" width="128" />|<img src="https://avatars.githubusercontent.com/dark-panda?s=256" alt="dark-panda" width="128" />|<img src="https://avatars.githubusercontent.com/smashwilson?s=256" alt="smashwilson" width="128" />|
974
+ |:---:|:---:|:---:|:---:|:---:|
975
+ |@mellowfish|@horacio|@dukex|@dark-panda|@smashwilson|
976
+ |Spring Hill, TN|Buenos Aires|São Paulo||Gambrills, MD|
977
+
978
+ |<img src="https://avatars.githubusercontent.com/blakewilliams?s=256" alt="blakewilliams" width="128" />|<img src="https://avatars.githubusercontent.com/seanpdoyle?s=256" alt="seanpdoyle" width="128" />|<img src="https://avatars.githubusercontent.com/tclem?s=256" alt="tclem" width="128" />|<img src="https://avatars.githubusercontent.com/nashby?s=256" alt="nashby" width="128" />|<img src="https://avatars.githubusercontent.com/jaredcwhite?s=256" alt="jaredcwhite" width="128" />|
979
+ |:---:|:---:|:---:|:---:|:---:|
980
+ |@blakewilliams|@seanpdoyle|@tclem|@nashby|@jaredcwhite|
981
+ |Boston, MA|New York, NY|San Francisco, CA|Minsk|Portland, OR|
982
+
983
+ |<img src="https://avatars.githubusercontent.com/simonrand?s=256" alt="simonrand" width="128" />|<img src="https://avatars.githubusercontent.com/fugufish?s=256" alt="fugufish" width="128" />|<img src="https://avatars.githubusercontent.com/cover?s=256" alt="cover" width="128" />|<img src="https://avatars.githubusercontent.com/franks921?s=256" alt="franks921" width="128" />|<img src="https://avatars.githubusercontent.com/fsateler?s=256" alt="fsateler" width="128" />|
984
+ |:---:|:---:|:---:|:---:|:---:|
985
+ |@simonrand|@fugufish|@cover|@franks921|@fsateler|
986
+ |Dublin, Ireland|Salt Lake City, Utah|Barcelona|South Africa|Chile|
987
+
988
+ |<img src="https://avatars.githubusercontent.com/maxbeizer?s=256" alt="maxbeizer" width="128" />|<img src="https://avatars.githubusercontent.com/franco?s=256" alt="franco" width="128" />|<img src="https://avatars.githubusercontent.com/tbroad-ramsey?s=256" alt="tbroad-ramsey" width="128" />|<img src="https://avatars.githubusercontent.com/jensljungblad?s=256" alt="jensljungblad" width="128" />|<img src="https://avatars.githubusercontent.com/bbugh?s=256" alt="bbugh" width="128" />|
989
+ |:---:|:---:|:---:|:---:|:---:|
990
+ |@maxbeizer|@franco|@tbroad-ramsey|@jensljungblad|@bbugh|
991
+ |Nashville, TN|Switzerland|Spring Hill, TN|New York, NY|Austin, TX|
992
+
993
+ |<img src="https://avatars.githubusercontent.com/johannesengl?s=256" alt="johannesengl" width="128" />|<img src="https://avatars.githubusercontent.com/czj?s=256" alt="czj" width="128" />|<img src="https://avatars.githubusercontent.com/mrrooijen?s=256" alt="mrrooijen" width="128" />|
994
+ |:---:|:---:|:---:|
995
+ |@johannesengl|@czj|@mrrooijen|
996
+ |Berlin, Germany|Paris, France|The Netherlands|
997
+
998
+ ## License
999
+
1000
+ ViewComponent is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).