view_component-contrib 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6b4beca0bdcc956bb6937571139c55c91f6e7978181b63a955f6120ab480628
4
- data.tar.gz: 4c4b49be24cde772d325ebd17b9a7a5050cf4b75ff272ca91bdd93637a5d834b
3
+ metadata.gz: 1df61bb2350983380af49ecddc12cb3e6e5f90b5b05bdc66466a40064c435508
4
+ data.tar.gz: 7b03a2c077b2c54cd16deb8720c3d878aa1a0a2d7e1240566aaa4c42fe4e616d
5
5
  SHA512:
6
- metadata.gz: 347e50fc7849d0758aa2fcf410494d1db98a06e557d5931e055bfc7675e15e440413beab64c91163a468f409a03a7f7b222c9ddc83db0a83586c94e97aca343b
7
- data.tar.gz: 9bd92b191093858c794e47742b680f913832db4dff38c7bbd1c348605d31d897c6f11cebfdf4d3201032aa05965a28a5a6d51c2feebf8b2b43f58336e42bb038
6
+ metadata.gz: 7ad12d739fad8a74bed8d299212ad41667021cd535f98bbd2a86055808a0f321b8a5919f11ca214f5bb4fc6cbff8199f19a1026d147eea92f2173986aaebcaaa
7
+ data.tar.gz: 01724fd258b05e894635ca0a438e45a0369160dc74645f7cd675eeae8eba0157f09f56e000951b0b2b7d3829582ff27950e8219f017a14b1f14252fbfdaadf39
data/CHANGELOG.md CHANGED
@@ -2,4 +2,8 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.1.0 (2021-04-07)
6
+
7
+ - Initial release.
8
+
5
9
  [@palkan]: https://github.com/palkan
data/README.md CHANGED
@@ -1,43 +1,475 @@
1
- [![Gem Version](https://badge.fury.io/rb/view_component-contrib.svg)](https://rubygems.org/gems/view_component-contrib) [![Build](https://github.com/palkan/view_component-contrib/workflows/Build/badge.svg)](https://github.com/palkan/view_component-contrib/actions)
2
- [![JRuby Build](https://github.com/palkan/view_component-contrib/workflows/JRuby%20Build/badge.svg)](https://github.com/palkan/view_component-contrib/actions)
1
+ [![Gem Version](https://badge.fury.io/rb/view_component-contrib.svg)](https://rubygems.org/gems/view_component-contrib)
2
+ [![Build](https://github.com/palkan/view_component-contrib/workflows/Build/badge.svg)](https://github.com/palkan/view_component-contrib/actions)
3
3
 
4
- # View Component Contrib
4
+ # View Component: extensions, examples and development tools
5
5
 
6
- TBD
6
+ This repository contains various code snippets and examples related to the [ViewComponent][] library. The goal of this project is to share common patterns and practices which we found useful while working on different projects (and which haven't been or couldn't be proposed to the upstream).
7
7
 
8
- ## Installation
8
+ All extensions and patches are packed into a `view_component-contrib` _meta-gem_. So, to use them add to your Gemfile:
9
9
 
10
- Adding to a gem:
10
+ ```ruby
11
+ gem "view_component-contrib"
12
+ ```
13
+
14
+ <a href="https://evilmartians.com/">
15
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
16
+
17
+ ## Installation and generating generators
18
+
19
+ **NOTE:** We highly recommend to walk through this document before running the generator.
20
+
21
+ The easiest way to start using `view_component-contrib` extensions and patterns is to run an interactive generator (a custom [Rails template][railsbytes-template]).
22
+
23
+ All you need to do is to run:
24
+
25
+ ```sh
26
+ rails app:template LOCATION="https://railsbytes.com/script/zJosO5"
27
+ ```
28
+
29
+ The command above:
30
+
31
+ - Installs `view_component-contrib` gem.
32
+ - Configure `view_component` paths.
33
+ - Adds `ApplicationViewComponent` and `ApplicationViewComponentPreview` classes.
34
+ - Configures testing framework (RSpec or Minitest).
35
+ - Adds required JS/CSS configuration.
36
+ - **Adds a custom generator to create components**.
37
+
38
+ The custom generator would allow you to create all the required component files in a single command:
39
+
40
+ ```sh
41
+ bundle exec rails g view_component Example
42
+
43
+ # see all available options
44
+ bundle exec rails g view_component -h
45
+ ```
46
+
47
+ **Why adding a custom generator to the project instead of bundling it into the gem?** The generator could only be useful if it fits
48
+ your project needs. The more control you have over the generator the better. Thus, the best way is to make the generator a part of a project.
49
+
50
+ ## Organizing components, or sidecar pattern extended
51
+
52
+ ViewComponent provides different ways to organize your components: putting everyhing (Ruby files, templates, etc.) into `app/components` folder or using a _sidecar_ directory for everything but the `.rb` file itself. The first approach could easily result in a directory bloat; the second is better though there is a room for improvement: we can move `.rb` files into sidecar folders as well. Then, we can get rid of the _noisy_ `_component` suffixes. Finally, we can also put previews there (since storing them within the test folder is a little bit confusing):
53
+
54
+ ```txt
55
+ components/ components/
56
+ example_component/ example/
57
+ example_component.html component.html
58
+ example_component.rb → component.rb
59
+ test/ preview.rb
60
+ components/ index.css
61
+ previews/ index.js
62
+ example_component_preview.rb
63
+ ```
64
+
65
+ Thus, everything related to a particular component (except tests, at least for now) is located within a single folder.
66
+
67
+ The two base classes are added to follow the Rails way: `ApplicationViewComponent` and `ApplicationViewComponentPreview`.
68
+
69
+ We also put the `components` folder into the `app/frontend` folder, because `app/components` is too general and could be used for other types of components, not related to the view layer.
70
+
71
+ Here is an example Rails configuration:
11
72
 
12
73
  ```ruby
13
- # my-cool-gem.gemspec
14
- Gem::Specification.new do |spec|
15
- # ...
16
- spec.add_dependency "view_component-contrib"
17
- # ...
74
+ config.autoload_paths << Rails.root.join("app", "frontend", "components")
75
+ ```
76
+
77
+ ### Organizing previews
78
+
79
+ First, we need to specify the lookup path for previews in the app's configuration:
80
+
81
+ ```ruby
82
+ config.view_component.preview_paths << Rails.root.join("app", "frontend", "components")
83
+ ```
84
+
85
+ By default, ViewComponent requires preview files to have `_preview.rb` suffix, and it's not configurable (yet). To overcome this, we have to patch the `ViewComponent::Preview` class:
86
+
87
+ ```ruby
88
+ # you can put this into an initializer
89
+ ActiveSupport.on_load(:view_component) do
90
+ ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable
18
91
  end
19
92
  ```
20
93
 
21
- Or adding to your project:
94
+ #### Reducing previews boilerplate
95
+
96
+ In most cases, previews contain only the `default` example and a very simple template (`= render Component.new(**options)`).
97
+ We provide a `ViewComponentContrib::Preview` class, which helps to reduce the boilerplate by re-using templates and providing a handful of helpers.
98
+
99
+ The default template shipped with the gem is as follows:
100
+
101
+ ```html
102
+ <div class="<%= container_class %>">
103
+ <%= render component %>
104
+ </div>
105
+ ```
106
+
107
+ Let's assume that you have the following `ApplicationViewComponentPreview`:
22
108
 
23
109
  ```ruby
24
- # Gemfile
25
- gem "view_component-contrib"
110
+ class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
111
+ # Do not show this class in the previews index
112
+ self.abstract_class = true
113
+ end
26
114
  ```
27
115
 
28
- ### Supported Ruby versions
116
+ It allows to render a component instances within a configurable container. The component could be either created explicitly in the preview action:
29
117
 
30
- - Ruby (MRI) >= 2.5.0
31
- - JRuby >= 9.2.9
118
+ ```ruby
119
+ class Banner::Preview < ApplicationViewComponentPreview
120
+ def default
121
+ render_component Banner::Component.new(text: "Welcome!")
122
+ end
123
+ end
124
+ ```
125
+
126
+ Or implicitly:
127
+
128
+ ```ruby
129
+ class LikeButton::Preview < ApplicationViewComponentPreview
130
+ def default
131
+ # Nothing here; the preview class would try to build a component automatically
132
+ # calling `LikeButton::Component.new`
133
+ end
134
+ end
135
+ ```
136
+
137
+ To provide the container class, you should either specify it in the preview class itself or within a particular action by calling `#render_with`:
138
+
139
+ ```ruby
140
+ class Banner::Preview < ApplicationViewComponentPreview
141
+ self.container_class = "absolute w-full"
142
+
143
+ def default
144
+ # This will use `absolute w-full` for the container class
145
+ render_component Banner::Component.new(text: "Welcome!")
146
+ end
147
+
148
+ def mobile
149
+ render_with(
150
+ component: Banner::Component.new(text: "Welcome!").with_variant(:mobile),
151
+ container_class: "w-25"
152
+ )
153
+ end
154
+ end
155
+ ```
156
+
157
+ If you need more control over your template, you can add a custom `preview.html.erb` file.
158
+ **NOTE:** We assume that all examples uses the same `preview.html`. If it's not the case,
159
+ you can use the original `#render_with_template` method.
160
+
161
+ ## Organizing assets (JS, CSS)
162
+
163
+ **NOTE*: This section assumes the usage of Webpack, Vite or other _frontend_ builder (e.g., not Sprockets).
164
+
165
+ We store JS and CSS files in the same sidecar folder:
166
+
167
+ ```txt
168
+ components/
169
+ example/
170
+ component.html
171
+ component.rb
172
+ index.css
173
+ index.js
174
+ ```
175
+
176
+ The `index.js` is the controller's entrypoint; it imports the CSS file and may contain some JS code:
177
+
178
+ ```js
179
+ import "./index.css"
180
+ ```
181
+
182
+ In the root of the `components` folder we have the `index.js` file, which loads all the components:
183
+
184
+ ```js
185
+ // components/index.js
186
+ const context = require.context(".", true, /index.js$/)
187
+ context.keys().forEach(context);
188
+ ```
189
+
190
+ ### Using with StimulusJS
191
+
192
+ You can define Stimulus controllers right in the `index.js` file using the following approach:
193
+
194
+ ```js
195
+ import "./index.css"
196
+ // We reserve Controller for the export name
197
+ import { Controller as BaseController } from "stimulus";
198
+
199
+ export class Controller extends BaseController {
200
+ connect() {
201
+ // ...
202
+ }
203
+ }
204
+ ```
205
+
206
+ Then, we need to update the `components/index.js` to automatically register controllers:
207
+
208
+ ```js
209
+ // We recommend putting Stimulus application instance into its own
210
+ // module, so you can use it for non-component controllers
32
211
 
33
- ## Usage
212
+ // init/stimulus.js
213
+ import { Application } from "stimulus";
214
+ export const application = Application.start();
34
215
 
35
- TBD
216
+ // components/index.js
217
+ import { application } from "../init/stimulus";
36
218
 
37
- ## Contributing
219
+ const context = require.context(".", true, /index.js$/)
220
+ context.keys().forEach((path) => {
221
+ const mod = context(path);
222
+
223
+ // Check whether a module has the Controller export defined
224
+ if (!mod.Controller) return;
225
+
226
+ // Convert path into a controller identifier:
227
+ // example/index.js -> example
228
+ // nav/user_info/index.js -> nav--user-info
229
+ const identifier = path.replace(/^\.\//, '')
230
+ .replace(/\/index\.js$/, '')
231
+ .replace(/\//, '--');
232
+
233
+ application.register(identifier, mod.Controller);
234
+ });
235
+ ```
236
+
237
+ We also can add a helper to our base ViewComponent class to generate the controller identifier following the convention above:
238
+
239
+ ```ruby
240
+ class ApplicationViewComponent
241
+ private
242
+
243
+ def identifier
244
+ @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--")
245
+ end
246
+ end
247
+ ```
38
248
 
39
- Bug reports and pull requests are welcome on GitHub at [https://github.com/palkan/view_component-contrib](https://github.com/palkan/view_component-contrib).
249
+ And now in your template:
250
+
251
+ ```erb
252
+ <!-- component.html -->
253
+ <div data-controller="<%= identifier %>">
254
+ </div>
255
+ ```
256
+
257
+ ### Isolating CSS with postcss-modules
258
+
259
+ Our JS code is isolated by design but our CSS is still global. Hence we should care about naming, use some convention (such as BEM) or whatever.
260
+
261
+ Alternatively, we can leverage the power of modern frontend technologies such as [CSS modules][] via [postcss-modules][] plugin. It allows you to use _local_ class names in your component, and takes care of generating unique names in build time. We can configure PostCSS Modules to follow our naming convention, so, we can generate the same unique class names in both JS and Ruby.
262
+
263
+ First, install the `postcss-modules` plugin (`yarn add postcss-modules`).
264
+
265
+ Then, add the following to your `postcss.config.js`:
266
+
267
+ ```js
268
+ module.exports = {
269
+ plugins: {
270
+ 'postcss-modules': {
271
+ generateScopedName: (name, filename, _css) => {
272
+ const matches = filename.match(/\/app\/frontend\/components\/?(.*)\/index.css$/);
273
+ // Do not transform CSS files from outside of the components folder
274
+ if (!matches) return name;
275
+
276
+ // identifier here is the same identifier we used for Stimulus controller (see above)
277
+ const identifier = matches[1].replace("/", "--");
278
+
279
+ // We also add the `c-` prefix to all components classes
280
+ return `c-${identifier}-${name}`;
281
+ },
282
+ // Do not generate *.css.json files (we don't use them)
283
+ getJSON: () => {}
284
+ },
285
+ /// other plugins
286
+ },
287
+ }
288
+ ```
289
+
290
+ Finally, let's add a helper to our view components:
291
+
292
+ ```ruby
293
+ class ApplicationViewComponent
294
+ private
295
+
296
+ # the same as above
297
+ def identifier
298
+ @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--")
299
+ end
300
+
301
+ # We also add an ability to build a class from a different component
302
+ def class_for(name, from: identifier)
303
+ "c-#{from}-#{name}"
304
+ end
305
+ end
306
+ ```
307
+
308
+ And now in your template:
309
+
310
+ ```erb
311
+ <!-- example/component.html -->
312
+ <div class="<%= class_for("container") %>">
313
+ <p class="<%= class_for("body") %>"><%= text %></p>
314
+ </div>
315
+ ```
316
+
317
+ Assuming that you have the following `index.css`:
318
+
319
+ ```css
320
+ .container {
321
+ padding: 10px;
322
+ background: white;
323
+ border: 1px solid #333;
324
+ }
325
+
326
+ .body {
327
+ margin-top: 20px;
328
+ font-size: 24px;
329
+ }
330
+ ```
331
+
332
+ The final HTML output would be:
333
+
334
+ ```html
335
+ <div class="c-example-container">
336
+ <p class="c-example-body">Some text</p>
337
+ </div>
338
+ ```
339
+
340
+ ## I18n integration (alternative)
341
+
342
+ ViewComponent recently added (experimental) [I18n support](https://github.com/github/view_component/pull/660), which allows you to have **isolated** localization files for each component. Isolation rocks, but managing dozens of YML files spread accross the project could be tricky, especially, if you rely on some external localization tool which creates these YMLs for you.
343
+
344
+ We provide an alternative (and more _classic_) way of dealing with translations—**namespacing**. Following the convention over configuration,
345
+ put translations under `<locale>.view_components.<component_scope>` key, for example:
346
+
347
+ ```yml
348
+ en:
349
+ view_components:
350
+ login_form:
351
+ submit: "Log in"
352
+ nav:
353
+ user_info:
354
+ login: "Log in"
355
+ logout: "Log out"
356
+ ```
357
+
358
+ And then in your components:
359
+
360
+ ```erb
361
+ <!-- login_form/component.html.erb -->
362
+ <button type="submit"><%= t(".submit") %></button>
363
+
364
+ <!-- nav/user_info/component.html.erb -->
365
+ <a href="/logout"><%= t(".logout") %></a>
366
+ ```
367
+
368
+ If you're using `ViewComponentContrib::Base`, you already have translation support included.
369
+ Othwerwise you must include the module yourself:
370
+
371
+ ```ruby
372
+ class ApplicationViewComponent < ViewComponent::Base
373
+ include ViewComponentContrib::TranslationHelper
374
+ end
375
+ ```
376
+
377
+ You can override the default namespace (`view_components`) and a particular component _scope_:
378
+
379
+ ```ruby
380
+ class ApplicationViewComponent < ViewComponentContrib::Base
381
+ self.i18n_namespace = "my_components"
382
+ end
383
+
384
+ class SomeButton::Component < ApplicationViewComponent
385
+ self.i18n_scope = %w[legacy button]
386
+ end
387
+ ```
388
+
389
+ ## Hanging `#initialize` out to Dry
390
+
391
+ One way to improve development experience with ViewComponent is to move from imperative `#initialize` to something declarative.
392
+ Our choice is [dry-initializer][].
393
+
394
+ Assuming that we have the following component:
395
+
396
+ ```ruby
397
+ class FlashAlert::Component < ApplicationViewComponent
398
+ attr_reader :type, :duration, :body
399
+
400
+ def initialize(body:, type: "success", duration: 3000)
401
+ @body = body
402
+ @type = type
403
+ @duration = duration
404
+ end
405
+ end
406
+ ```
407
+
408
+ Let's add `dry-initializer` to our base class:
409
+
410
+ ```ruby
411
+ class ApplicationViewComponent
412
+ extend Dry::Initializer
413
+ end
414
+ ```
415
+
416
+ And then refactor our FlashAlert component:
417
+
418
+ ```ruby
419
+ class FlashAlert::Component < ApplicationViewComponent
420
+ option :type, default: proc { "success" }
421
+ option :duration, default: proc { 3000 }
422
+ option :body
423
+ end
424
+ ```
425
+
426
+ ## Wrapped components
427
+
428
+ Sometimes we need to wrap a component into a custom HTML container (for positioning or whatever). By default, such wrapping doesn't play well with the `#render?` method because if we don't need a component, we don't need a wrapper.
429
+
430
+ To solve this problem, we introduce a special `ViewComponentContrib::WrapperComponent` class: it takes any component as the only argument and accepts a block during rendering to define a wrapping HTML. And it renders only if the _inner component_'s `#render?` method returns true.
431
+
432
+ ```erb
433
+ <%= render ViewComponentContrib::WrappedComponent.new(Example::Component.new) do |wrapper| %>
434
+ <div class="col-md-auto mb-4">
435
+ <%= wrapper.component %>
436
+ </div>
437
+ <%- end -%>
438
+ ```
439
+
440
+ You can add a `#wrapped` method to your base class to simplify the code above:
441
+
442
+ ```ruby
443
+ class ApplicationViewComponent < ViewComponent::Base
444
+ # adds #wrapped method
445
+ # NOTE: Already included into ViewComponentContrib::Base
446
+ include ViewComponentContrib::WrappedHelper
447
+ end
448
+ ```
449
+
450
+ And the template looks like this now:
451
+
452
+ ```erb
453
+ <%= render Example::Component.new.wrapped do |wrapper| %>
454
+ <div class="col-md-auto mb-4">
455
+ <%= wrapper.component %>
456
+ </div>
457
+ <%- end -%>
458
+ ```
459
+
460
+ You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically:
461
+
462
+ ## ToDo list
463
+
464
+ - Better preview tools (w/o JS deps 😉).
465
+ - Hotwire-related extensions.
40
466
 
41
467
  ## License
42
468
 
43
469
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
470
+
471
+ [ViewComponent]: https://github.com/github/view_component
472
+ [postcss-modules]: https://github.com/madyankin/postcss-modules
473
+ [CSS modules]: https://github.com/css-modules/css-modules
474
+ [dry-initializer]: https://dry-rb.org/gems/dry-initializer
475
+ [railsbytes-template]: https://railsbytes.com/templates/zJosO5