├── .gem_release.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── edge_test.yml │ ├── publish.yml │ ├── release.yml │ ├── rubocop.yml │ └── test.yml ├── .gitignore ├── .mdlrc ├── .rbnextrc ├── .rubocop-md.yml ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── Makefile ├── README.md ├── RELEASING.md ├── Rakefile ├── app └── views │ └── view_component_contrib │ └── preview.html.erb ├── gemfiles ├── rails6.gemfile ├── rails7.gemfile ├── rails8.gemfile ├── railsmaster.gemfile ├── rubocop.gemfile └── view_component_master.gemfile ├── lib ├── view_component-contrib.rb ├── view_component_contrib.rb └── view_component_contrib │ ├── base.rb │ ├── preview.rb │ ├── preview │ ├── abstract.rb │ ├── base.rb │ ├── default_template.rb │ └── sidecarable.rb │ ├── railtie.rb │ ├── style_variants.rb │ ├── translation_helper.rb │ ├── version.rb │ ├── wrapped_helper.rb │ └── wrapper_component.rb ├── templates └── install │ ├── application_view_component.rb │ ├── application_view_component_preview.rb │ ├── generator.rb │ ├── identifier.rb │ ├── initializer.rb │ └── template.rb ├── test ├── cases │ ├── i18n_sidecar_test.rb │ ├── i18n_sidecar_test │ │ └── component.yml │ ├── i18n_test.rb │ ├── previews_test.rb │ ├── style_variants_test.rb │ └── wrapped_test.rb ├── fixtures │ └── basic_rails_app │ │ ├── Gemfile │ │ ├── Rakefile │ │ ├── bin │ │ └── rails │ │ ├── config.ru │ │ └── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ └── development.rb │ │ └── initializers │ │ └── .keep ├── internal │ └── app │ │ └── frontend │ │ ├── components │ │ ├── application_view_component_preview.rb │ │ ├── banner │ │ │ ├── component.rb │ │ │ └── preview.rb │ │ ├── button │ │ │ ├── component.html.erb │ │ │ └── component.rb │ │ └── custom_banner │ │ │ ├── component.rb │ │ │ ├── preview.html.erb │ │ │ ├── preview.rb │ │ │ └── previews │ │ │ └── example.html.erb │ │ └── previews │ │ └── button_preview.rb ├── template │ └── template_test.rb └── test_helper.rb └── view_component-contrib.gemspec /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | file: lib/view_component-contrib/version.rb 3 | skip_ci: true 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: palkan 7 | 8 | --- 9 | 10 | ## What did you do? 11 | 12 | ## What did you expect to happen? 13 | 14 | ## What actually happened? 15 | 16 | ## Additional context 17 | 18 | ## Environment 19 | 20 | **Ruby Version:** 21 | 22 | **Framework Version (Rails, whatever):** 23 | 24 | **View Component Contrib Version:** 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: palkan 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ## Describe the solution you'd like 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ## Describe alternatives you've considered 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | ## Additional context 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## What is the purpose of this pull request? 8 | 9 | 14 | 15 | ## What changes did you make? (overview) 16 | 17 | ## Is there anything you'd like reviewers to focus on? 18 | 19 | ## Checklist 20 | 21 | - [ ] I've added tests for this change 22 | - [ ] I've added a Changelog entry 23 | - [ ] I've updated a documentation 24 | -------------------------------------------------------------------------------- /.github/workflows/edge_test.yml: -------------------------------------------------------------------------------- 1 | name: Edge Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | schedule: 10 | - cron: "10 4 * * */2" 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | env: 16 | BUNDLE_JOBS: 4 17 | BUNDLE_RETRY: 3 18 | BUNDLE_GEMFILE: gemfiles/view_component_master.gemfile 19 | CI: true 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | ruby: ["3.3", "3.4"] 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby }} 29 | bundler-cache: true 30 | - name: Run Ruby Next 31 | run: bundle exec rake nextify 32 | - name: Run tests 33 | run: | 34 | bundle exec rake test:isolated 35 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | uses: palkan/rbytes/.github/workflows/railsbytes.yml@master 12 | with: 13 | template: templates/install/template.rb 14 | secrets: 15 | RAILS_BYTES_ACCOUNT_ID: "${{ secrets.RAILS_BYTES_ACCOUNT_ID }}" 16 | RAILS_BYTES_TOKEN: "${{ secrets.RAILS_BYTES_TOKEN }}" 17 | RAILS_BYTES_TEMPLATE_ID: zJosO5 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release gems 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.2 22 | bundler-cache: true 23 | - name: Configure RubyGems Credentials 24 | uses: rubygems/configure-rubygems-credentials@main 25 | - name: Publish to RubyGems 26 | run: | 27 | gem install gem-release 28 | make ci-release 29 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Lint Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_GEMFILE: gemfiles/rubocop.gemfile 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.1 19 | bundler-cache: true 20 | - name: Lint Ruby code with RuboCop 21 | run: | 22 | bundle exec rubocop 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 16 | CI: true 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | ruby: ["3.0"] 21 | gemfile: ["gemfiles/rails7.gemfile"] 22 | include: 23 | - ruby: "3.4" 24 | gemfile: "gemfiles/railsmaster.gemfile" 25 | - ruby: "3.3" 26 | gemfile: "gemfiles/rails8.gemfile" 27 | - ruby: "2.7" 28 | gemfile: "gemfiles/rails6.gemfile" 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: ${{ matrix.ruby }} 34 | bundler-cache: true 35 | - name: Run Ruby Next 36 | run: bundle exec rake nextify 37 | - name: Run tests 38 | run: | 39 | bundle exec rake test:isolated 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | 15 | # Sublime 16 | *.sublime-project 17 | *.sublime-workspace 18 | 19 | # OS or Editor folders 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | Thumbs.db 26 | 27 | .bundle/ 28 | log/*.log 29 | pkg/ 30 | spec/dummy/db/*.sqlite3 31 | spec/dummy/db/*.sqlite3-journal 32 | spec/dummy/tmp/ 33 | 34 | Gemfile.lock 35 | Gemfile.local 36 | .rspec 37 | .ruby-version 38 | *.gem 39 | 40 | tmp/ 41 | .rbnext/ 42 | 43 | gemfiles/*.lock 44 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD029", "~MD034" 2 | -------------------------------------------------------------------------------- /.rbnextrc: -------------------------------------------------------------------------------- 1 | nextify: | 2 | ./lib 3 | --min-version=2.7 4 | --edge 5 | --proposed 6 | -------------------------------------------------------------------------------- /.rubocop-md.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ".rubocop.yml" 2 | 3 | require: 4 | - rubocop-md 5 | 6 | AllCops: 7 | Include: 8 | - '**/*.md' 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard/cop/block_single_line_braces 3 | 4 | inherit_gem: 5 | standard: config/base.yml 6 | 7 | AllCops: 8 | Exclude: 9 | - 'bin/*' 10 | - 'tmp/**/*' 11 | - 'Gemfile' 12 | - 'vendor/**/*' 13 | - 'gemfiles/**/*' 14 | - 'lib/.rbnext/**/*' 15 | - 'lib/generators/**/templates/*.rb' 16 | - '.github/**/*' 17 | - 'templates/**/*' 18 | - 'test/fixtures/basic_rails_app/**/*' 19 | DisplayCopNames: true 20 | SuggestExtensions: false 21 | TargetRubyVersion: 3.0 22 | 23 | Standard/BlockSingleLineBraces: 24 | Enabled: false 25 | 26 | Style/FrozenStringLiteralComment: 27 | Enabled: true 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master 4 | 5 | ## 0.2.4 (2025-01-03) 6 | 7 | - Add inheritance strategies to style variants ([@omarluq][]) 8 | 9 | - Add special `class:` variant to `style` helper. For appending classes. 10 | Inspired by https://cva.style/docs/getting-started/extending-components 11 | 12 | ## 0.2.3 (2024-07-31) 13 | 14 | - Fix publishing transpiled files (and bring Ruby 2.7 support back) ([@palkan][]) 15 | 16 | ## 0.2.2 (2023-11-29) 17 | 18 | - Add `compound` styles support. ([@palkan][]) 19 | 20 | - Support using booleans as style variant values. ([@palkan][]) 21 | 22 | ## 0.2.1 (2023-11-16) 23 | 24 | - Fix style variants inhertiance. ([@palkan][]) 25 | 26 | ## 0.2.0 (2023-11-07) 27 | 28 | - Introduce style variants. ([@palkan][]) 29 | 30 | - **Require Ruby 2.7+**. ([@palkan][]) 31 | 32 | - Add system tests to generator. ([@palkan][]) 33 | 34 | - Drop Webpack-related stuff from the generator. ([@palkan][]) 35 | 36 | ## 0.1.6 (2023-11-07) 37 | 38 | - Support preview classes named `_preview.rb`. ([@palkan][]) 39 | 40 | It's also possible to explicitly specify the component class name for the preview class: 41 | 42 | ```ruby 43 | class MyComponentPreview 44 | self.component_class_name = "SomeComponent" 45 | 46 | def default 47 | render_component 48 | end 49 | end 50 | ``` 51 | 52 | ## 0.1.5 (2023-11-02) 53 | 54 | - Support content blocks in `#render_component` and `#render_with`. ([@palkan][]) 55 | 56 | ```ruby 57 | class MyComponent::Preview 58 | def default 59 | # Now you can pass a block to render_component to render it inside the component: 60 | render_component(kind: "info") do 61 | "Welcome!" 62 | end 63 | end 64 | end 65 | ``` 66 | 67 | - Support implicit components in `#render_component` helper. ([@palkan][]) 68 | 69 | ```ruby 70 | class MyComponent::Preview 71 | def default 72 | # Before 73 | render_component(MyComponent::Component.new(foo: "bar")) 74 | end 75 | 76 | # After 77 | def default 78 | render_component(foo: "bar") 79 | end 80 | end 81 | ``` 82 | 83 | ## 0.1.4 (2023-04-30) 84 | 85 | - Fix compatibility with new errors classes in view_component. 86 | 87 | See [view_component#1701](https://github.com/ViewComponent/view_component/pull/1701). 88 | 89 | ## 0.1.3 (2023-02-02) 90 | 91 | - Fix release dependencies ([@palkan][]) 92 | 93 | ## 0.1.2 (2023-01-13) 94 | 95 | - Fix compatibility with sidecar translations. ([@palkan][]) 96 | 97 | - Detect Webpack when using Rails 7 + jsbundling-rails. ([@unikitty37][]) 98 | 99 | - Skip autoloading of Preview files when viewing previews is disabled. ([@dhnaranjo][]) 100 | 101 | - Automatic publish to RailsBytes in CI. ([@fargelus][]) 102 | 103 | ## 0.1.1 (2022-03-14) 104 | 105 | - Fix adding gem's previews to the app's path. ([@palkan][]) 106 | 107 | - Fix configurable default template. 108 | 109 | ## 0.1.0 (2021-04-07) 110 | 111 | - Initial release. 112 | 113 | [@palkan]: https://github.com/palkan 114 | [@fargelus]: https://github.com/fargelus 115 | [@dhnaranjo]: https://github.com/dhnaranjo 116 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "debug", platform: :mri 6 | 7 | gem "rbytes" if RUBY_VERSION >= "3.0.0" 8 | 9 | gemspec 10 | 11 | eval_gemfile "gemfiles/rubocop.gemfile" 12 | 13 | local_gemfile = "#{File.dirname(__FILE__)}/Gemfile.local" 14 | 15 | if File.exist?(local_gemfile) 16 | eval(File.read(local_gemfile)) # rubocop:disable Security/Eval 17 | else 18 | gem "rails", "~> 7.0" 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Vladimir Dementyev 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | nextify: 4 | bundle exec rake nextify 5 | 6 | test: nextify 7 | bundle exec rake 8 | CI=true bundle exec rake 9 | 10 | lint: 11 | bundle exec rubocop 12 | 13 | release: test lint 14 | git status 15 | RELEASING_GEM=true gem release -t 16 | git push 17 | git push --tags 18 | 19 | ci-release: test lint 20 | RELEASING_GEM=true gem release 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | 4 | # View Component: extensions, examples and development tools 5 | 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 | 8 | All extensions and patches are packed into a `view_component-contrib` _meta-gem_. So, to use them add to your Gemfile: 9 | 10 | ```ruby 11 | gem "view_component-contrib" 12 | ``` 13 | 14 | 15 | Sponsored by Evil Martians 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 a custom generator to create components**. 36 | 37 | The custom generator would allow you to create all the required component files in a single command: 38 | 39 | ```sh 40 | bundle exec rails g view_component Example 41 | 42 | # see all available options 43 | bundle exec rails g view_component -h 44 | ``` 45 | 46 | **Why adding a custom generator to the project instead of bundling it into the gem?** The generator could only be useful if it fits 47 | 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. 48 | 49 | > [!IMPORTANT] 50 | > If your application has the `lib/` folder in the autoload paths, make sure you ignored the generated `lib/generators` folder. In Rails 7.1+, you can do this via adding `generators` the `config.autoload_lib` call's `ignore` option. Before, you can use `Rails.autoloaders.main.ignore(...)`. 51 | 52 | ## Organizing components, or sidecar pattern extended 53 | 54 | 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): 55 | 56 | ```txt 57 | components/ components/ 58 | example_component/ example/ 59 | example_component.html component.html 60 | example_component.rb → component.rb 61 | test/ preview.rb 62 | components/ index.css 63 | previews/ index.js 64 | example_component_preview.rb 65 | ``` 66 | 67 | Thus, everything related to a particular component (except tests, at least for now) is located within a single folder. 68 | 69 | The two base classes are added to follow the Rails way: `ApplicationViewComponent` and `ApplicationViewComponentPreview`. 70 | 71 | 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. 72 | 73 | Here is an example Rails configuration: 74 | 75 | ```ruby 76 | config.autoload_paths << Rails.root.join("app", "frontend", "components") 77 | ``` 78 | 79 | ### Organizing previews 80 | 81 | First, we need to specify the lookup path for previews in the app's configuration: 82 | 83 | ```ruby 84 | config.view_component.preview_paths << Rails.root.join("app", "frontend", "components") 85 | ``` 86 | 87 | 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: 88 | 89 | ```ruby 90 | # you can put this into an initializer 91 | ActiveSupport.on_load(:view_component) do 92 | ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable 93 | end 94 | ``` 95 | 96 | You can still continue using preview clases with the `_preview.rb` suffix, they would work as before. 97 | 98 | #### Reducing previews boilerplate 99 | 100 | In most cases, previews contain only the `default` example and a very simple template (`= render Component.new(**options)`). 101 | We provide a `ViewComponentContrib::Preview` class, which helps to reduce the boilerplate by re-using templates and providing a handful of helpers. 102 | 103 | The default template shipped with the gem is as follows: 104 | 105 | ```erb 106 |
107 | <%- if component -%> 108 | <%= render component %> 109 | <%- else -%> 110 | Failed to infer a component from the preview: <%= error %> 111 | <%- end -%> 112 |
113 | ``` 114 | 115 | To define your own default template: 116 | ```ruby 117 | class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base 118 | # ... 119 | self.default_preview_template = "path/to/your/template.html.{erb,haml,slim}" 120 | # ... 121 | end 122 | ``` 123 | 124 | Let's assume that you have the following `ApplicationViewComponentPreview`: 125 | 126 | ```ruby 127 | class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base 128 | # Do not show this class in the previews index 129 | self.abstract_class = true 130 | end 131 | ``` 132 | 133 | It allows to render a component instances within a configurable container. The component could be either created explicitly in the preview action: 134 | 135 | ```ruby 136 | class Banner::Preview < ApplicationViewComponentPreview 137 | def default 138 | render_component Banner::Component.new(text: "Welcome!") 139 | end 140 | end 141 | ``` 142 | 143 | Or implicitly: 144 | 145 | ```ruby 146 | class LikeButton::Preview < ApplicationViewComponentPreview 147 | def default 148 | # Nothing here; the preview class would try to build a component automatically 149 | # calling `LikeButton::Component.new` 150 | end 151 | end 152 | ``` 153 | 154 | To provide the container class, you should either specify it in the preview class itself or within a particular action by calling `#render_with`: 155 | 156 | ```ruby 157 | class Banner::Preview < ApplicationViewComponentPreview 158 | self.container_class = "absolute w-full" 159 | 160 | def default 161 | # This will use `absolute w-full` for the container class 162 | render_component Banner::Component.new(text: "Welcome!") 163 | 164 | # or even shorter 165 | render_component(text: "Welcome!") 166 | 167 | # you can also pass a content block 168 | render_component(kind: :notice) do 169 | "Some content" 170 | end 171 | end 172 | 173 | def mobile 174 | render_with( 175 | component: Banner::Component.new(text: "Welcome!").with_variant(:mobile), 176 | container_class: "w-25" 177 | ) 178 | end 179 | end 180 | ``` 181 | 182 | If you need more control over your template, you can add a custom `preview.html.*` template (which will be used for all examples in this preview), or even create an example-specific `previews/example.html.*` (e.g. `previews/mobile.html.erb`). 183 | 184 | ## Style variants 185 | 186 | Since v0.2.0, we provide a custom extentions to manage CSS classes and their combinations—**Style Variants**. This is especially useful for project using CSS frameworks such as TailwindCSS. 187 | 188 | The idea is to define variants schema in the component class and use it to compile the resulting list of CSS classes. (Inspired by [Tailwind Variants](https://www.tailwind-variants.org) and [CVA variants](https://cva.style/docs/getting-started/variants)). 189 | 190 | Consider an example: 191 | 192 | ```ruby 193 | class ButtonComponent < ViewComponent::Base 194 | include ViewComponentContrib::StyleVariants 195 | 196 | style do 197 | base { 198 | %w[ 199 | font-medium bg-blue-500 text-white rounded-full 200 | ] 201 | } 202 | variants { 203 | color { 204 | primary { %w[bg-blue-500 text-white] } 205 | secondary { %w[bg-purple-500 text-white] } 206 | } 207 | size { 208 | sm { "text-sm" } 209 | md { "text-base" } 210 | lg { "px-4 py-3 text-lg" } 211 | } 212 | disabled { 213 | yes { "opacity-75" } 214 | } 215 | } 216 | defaults { {size: :md, color: :primary} } 217 | end 218 | 219 | attr_reader :size, :color, :disabled 220 | 221 | def initialize(size: nil, color: nil, disabled: false) 222 | @size = size 223 | @color = color 224 | @disabled = disabled 225 | end 226 | end 227 | ``` 228 | 229 | Now, in the template, you can use the `#style` method and pass the variants to it: 230 | 231 | ```erb 232 | 233 | ``` 234 | 235 | Passing `size: :lg` and `color: :secondary` would result in the following HTML: 236 | 237 | ```html 238 | 239 | ``` 240 | 241 | The `true` / `false` variant value would be converted into the `yes` / `no` variants: 242 | 243 | ```erb 244 | 245 | ``` 246 | 247 | **NOTE:** If you pass `nil`, the default value would be used. 248 | 249 | You can define multiple style sets in a single component: 250 | 251 | ```ruby 252 | class ButtonComponent < ViewComponent::Base 253 | include ViewComponentContrib::StyleVariants 254 | 255 | # default component styles 256 | style do 257 | # ... 258 | end 259 | 260 | style :image do 261 | variants { 262 | orient { 263 | portrait { "w-32 h-32" } 264 | landscape { "w-64 h-32" } 265 | } 266 | } 267 | end 268 | end 269 | ``` 270 | 271 | And in the template: 272 | 273 | ```erb 274 |
275 | 276 | 277 |
278 | ``` 279 | 280 | You can also add additional classes through thr `style` method using the special `class:` variant, like so: 281 | 282 | ```erb 283 |
284 | 285 | 286 |
287 | ``` 288 | 289 | Finally, you can inject into the class list compilation process to add your own logic: 290 | 291 | ```ruby 292 | class ButtonComponent < ViewComponent::Base 293 | include ViewComponentContrib::StyleVariants 294 | 295 | # You can provide either a proc or any other callable object 296 | style_config.postprocess_with do |classes| 297 | # classes is an array of CSS classes 298 | # NOTE: This is an abstract TailwindMerge class, not to be confused with existing libraries 299 | TailwindMerge.call(classes).join(" ") 300 | end 301 | end 302 | ``` 303 | 304 | ### Style variants inheritance 305 | 306 | Style variants support three inheritance strategies when extending components: 307 | 308 | 1. `override` (default behavior): Completely replaces parent variants. 309 | 2. `merge` (deep merge): Preserves all variant keys unless explicitly overwritten. 310 | 3. `extend` (shallow merge): Preserves variants unless explicitly overwritten. 311 | 312 | Consider an example: 313 | 314 | ```ruby 315 | class Parent::Component < ViewComponent::Base 316 | include ViewComponentContrib::StyleVariants 317 | 318 | style do 319 | variants do 320 | size { 321 | md { "text-md" } 322 | lg { "text-lg" } 323 | } 324 | disabled { 325 | yes { "opacity-50" } 326 | } 327 | end 328 | end 329 | end 330 | 331 | # Using override strategy (default) 332 | class Child::Component < Parent::Component 333 | style do 334 | variants do 335 | size { 336 | lg { "text-larger" } 337 | } 338 | end 339 | end 340 | end 341 | 342 | # Using merge strategy 343 | class Child::Component < Parent::Component 344 | style do 345 | variants(strategy: :merge) do 346 | size { 347 | lg { "text-larger" } 348 | } 349 | end 350 | end 351 | end 352 | 353 | # Using extend strategy 354 | class Child::Component < Parent::Component 355 | style do 356 | variants(strategy: :extend) do 357 | size { 358 | lg { "text-larger" } 359 | } 360 | end 361 | end 362 | end 363 | ``` 364 | 365 | In this example, the `override` strategy will only keep the `size.lg` variant, dropping all others. The `merge` strategy preserves all variants and their keys, only replacing the `size.lg` value. The `extend` strategy keeps all variants but replaces all keys of the overwritten `size` variant. 366 | 367 | ### Dependent (or compound) styles 368 | 369 | Sometimes it might be necessary to define complex styling rules, e.g., when a combination of variants requires adding additional styles. That's where usage of Ruby blocks for configuration becomes useful. For example: 370 | 371 | ```ruby 372 | style do 373 | variants { 374 | size { 375 | sm { "text-sm" } 376 | md { "text-base" } 377 | lg { "px-4 py-3 text-lg" } 378 | } 379 | theme { 380 | primary do |size:, **| 381 | %w[bg-blue-500 text-white].tap do 382 | _1 << "uppercase" if size == :lg 383 | end 384 | end 385 | secondary { %w[bg-purple-500 text-white] } 386 | } 387 | } 388 | end 389 | ``` 390 | 391 | The specified variants are passed as block arguments, so you can implement dynamic styling. 392 | 393 | If you prefer declarative approach, you can use the special `compound` directive. The previous example could be rewritten as follows: 394 | 395 | ```ruby 396 | style do 397 | variants { 398 | size { 399 | sm { "text-sm" } 400 | md { "text-base" } 401 | lg { "px-4 py-3 text-lg" } 402 | } 403 | theme { 404 | primary { %w[bg-blue-500 text-white] } 405 | secondary { %w[bg-purple-500 text-white] } 406 | } 407 | } 408 | 409 | compound(size: :lg, theme: :primary) { %w[uppercase] } 410 | end 411 | ``` 412 | 413 | ### Using with TailwindCSS LSP 414 | 415 | To make completions (and other LSP features) work with our DSL, try the following configuration: 416 | 417 | ```json 418 | "tailwindCSS.includeLanguages": { 419 | "erb": "html", 420 | "ruby": "html" 421 | }, 422 | "tailwindCSS.experimental.classRegex": [ 423 | "%w\\[([^\\]]*)\\]" 424 | ] 425 | ``` 426 | 427 | **NOTE:** It will only work with `%w[ ... ]` word arrays, but you can adjust it to your needs. 428 | 429 | ## Organizing assets (JS, CSS) 430 | 431 | **NOTE**: This section assumes the usage of Vite or Webpack. See [this discussion](https://github.com/palkan/view_component-contrib/discussions/14) for other options. 432 | 433 | We store JS and CSS files in the same sidecar folder: 434 | 435 | ```txt 436 | components/ 437 | example/ 438 | component.html 439 | component.rb 440 | index.css 441 | index.js 442 | ``` 443 | 444 | The `index.js` is the controller's entrypoint; it imports the CSS file and may contain some JS code: 445 | 446 | ```js 447 | import "./index.css" 448 | ``` 449 | 450 | In the root of the `components` folder we have the `index.js` file, which loads all the components: 451 | 452 | - With Vite: 453 | 454 | ```js 455 | // With Vite 456 | import.meta.glob("./**/index.js").forEach((path) => { 457 | const mod = await import(path); 458 | mod.default(); 459 | }); 460 | ``` 461 | 462 | - With Webpack: 463 | 464 | ```js 465 | // components/index.js 466 | const context = require.context(".", true, /index.js$/) 467 | context.keys().forEach(context); 468 | ``` 469 | 470 | ### Using with StimulusJS 471 | 472 | You can define Stimulus controllers right in the component folder in the `controller.js` file: 473 | 474 | ```js 475 | // We reserve Controller for the export name 476 | import { Controller as BaseController } from "@hotwired/stimulus"; 477 | 478 | export class Controller extends BaseController { 479 | connect() { 480 | // ... 481 | } 482 | } 483 | ``` 484 | 485 | Then, in your Stimulus entrypoint, you can load and register your component controllers as follows: 486 | 487 | - With Vite: 488 | 489 | ```js 490 | import { Application } from "@hotwired/stimulus"; 491 | 492 | const application = Application.start(); 493 | 494 | // Configure Stimulus development experience 495 | application.debug = false; 496 | window.Stimulus = application; 497 | 498 | // Generic controllers 499 | const genericControllers = import.meta.globEager( 500 | "../controllers/**/*_controller.js" 501 | ); 502 | 503 | for (let path in genericControllers) { 504 | let module = genericControllers[path]; 505 | let name = path 506 | .match(/controllers\/(.+)_controller\.js$/)[1] 507 | .replaceAll("/", "-") 508 | .replaceAll("_", "-"); 509 | 510 | application.register(name, module.default); 511 | } 512 | 513 | // Controllers from components 514 | const controllers = import.meta.globEager( 515 | "./../../app/frontend/components/**/controller.js" 516 | ); 517 | 518 | for (let path in controllers) { 519 | let module = controllers[path]; 520 | let name = path 521 | .match(/app\/frontend\/components\/(.+)\/controller\.js$/)[1] 522 | .replaceAll("/", "-") 523 | .replaceAll("_", "-"); 524 | application.register(name, module.default); 525 | } 526 | 527 | export default application; 528 | ``` 529 | 530 | - With Webpack: 531 | 532 | ```js 533 | import { Application } from "stimulus"; 534 | export const application = Application.start(); 535 | 536 | // ... other controllers 537 | 538 | const context = require.context("./../../app/frontend/components/", true, /controllers.js$/) 539 | context.keys().forEach((path) => { 540 | const mod = context(path); 541 | 542 | // Check whether a module has the Controller export defined 543 | if (!mod.Controller) return; 544 | 545 | // Convert path into a controller identifier: 546 | // example/index.js -> example 547 | // nav/user_info/index.js -> nav--user-info 548 | const identifier = path 549 | .match(/app\/frontend\/components\/(.+)\/controller\.js$/)[1] 550 | .replaceAll("/", "-") 551 | .replaceAll("_", "-"); 552 | 553 | application.register(identifier, mod.Controller); 554 | }); 555 | ``` 556 | 557 | We also can add a helper to our base ViewComponent class to generate the controller identifier following the convention above: 558 | 559 | ```ruby 560 | class ApplicationViewComponent 561 | private 562 | 563 | def identifier 564 | @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--") 565 | end 566 | 567 | alias_method :controller_name, :identifier 568 | end 569 | ``` 570 | 571 | And now in your template: 572 | 573 | ```erb 574 | 575 |
576 |
577 | ``` 578 | 579 | ### Isolating CSS with postcss-modules 580 | 581 | 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. 582 | 583 | 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. 584 | 585 | First, install the `postcss-modules` plugin (`yarn add postcss-modules`). 586 | 587 | Then, add the following to your `postcss.config.js`: 588 | 589 | ```js 590 | module.exports = { 591 | plugins: { 592 | 'postcss-modules': { 593 | generateScopedName: (name, filename, _css) => { 594 | const matches = filename.match(/\/app\/frontend\/components\/?(.*)\/index.css$/); 595 | // Do not transform CSS files from outside of the components folder 596 | if (!matches) return name; 597 | 598 | // identifier here is the same identifier we used for Stimulus controller (see above) 599 | const identifier = matches[1].replace("/", "--"); 600 | 601 | // We also add the `c-` prefix to all components classes 602 | return `c-${identifier}-${name}`; 603 | }, 604 | // Do not generate *.css.json files (we don't use them) 605 | getJSON: () => {} 606 | }, 607 | /// other plugins 608 | }, 609 | } 610 | ``` 611 | 612 | Finally, let's add a helper to our view components: 613 | 614 | ```ruby 615 | class ApplicationViewComponent 616 | private 617 | 618 | # the same as above 619 | def identifier 620 | @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--") 621 | end 622 | 623 | # We also add an ability to build a class from a different component 624 | def class_for(name, from: identifier) 625 | "c-#{from}-#{name}" 626 | end 627 | end 628 | ``` 629 | 630 | And now in your template: 631 | 632 | ```erb 633 | 634 |
"> 635 |

"><%= text %>

636 |
637 | ``` 638 | 639 | Assuming that you have the following `index.css`: 640 | 641 | ```css 642 | .container { 643 | padding: 10px; 644 | background: white; 645 | border: 1px solid #333; 646 | } 647 | 648 | .body { 649 | margin-top: 20px; 650 | font-size: 24px; 651 | } 652 | ``` 653 | 654 | The final HTML output would be: 655 | 656 | ```html 657 |
658 |

Some text

659 |
660 | ``` 661 | 662 | ## I18n integration (alternative) 663 | 664 | 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. 665 | 666 | We provide an alternative (and more _classic_) way of dealing with translations—**namespacing**. Following the convention over configuration, 667 | put translations under `.view_components.` key, for example: 668 | 669 | ```yml 670 | en: 671 | view_components: 672 | login_form: 673 | submit: "Log in" 674 | nav: 675 | user_info: 676 | login: "Log in" 677 | logout: "Log out" 678 | ``` 679 | 680 | And then in your components: 681 | 682 | ```erb 683 | 684 | 685 | 686 | 687 | <%= t(".logout") %> 688 | ``` 689 | 690 | If you're using `ViewComponentContrib::Base`, you already have translation support included. 691 | Othwerwise you must include the module yourself: 692 | 693 | ```ruby 694 | class ApplicationViewComponent < ViewComponent::Base 695 | include ViewComponentContrib::TranslationHelper 696 | end 697 | ``` 698 | 699 | You can override the default namespace (`view_components`) and a particular component _scope_: 700 | 701 | ```ruby 702 | class ApplicationViewComponent < ViewComponentContrib::Base 703 | self.i18n_namespace = "my_components" 704 | end 705 | 706 | class SomeButton::Component < ApplicationViewComponent 707 | self.i18n_scope = %w[legacy button] 708 | end 709 | ``` 710 | 711 | ## Hanging `#initialize` out to Dry 712 | 713 | One way to improve development experience with ViewComponent is to move from imperative `#initialize` to something declarative. 714 | Our choice is [dry-initializer][]. 715 | 716 | Assuming that we have the following component: 717 | 718 | ```ruby 719 | class FlashAlert::Component < ApplicationViewComponent 720 | attr_reader :type, :duration, :body 721 | 722 | def initialize(body:, type: "success", duration: 3000) 723 | @body = body 724 | @type = type 725 | @duration = duration 726 | end 727 | end 728 | ``` 729 | 730 | Let's add `dry-initializer` to our base class: 731 | 732 | ```ruby 733 | class ApplicationViewComponent 734 | extend Dry::Initializer 735 | end 736 | ``` 737 | 738 | And then refactor our FlashAlert component: 739 | 740 | ```ruby 741 | class FlashAlert::Component < ApplicationViewComponent 742 | option :type, default: proc { "success" } 743 | option :duration, default: proc { 3000 } 744 | option :body 745 | end 746 | ``` 747 | 748 | ## Supporting `.with_collection` 749 | 750 | The `.with_collection` method from ViewComponent expects a component class to have the "Component" suffix to correctly infer the parameter name. Since we're using a different naming convention, we need to specify the collection parameter name explicitly. For example: 751 | 752 | ```ruby 753 | class PostCard::Component < ApplicationViewComponent 754 | with_collection_parameter :post 755 | 756 | option :post 757 | end 758 | ``` 759 | 760 | You can add this to following line to your component generator (unless it's already added): `with_collection_parameter :<%= singular_name %>` to always explicitly provide the collection parameter name. 761 | 762 | ## Wrapped components 763 | 764 | 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. 765 | 766 | 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. 767 | 768 | ```erb 769 | <%= render ViewComponentContrib::WrappedComponent.new(Example::Component.new) do |wrapper| %> 770 |
771 | <%= wrapper.component %> 772 |
773 | <%- end -%> 774 | ``` 775 | 776 | You can add a `#wrapped` method to your base class to simplify the code above: 777 | 778 | ```ruby 779 | class ApplicationViewComponent < ViewComponent::Base 780 | # adds #wrapped method 781 | # NOTE: Already included into ViewComponentContrib::Base 782 | include ViewComponentContrib::WrappedHelper 783 | end 784 | ``` 785 | 786 | And the template looks like this now: 787 | 788 | ```erb 789 | <%= render Example::Component.new.wrapped do |wrapper| %> 790 |
791 | <%= wrapper.component %> 792 |
793 | <%- end -%> 794 | ``` 795 | 796 | You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically: 797 | 798 | ## License 799 | 800 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 801 | 802 | [ViewComponent]: https://github.com/github/view_component 803 | [postcss-modules]: https://github.com/madyankin/postcss-modules 804 | [CSS modules]: https://github.com/css-modules/css-modules 805 | [dry-initializer]: https://dry-rb.org/gems/dry-initializer 806 | [railsbytes-template]: https://railsbytes.com/templates/zJosO5 807 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # How to release a gem 2 | 3 | This document describes a process of releasing a new version of a gem. 4 | 5 | 1. Bump version. 6 | 7 | ```sh 8 | git commit -m "Bump 1.." 9 | ``` 10 | 11 | We're (kinda) using semantic versioning: 12 | 13 | - Bugfixes should be released as fast as possible as patch versions. 14 | - New features could be combined and released as minor or patch version upgrades (depending on the _size of the feature_—it's up to maintainers to decide). 15 | - Breaking API changes should be avoided in minor and patch releases. 16 | - Breaking dependencies changes (e.g., dropping older Ruby support) could be released in minor versions. 17 | 18 | How to bump a version: 19 | 20 | - Change the version number in `lib/view_component/contrib/version.rb` file. 21 | - Update the changelog (add new heading with the version name and date). 22 | - Update the installation documentation if necessary (e.g., during minor and major updates). 23 | 24 | 2. Push code to GitHub and make sure CI passes. 25 | 26 | ```sh 27 | git push 28 | ``` 29 | 30 | 3. Release a gem. 31 | 32 | ```sh 33 | gem release -t 34 | git push --tags 35 | ``` 36 | 37 | We use [gem-release](https://github.com/svenfuchs/gem-release) for publishing gems with a single command: 38 | 39 | ```sh 40 | gem release -t 41 | ``` 42 | 43 | Don't forget to push tags and write release notes on GitHub (if necessary). 44 | 45 | 4. Update Rails Bytes template. Requires `RAILS_BYTES_TOKEN`, `RAILS_BYTES_ACCOUNT_ID` envs. 46 | 47 | ```sh 48 | bundle exec rake publish_template 49 | ``` 50 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | begin 7 | require "rubocop/rake_task" 8 | RuboCop::RakeTask.new 9 | 10 | RuboCop::RakeTask.new("rubocop:md") do |task| 11 | task.options << %w[-c .rubocop-md.yml] 12 | end 13 | rescue LoadError 14 | task(:rubocop) {} 15 | task("rubocop:md") {} 16 | end 17 | 18 | Rake::TestTask.new(:test) do |t| 19 | t.libs << "test" 20 | t.libs << "lib" 21 | t.test_files = FileList["test/**/*_test.rb"] 22 | t.warning = false 23 | end 24 | 25 | namespace :test do 26 | task :isolated do 27 | Dir.glob("test/**/*_test.rb").all? do |file| 28 | sh(Gem.ruby, "-I#{__dir__}/lib:#{__dir__}/test", file) 29 | end || raise("Failures") 30 | end 31 | end 32 | 33 | desc "Run Ruby Next nextify" 34 | task :nextify do 35 | sh "bundle exec ruby-next nextify -V" 36 | end 37 | 38 | desc "Generate installation template" 39 | task :build_template do 40 | require "ruby_bytes/cli" 41 | RubyBytes::CLI.new.run("compile", "./templates/install/template.rb") 42 | end 43 | 44 | task default: %w[rubocop rubocop:md test] 45 | -------------------------------------------------------------------------------- /app/views/view_component_contrib/preview.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%- if component -%> 3 | <%- if local_assigns[:content_block] -%> 4 | <%= render component, &content_block %> 5 | <% else %> 6 | <%= render component %> 7 | <% end %> 8 | <%- else -%> 9 | Failed to infer a component from the preview: <%= error %> 10 | <%- end -%> 11 |
12 | -------------------------------------------------------------------------------- /gemfiles/rails6.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 6.0" 4 | 5 | gemspec path: ".." 6 | -------------------------------------------------------------------------------- /gemfiles/rails7.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.0" 4 | 5 | gemspec path: ".." 6 | -------------------------------------------------------------------------------- /gemfiles/rails8.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 8.0" 4 | 5 | gemspec path: ".." 6 | -------------------------------------------------------------------------------- /gemfiles/railsmaster.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", github: "rails/rails" 4 | 5 | gemspec path: ".." 6 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "rubocop-md", "~> 1.0" 3 | gem "standard", "~> 1.0" 4 | end 5 | -------------------------------------------------------------------------------- /gemfiles/view_component_master.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 8.0" 4 | gem "view_component", github: "github/view_component" 5 | 6 | gemspec path: ".." 7 | -------------------------------------------------------------------------------- /lib/view_component-contrib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "view_component_contrib" 4 | -------------------------------------------------------------------------------- /lib/view_component_contrib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ruby-next/language/setup" 4 | RubyNext::Language.setup_gem_load_path 5 | 6 | require "view_component" 7 | 8 | module ViewComponentContrib 9 | APP_PATH = File.expand_path(File.join(__dir__, "../app")) 10 | 11 | autoload :TranslationHelper, "view_component_contrib/translation_helper" 12 | autoload :WrapperComponent, "view_component_contrib/wrapper_component" 13 | autoload :WrappedHelper, "view_component_contrib/wrapped_helper" 14 | autoload :StyleVariants, "view_component_contrib/style_variants" 15 | 16 | autoload :Base, "view_component_contrib/base" 17 | autoload :Preview, "view_component_contrib/preview" 18 | end 19 | 20 | require "view_component_contrib/railtie" if defined?(::Rails::Railtie) 21 | require "view_component_contrib/version" 22 | -------------------------------------------------------------------------------- /lib/view_component_contrib/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | # Base view component class with many extensions already included 5 | class Base < ViewComponent::Base 6 | include TranslationHelper 7 | include WrappedHelper 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/view_component_contrib/preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module Preview 5 | autoload :Base, "view_component_contrib/preview/base" 6 | 7 | autoload :Abstract, "view_component_contrib/preview/abstract" 8 | autoload :DefaultTemplate, "view_component_contrib/preview/default_template" 9 | autoload :Sidecarable, "view_component_contrib/preview/sidecarable" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/view_component_contrib/preview/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module Preview 5 | # Adds `abstract_class` accessor and exclude abstract 6 | # preview classes from index 7 | module Abstract 8 | def self.extended(base) 9 | base.singleton_class.prepend(ClassMethods) 10 | end 11 | 12 | module ClassMethods 13 | attr_accessor :abstract_class 14 | alias_method :abstract_class?, :abstract_class 15 | 16 | def all 17 | load_previews if descendants.reject(&:abstract_class?).empty? 18 | descendants.reject(&:abstract_class?) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/view_component_contrib/preview/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module Preview 5 | # First, enable abstract classes (if not already extended) 6 | unless ViewComponent::Preview.singleton_class.is_a?(ViewComponentContrib::Preview::Abstract) 7 | ViewComponent::Preview.extend ViewComponentContrib::Preview::Abstract 8 | end 9 | 10 | # Base view component class with extensions already included 11 | class Base < ViewComponent::Preview 12 | self.abstract_class = true 13 | 14 | include DefaultTemplate 15 | 16 | DEFAULT_CONTAINER_CLASS = "" 17 | 18 | class << self 19 | # Support layout inheritance 20 | def inherited(child) 21 | child.layout(@layout) if defined?(@layout) 22 | super 23 | end 24 | 25 | attr_writer :container_class 26 | 27 | def container_class 28 | return @container_class if defined?(@container_class) 29 | 30 | @container_class = 31 | if superclass.respond_to?(:container_class) 32 | superclass.container_class 33 | else 34 | DEFAULT_CONTAINER_CLASS 35 | end 36 | end 37 | 38 | def render_args(...) 39 | super.tap do |res| 40 | res[:locals] ||= {} 41 | build_component_instance(res[:locals]) 42 | res[:locals][:container_class] ||= container_class 43 | end 44 | end 45 | 46 | # Infer component class name from preview class name: 47 | # - Namespace::ButtonPreview => Namespace::Button::Component | Namespace::ButtonComponent | Namespace::Button 48 | # - Button::Preview => Button::Component | ButtonComponent | Button 49 | def component_class_name 50 | @component_class_name ||= begin 51 | component_name = name.sub(/(::Preview|Preview)$/, "") 52 | [ 53 | "#{component_name}::Component", 54 | "#{component_name}Component", 55 | component_name 56 | ].find do 57 | _1.safe_constantize 58 | end 59 | end 60 | end 61 | 62 | attr_writer :component_class_name 63 | 64 | private 65 | 66 | def build_component_instance(locals) 67 | return locals unless locals[:component].nil? 68 | locals[:component] = component_class_name.safe_constantize&.new 69 | rescue => e 70 | locals[:component] = nil 71 | locals[:error] = e.message 72 | end 73 | end 74 | 75 | # Shortcut for render_with_template(locals: ...) 76 | def render_with(**locals) 77 | render_with_template(locals: locals) 78 | end 79 | 80 | # Shortcut for render_with_template(locals: {component: ...}) 81 | def render_component(component_or_props = nil, &block) 82 | component = if component_or_props.is_a?(::ViewComponent::Base) 83 | component_or_props 84 | else 85 | self.class.component_class_name.constantize.new(**(component_or_props || {})) 86 | end 87 | 88 | render_with(component: component, content_block: block) 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/view_component_contrib/preview/default_template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module Preview 5 | module DefaultTemplate 6 | DEFAULT_TEMPLATE = "view_component_contrib/preview" 7 | 8 | # Make sure view components errors are loaded 9 | begin 10 | require "view_component/errors" 11 | rescue LoadError 12 | end 13 | 14 | MISSING_TEMPLATE_ERROR = if ViewComponent.const_defined?(:MissingPreviewTemplateError) 15 | ViewComponent::MissingPreviewTemplateError 16 | else 17 | ViewComponent::PreviewTemplateError 18 | end 19 | 20 | def self.included(base) 21 | base.singleton_class.prepend(ClassMethods) 22 | end 23 | 24 | module ClassMethods 25 | attr_writer :default_preview_template 26 | 27 | def default_preview_template 28 | return @default_preview_template if defined?(@default_preview_template) 29 | 30 | @default_preview_template = 31 | if superclass.respond_to?(:default_preview_template) 32 | superclass.default_preview_template 33 | else 34 | DEFAULT_TEMPLATE 35 | end 36 | end 37 | 38 | def preview_example_template_path(example) 39 | super 40 | rescue MISSING_TEMPLATE_ERROR 41 | has_example_preview = preview_paths.find do |path| 42 | Dir.glob(File.join(path, preview_name, "previews", "#{example}.html.*")).any? 43 | end 44 | 45 | return File.join(preview_name, "previews", example) if has_example_preview 46 | 47 | has_root_preview = preview_paths.find do |path| 48 | Dir.glob(File.join(path, preview_name, "preview.html.*")).any? 49 | end 50 | 51 | return File.join(preview_name, "preview") if has_root_preview 52 | 53 | default_preview_template 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/view_component_contrib/preview/sidecarable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module Preview 5 | module Sidecarable 6 | PREVIEW_GLOB = "**/{preview.rb,*_preview.rb}" 7 | 8 | def self.extended(base) 9 | base.singleton_class.prepend(ClassMethods) 10 | end 11 | 12 | module ClassMethods 13 | def load_previews 14 | Array(preview_paths).each do |preview_path| 15 | Dir["#{preview_path}/#{PREVIEW_GLOB}"].sort.each { |file| require_dependency file } 16 | end 17 | end 18 | 19 | def preview_name 20 | name.sub(/(::Preview|Preview)$/, "").underscore 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/view_component_contrib/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | class Railtie < Rails::Railtie 5 | config.view_component.preview_paths << File.join(ViewComponentContrib::APP_PATH, "views") 6 | 7 | initializer "view_component-contrib.skip_loading_previews_if_disabled" do 8 | unless Rails.application.config.view_component.show_previews 9 | previews = Rails.application.config.view_component.preview_paths.flat_map do |path| 10 | Pathname(path).glob("**/*preview.rb") 11 | end 12 | Rails.autoloaders.each { |autoloader| autoloader.ignore(previews) } 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/view_component_contrib/style_variants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | # Organize style in variants that can be combined. 5 | # Inspired by https://www.tailwind-variants.org and https://cva.style/docs/getting-started/variants 6 | # 7 | # Example: 8 | # 9 | # class ButtonComponent < ViewComponent::Base 10 | # include ViewComponentContrib::StyleVariants 11 | # 12 | # erb_template <<~ERB 13 | # 14 | # ERB 15 | # 16 | # style do 17 | # base { 18 | # %w( 19 | # font-medium bg-blue-500 text-white rounded-full 20 | # ) 21 | # } 22 | # variants { 23 | # color { 24 | # primary { %w(bg-blue-500 text-white) } 25 | # secondary { %w(bg-purple-500 text-white) } 26 | # } 27 | # size { 28 | # sm { "text-sm" } 29 | # md { "text-base" } 30 | # lg { "px-4 py-3 text-lg" } 31 | # } 32 | # } 33 | # defaults { {size: :md, color: :primary} } 34 | # end 35 | # 36 | # attr_reader :size, :color 37 | # 38 | # def initialize(size: :md, color: :primary) 39 | # @size = size 40 | # @color = color 41 | # end 42 | # end 43 | # 44 | module StyleVariants 45 | class VariantBuilder 46 | attr_reader :unwrap_blocks 47 | 48 | def initialize(unwrap_blocks = true) 49 | @unwrap_blocks = unwrap_blocks 50 | @variants = {} 51 | end 52 | 53 | def build(&block) 54 | instance_eval(&block) 55 | @variants 56 | end 57 | 58 | def respond_to_missing?(name, include_private = false) 59 | true 60 | end 61 | 62 | def method_missing(name, &block) 63 | return super unless block_given? 64 | 65 | @variants[name] = if unwrap_blocks 66 | VariantBuilder.new(false).build(&block) 67 | else 68 | block 69 | end 70 | end 71 | end 72 | 73 | class StyleSet 74 | def initialize(&init_block) 75 | @base_block = nil 76 | @defaults = {} 77 | @variants = {} 78 | @compounds = {} 79 | 80 | return unless init_block 81 | 82 | @init_block = init_block 83 | instance_eval(&init_block) 84 | end 85 | 86 | def base(&block) 87 | @base_block = block 88 | end 89 | 90 | def defaults(&block) 91 | @defaults = block.call.freeze 92 | end 93 | 94 | def variants(strategy: :override, &block) 95 | variants = build_variants(&block) 96 | @variants = handle_variants(variants, strategy) 97 | end 98 | 99 | def build_variants(&block) 100 | VariantBuilder.new(true).build(&block) 101 | end 102 | 103 | def handle_variants(variants, strategy) 104 | return variants if strategy == :override 105 | 106 | parent_variants = find_parent_variants 107 | return variants unless parent_variants 108 | 109 | return parent_variants.deep_merge(variants) if strategy == :merge 110 | 111 | parent_variants.merge(variants) if strategy == :extend 112 | end 113 | 114 | def find_parent_variants 115 | parent_component = @init_block.binding.receiver.superclass 116 | return unless parent_component.respond_to?(:style_config) 117 | 118 | parent_config = parent_component.style_config 119 | default_parent_style = parent_component.default_style_name 120 | parent_style_set = parent_config.instance_variable_get(:@styles)[default_parent_style.to_sym] 121 | parent_style_set.instance_variable_get(:@variants).deep_dup 122 | end 123 | 124 | def compound(**variants, &block) 125 | @compounds[variants] = block 126 | end 127 | 128 | def compile(**variants) 129 | acc = Array(@base_block&.call || []) 130 | 131 | config = @defaults.merge(variants.compact) 132 | 133 | config.each do |variant, value| 134 | value = cast_value(value) 135 | variant = @variants.dig(variant, value) || next 136 | styles = variant.is_a?(::Proc) ? variant.call(**config) : variant 137 | acc.concat(Array(styles)) 138 | end 139 | 140 | @compounds.each do |compound, value| 141 | next unless compound.all? { |k, v| config[k] == v } 142 | 143 | styles = value.is_a?(::Proc) ? value.call(**config) : value 144 | acc.concat(Array(styles)) 145 | end 146 | 147 | acc.concat(Array(config[:class])) 148 | acc.concat(Array(config[:class_name])) 149 | acc 150 | end 151 | 152 | def dup 153 | copy = super 154 | copy.instance_variable_set(:@defaults, @defaults.dup) 155 | copy.instance_variable_set(:@variants, @variants.dup) 156 | copy.instance_variable_set(:@compounds, @compounds.dup) 157 | copy 158 | end 159 | 160 | private 161 | 162 | def cast_value(val) 163 | case val 164 | when true then :yes 165 | when false then :no 166 | else 167 | val 168 | end 169 | end 170 | end 171 | 172 | class StyleConfig # :nodoc: 173 | DEFAULT_POST_PROCESSOR = ->(compiled) { compiled.join(" ") } 174 | 175 | attr_reader :postprocessor 176 | 177 | def initialize 178 | @styles = {} 179 | @postprocessor = DEFAULT_POST_PROCESSOR 180 | end 181 | 182 | def define(name, &block) 183 | styles[name] = StyleSet.new(&block) 184 | end 185 | 186 | def compile(name, **variants) 187 | styles[name]&.compile(**variants).then do |compiled| 188 | next unless compiled 189 | 190 | postprocess(compiled) 191 | end 192 | end 193 | 194 | # Allow defining a custom postprocessor 195 | def postprocess_with(callable = nil, &block) 196 | @postprocessor = callable || block 197 | end 198 | 199 | def dup 200 | copy = super 201 | copy.instance_variable_set(:@styles, @styles.dup) 202 | copy 203 | end 204 | 205 | private 206 | 207 | attr_reader :styles 208 | 209 | def postprocess(compiled) = postprocessor.call(compiled) 210 | end 211 | 212 | def self.included(base) 213 | base.extend ClassMethods 214 | end 215 | 216 | module ClassMethods 217 | # Returns the name of the default style set based on the class name: 218 | # MyComponent::Component => my_component 219 | # Namespaced::MyComponent => my_component 220 | def default_style_name 221 | @default_style_name ||= name.demodulize.sub(/(::Component|Component)$/, "").underscore.presence || "component" 222 | end 223 | 224 | def style(name = default_style_name, &block) 225 | style_config.define(name.to_sym, &block) 226 | end 227 | 228 | def style_config 229 | @style_config ||= 230 | if superclass.respond_to?(:style_config) 231 | superclass.style_config.dup 232 | else 233 | StyleConfig.new 234 | end 235 | end 236 | end 237 | 238 | def style(name = self.class.default_style_name, **variants) 239 | self.class.style_config.compile(name.to_sym, **variants) 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /lib/view_component_contrib/translation_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | module TranslationHelper 5 | DEFAULT_NAMESPACE = "view_components" 6 | 7 | def self.included(base) 8 | base.extend ClassMethods 9 | end 10 | 11 | module ClassMethods 12 | attr_writer :i18n_namespace 13 | 14 | def i18n_namespace 15 | return @i18n_namespace if defined?(@i18n_namespace) 16 | 17 | @i18n_namespace = 18 | if superclass.respond_to?(:i18n_namespace) 19 | superclass.i18n_namespace 20 | else 21 | DEFAULT_NAMESPACE 22 | end 23 | end 24 | 25 | def contrib_i18n_scope 26 | return @contrib_i18n_scope if defined?(@contrib_i18n_scope) 27 | 28 | @contrib_i18n_scope = name.sub("::Component", "").underscore.split("/") 29 | end 30 | 31 | def i18n_scope=(val) 32 | raise ArgumentError, "Must be array" unless val.is_a?(Array) 33 | 34 | @contrib_i18n_scope = val.dup.freeze 35 | end 36 | 37 | def virtual_path 38 | @contrib_virtual_path ||= [ 39 | i18n_namespace, 40 | *contrib_i18n_scope 41 | ].join(".") 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/view_component_contrib/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib # :nodoc:all 4 | VERSION = "0.2.4" 5 | end 6 | -------------------------------------------------------------------------------- /lib/view_component_contrib/wrapped_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | # Adds `#wrapped` method to automatically wrap self into a WrapperComponent 5 | module WrappedHelper 6 | def wrapped 7 | WrapperComponent.new(self) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/view_component_contrib/wrapper_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponentContrib 4 | # WrapperComponent allwows to wrap any component with a custom HTML code. 5 | # The whole wrapper is only rendered when the child component.render? returns true. 6 | # Thus, wrapper could be used to conditionally render the outer html for components without 7 | # conditionals in templates. 8 | class WrapperComponent < ViewComponent::Base 9 | class DoubleRenderError < StandardError 10 | def initialize(component) 11 | super("A child component could only be rendered once within a wrapper: #{component}") 12 | end 13 | end 14 | 15 | attr_reader :component_instance 16 | 17 | delegate :render?, to: :component_instance 18 | 19 | def initialize(component) 20 | @component_instance = component 21 | end 22 | 23 | # Simply return the contents of the block passed to #render_component. 24 | # (Alias couldn't be used here 'cause ViewComponent check for the method presence when 25 | # choosing between #call and a template.) 26 | def call 27 | content 28 | end 29 | 30 | # Returns rendered child component 31 | # The name component is chosen for convienent usage in templates, 32 | # so we can simply call `= wrapper.component` in the place where we're going 33 | # to put the component 34 | def component 35 | raise DoubleRenderError, component_instance if @rendered 36 | 37 | @rendered = component_instance.render_in(view_context).html_safe 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /templates/install/application_view_component.rb: -------------------------------------------------------------------------------- 1 | class ApplicationViewComponent < ViewComponentContrib::Base 2 | end -------------------------------------------------------------------------------- /templates/install/application_view_component_preview.rb: -------------------------------------------------------------------------------- 1 | class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base 2 | self.abstract_class = true 3 | end -------------------------------------------------------------------------------- /templates/install/generator.rb: -------------------------------------------------------------------------------- 1 | if yes?("Would you like to create a custom generator for your setup? (y/n)") 2 | template_choice_to_ext = {"1" => ".erb", "2" => ".haml", "3" => ".slim"} 3 | 4 | template = ask "Which template processor do you use? (1) ERB, (2) Haml, (3) Slim, (0) Other" 5 | 6 | TEMPLATE_EXT = template_choice_to_ext.fetch(template, "") 7 | TEST_SUFFIX = USE_RSPEC ? 'spec' : 'test' 8 | 9 | file "lib/generators/view_component/view_component_generator.rb", <<~CODE 10 | # frozen_string_literal: true 11 | 12 | # Based on https://github.com/github/view_component/blob/master/lib/rails/generators/component/component_generator.rb 13 | class ViewComponentGenerator < Rails::Generators::NamedBase 14 | source_root File.expand_path("templates", __dir__) 15 | 16 | class_option :skip_test, type: :boolean, default: false 17 | class_option :skip_system_test, type: :boolean, default: false 18 | class_option :skip_preview, type: :boolean, default: false 19 | 20 | argument :attributes, type: :array, default: [], banner: "attribute" 21 | 22 | def create_component_file 23 | template "component.rb", File.join("#{ROOT_PATH}", class_path, file_name, "component.rb") 24 | end 25 | 26 | def create_template_file 27 | template "component.html#{TEMPLATE_EXT}", File.join("#{ROOT_PATH}", class_path, file_name, "component.html#{TEMPLATE_EXT}") 28 | end 29 | 30 | def create_test_file 31 | return if options[:skip_test] 32 | 33 | template "component_#{TEST_SUFFIX}.rb", File.join("#{TEST_ROOT_PATH}", class_path, "\#{file_name}_#{TEST_SUFFIX}.rb") 34 | end 35 | 36 | def create_system_test_file 37 | return if options[:skip_system_test] 38 | 39 | template "component_system_#{TEST_SUFFIX}.rb", File.join("#{TEST_SYSTEM_ROOT_PATH}", class_path, "\#{file_name}_#{TEST_SUFFIX}.rb") 40 | end 41 | 42 | def create_preview_file 43 | return if options[:skip_preview] 44 | 45 | template "preview.rb", File.join("#{ROOT_PATH}", class_path, file_name, "preview.rb") 46 | end 47 | 48 | private 49 | 50 | def parent_class 51 | "ApplicationViewComponent" 52 | end 53 | 54 | def preview_parent_class 55 | "ApplicationViewComponentPreview" 56 | end 57 | end 58 | CODE 59 | 60 | if USE_DRY 61 | inject_into_file "lib/generators/view_component/view_component_generator.rb", before: "\nend" do 62 | <<-CODE 63 | 64 | 65 | def initialize_signature 66 | return if attributes.blank? 67 | 68 | attributes.map { |attr| "option :\#{attr.name}" }.join("\\n ") 69 | end 70 | CODE 71 | end 72 | 73 | file "lib/generators/view_component/templates/component.rb.tt", 74 | <<~CODE 75 | # frozen_string_literal: true 76 | 77 | class <%%= class_name %>::Component < <%%= parent_class %> 78 | with_collection_parameter :<%%= singular_name %> 79 | <%%- if initialize_signature -%> 80 | <%%= initialize_signature %> 81 | <%%- end -%> 82 | end 83 | CODE 84 | else 85 | inject_into_file "lib/generators/view_component/view_component_generator.rb", before: "\nend" do 86 | <<-CODE 87 | 88 | 89 | def initialize_signature 90 | return if attributes.blank? 91 | 92 | attributes.map { |attr| "\#{attr.name}:" }.join(", ") 93 | end 94 | 95 | def initialize_body 96 | attributes.map { |attr| "@\#{attr.name} = \#{attr.name}" }.join("\\n ") 97 | end 98 | CODE 99 | end 100 | 101 | file "lib/generators/view_component/templates/component.rb.tt", 102 | <<~CODE 103 | # frozen_string_literal: true 104 | 105 | class <%%= class_name %>::Component < <%%= parent_class %> 106 | with_collection_parameter :<%%= singular_name %> 107 | <%%- if initialize_signature -%> 108 | def initialize(<%%= initialize_signature %>) 109 | <%%= initialize_body %> 110 | end 111 | <%%- end -%> 112 | end 113 | CODE 114 | end 115 | 116 | if TEMPLATE_EXT == ".slim" 117 | file "lib/generators/view_component/templates/component.html.slim.tt", <<~CODE 118 | div Add <%%= class_name %> template here 119 | CODE 120 | end 121 | 122 | if TEMPLATE_EXT == ".erb" 123 | file "lib/generators/view_component/templates/component.html.erb.tt", <<~CODE 124 |
Add <%%= class_name %> template here
125 | CODE 126 | end 127 | 128 | if TEMPLATE_EXT == ".haml" 129 | file "lib/generators/view_component/templates/component.html.haml.tt", <<~CODE 130 | %div Add <%%= class_name %> template here 131 | CODE 132 | end 133 | 134 | if TEMPLATE_EXT == "" 135 | file "lib/generators/view_component/templates/component.html.tt", <<~CODE 136 |
Add <%%= class_name %> template here
137 | CODE 138 | end 139 | 140 | file "lib/generators/view_component/templates/preview.rb.tt", <<~CODE 141 | # frozen_string_literal: true 142 | 143 | class <%%= class_name %>::Preview < <%%= preview_parent_class %> 144 | # You can specify the container class for the default template 145 | # self.container_class = "w-1/2 border border-gray-300" 146 | 147 | def default 148 | end 149 | end 150 | CODE 151 | 152 | if USE_RSPEC 153 | file "lib/generators/view_component/templates/component_spec.rb.tt", <<~CODE 154 | # frozen_string_literal: true 155 | 156 | require "rails_helper" 157 | 158 | describe <%%= class_name %>::Component do 159 | let(:options) { {} } 160 | let(:component) { <%%= class_name %>::Component.new(**options) } 161 | 162 | subject { rendered_content } 163 | 164 | it "renders" do 165 | render_inline(component) 166 | 167 | is_expected.to have_css "div" 168 | end 169 | end 170 | CODE 171 | 172 | file "lib/generators/view_component/templates/component_system_spec.rb.tt", <<~CODE 173 | # frozen_string_literal: true 174 | 175 | require "rails_helper" 176 | 177 | describe "<%%= file_name %> component" do 178 | it "default preview" do 179 | visit("/rails/view_components<%%= File.join(class_path, file_name) %>/default") 180 | 181 | # is_expected.to have_text "Hello!" 182 | # click_on "Click me" 183 | # is_expected.to have_text "Good-bye!" 184 | end 185 | end 186 | CODE 187 | else 188 | file "lib/generators/view_component/templates/component_test.rb.tt", <<~CODE 189 | # frozen_string_literal: true 190 | 191 | require "test_helper" 192 | 193 | class <%%= class_name %>::ComponentTest < ViewComponent::TestCase 194 | def test_renders 195 | component = build_component 196 | 197 | render_inline(component) 198 | 199 | assert_selector "div" 200 | end 201 | 202 | private 203 | 204 | def build_component(**options) 205 | <%%= class_name %>::Component.new(**options) 206 | end 207 | end 208 | CODE 209 | 210 | file "lib/generators/view_component/templates/component_system_test.rb.tt", <<~CODE 211 | # frozen_string_literal: true 212 | 213 | require "application_system_test_case" 214 | 215 | class <%%= class_name %>::ComponentSystemTest < ApplicationSystemTestCase 216 | def test_default_preview 217 | visit("/rails/view_components<%%= File.join(class_path, file_name) %>/default") 218 | 219 | # assert_text "Hello!" 220 | # click_on("Click me!") 221 | # assert_text "Good-bye!" 222 | end 223 | end 224 | CODE 225 | end 226 | 227 | file "lib/generators/view_component/USAGE", <<~CODE 228 | Description: 229 | ============ 230 | Creates a new view component, test and preview files. 231 | Pass the component name, either CamelCased or under_scored, and an optional list of attributes as arguments. 232 | 233 | Example: 234 | ======== 235 | bin/rails generate view_component Profile name age 236 | 237 | creates a Profile component and test: 238 | Component: #{ROOT_PATH}/profile/component.rb 239 | Template: #{ROOT_PATH}/profile/component.html#{TEMPLATE_EXT} 240 | Test: #{TEST_ROOT_PATH}/profile_component_#{TEST_SUFFIX}.rb 241 | System Test: #{TEST_SYSTEM_ROOT_PATH}/profile_component_#{TEST_SUFFIX}.rb 242 | Preview: #{ROOT_PATH}/profile/component_preview.rb 243 | CODE 244 | 245 | # Check if autoload_lib is configured 246 | if File.file?("config/application.rb") && File.read("config/application.rb").include?("config.autoload_lib") 247 | say_status :info, "⚠️ Make sure you configured autoload_lib to ignore the lib/generators folder" 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /templates/install/identifier.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | private 4 | 5 | def identifier 6 | @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--") 7 | end -------------------------------------------------------------------------------- /templates/install/initializer.rb: -------------------------------------------------------------------------------- 1 | ActiveSupport.on_load(:view_component) do 2 | # Extend your preview controller to support authentication and other 3 | # application-specific stuff 4 | # 5 | # Rails.application.config.to_prepare do 6 | # ViewComponentsController.class_eval do 7 | # include Authenticated 8 | # end 9 | # end 10 | # 11 | # Make it possible to store previews in sidecar folders 12 | # See https://github.com/palkan/view_component-contrib#organizing-components-or-sidecar-pattern-extended 13 | ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable 14 | # Enable `self.abstract_class = true` to exclude previews from the list 15 | ViewComponent::Preview.extend ViewComponentContrib::Preview::Abstract 16 | end -------------------------------------------------------------------------------- /templates/install/template.rb: -------------------------------------------------------------------------------- 1 | say "👋 Welcome to interactive ViewComponent installer and configurator. " \ 2 | "Make sure you've read the view_component-contrib guide: https://github.com/palkan/view_component-contrib" 3 | 4 | run "bundle add view_component view_component-contrib --skip-install" 5 | 6 | say_status :info, "✅ ViewComponent gems added" 7 | 8 | DEFAULT_ROOT = "app/frontend/components" 9 | 10 | root = ask("Where do you want to store your view components? (default: #{DEFAULT_ROOT})") 11 | ROOT_PATH = root.present? && root.downcase != "n" ? root : DEFAULT_ROOT 12 | 13 | root_paths = ROOT_PATH.split("/").map { |path| "\"#{path}\"" }.join(", ") 14 | 15 | application "config.view_component.preview_paths << Rails.root.join(#{root_paths})" 16 | application "config.autoload_paths << Rails.root.join(#{root_paths})" 17 | 18 | say_status :info, "✅ ViewComponent paths configured" 19 | 20 | file "#{ROOT_PATH}/application_view_component.rb", 21 | <%= code("./application_view_component.rb") %> 22 | 23 | file "#{ROOT_PATH}/application_view_component_preview.rb", 24 | <%= code("./application_view_component_preview.rb") %> 25 | 26 | say_status :info, "✅ ApplicationViewComponent and ApplicationViewComponentPreview classes added" 27 | 28 | USE_RSPEC = File.directory?("spec") 29 | TEST_ROOT_PATH = USE_RSPEC ? File.join("spec", ROOT_PATH.sub("app/", "")) : File.join("test", ROOT_PATH.sub("app/", "")) 30 | TEST_SYSTEM_ROOT_PATH = USE_RSPEC ? File.join("spec", "system", ROOT_PATH.sub("app/", "")) : File.join("test", "system", ROOT_PATH.sub("app/", "")) 31 | 32 | USE_DRY = yes? "Would you like to use dry-initializer in your component classes? (y/n)" 33 | 34 | if USE_DRY 35 | run "bundle add dry-initializer --skip-install" 36 | 37 | inject_into_file "#{ROOT_PATH}/application_view_component.rb", "\n extend Dry::Initializer", after: "class ApplicationViewComponent < ViewComponentContrib::Base" 38 | 39 | say_status :info, "✅ Extended ApplicationViewComponent with Dry::Initializer" 40 | end 41 | 42 | initializer "view_component.rb", 43 | <%= code("./initializer.rb") %> 44 | 45 | say_status :info, "✅ Added ViewComponent initializer with required patches" 46 | 47 | if USE_RSPEC 48 | inject_into_file "spec/rails_helper.rb", after: "require \"rspec/rails\"\n" do 49 | "require \"capybara/rspec\"\nrequire \"view_component/test_helpers\"\n" 50 | end 51 | 52 | inject_into_file "spec/rails_helper.rb", after: "RSpec.configure do |config|\n" do 53 | <<-CODE 54 | config.include ViewComponent::TestHelpers, type: :view_component 55 | config.include Capybara::RSpecMatchers, type: :view_component 56 | 57 | config.define_derived_metadata(file_path: %r{/#{TEST_ROOT_PATH}}) do |metadata| 58 | metadata[:type] = :view_component 59 | end 60 | 61 | CODE 62 | end 63 | end 64 | 65 | say_status :info, "✅ RSpec configured" 66 | 67 | USE_STIMULUS = yes? "Do you use Stimulus? (y/n)" 68 | 69 | if USE_STIMULUS 70 | say "⚠️ See the discussion on how to configure your JS bundler to auto-load controllers: https://github.com/palkan/view_component-contrib/discussions/14" 71 | end 72 | 73 | USE_TAILWIND = yes? "Do you use TailwindCSS? (y/n)" 74 | 75 | if USE_TAILWIND 76 | # TODO: Use styled variants 77 | else 78 | say "⚠️ Check out PostCSS modules to keep your CSS isolated and closer to your components: https://github.com/palkan/view_component-contrib#isolating-css-with-postcss-modules" 79 | end 80 | 81 | <%= include("./generator.rb") %> 82 | 83 | say "Installing gems..." 84 | 85 | Bundler.with_unbundled_env { run "bundle install" } 86 | 87 | say_status :info, "✅ You're ready to rock!" 88 | -------------------------------------------------------------------------------- /test/cases/i18n_sidecar_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SidecarTranslationHelperTest < ViewTestCase 6 | def setup 7 | I18n.backend.store_translations( 8 | :en, 9 | view_components: { 10 | sidecar_translation_helper_test: { 11 | test: { 12 | msg: "Hello from contrib" 13 | } 14 | } 15 | } 16 | ) 17 | end 18 | 19 | def teardown 20 | I18n.backend.reload! 21 | end 22 | 23 | module Test 24 | class Component < ViewComponent::Base 25 | include(ViewComponent::Translatable) unless ancestors.include?(ViewComponent::Translatable) 26 | include ViewComponentContrib::TranslationHelper 27 | 28 | def initialize(source = :sidecar) 29 | @source = source 30 | end 31 | 32 | def call 33 | if @source == :sidecar 34 | "
#{t(".message")}
".html_safe 35 | else 36 | "
#{t(".msg")}
".html_safe 37 | end 38 | end 39 | end 40 | end 41 | 42 | def test_translate_from_sidecar 43 | component = Test::Component.new 44 | 45 | render_inline(component) 46 | 47 | assert_selector "div", count: 1, text: "Hello from sidecar" 48 | end 49 | 50 | def test_translate_from_contrib 51 | component = Test::Component.new(:contrib) 52 | 53 | render_inline(component) 54 | 55 | assert_selector "div", count: 1, text: "Hello from contrib" 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/cases/i18n_sidecar_test/component.yml: -------------------------------------------------------------------------------- 1 | en: 2 | message: "Hello from sidecar" 3 | -------------------------------------------------------------------------------- /test/cases/i18n_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TranslationHelperTest < ViewTestCase 6 | def setup 7 | I18n.backend.store_translations( 8 | :en, 9 | view_components: { 10 | translation_helper_test: { 11 | test: { 12 | message: "Hello from test" 13 | } 14 | } 15 | }, 16 | components: { 17 | my_test: { 18 | component: { 19 | message: "Hello from custom" 20 | } 21 | } 22 | } 23 | ) 24 | end 25 | 26 | def teardown 27 | I18n.backend.reload! 28 | end 29 | 30 | module Test 31 | class Component < ViewComponent::Base 32 | include(ViewComponent::Translatable) unless ancestors.include?(ViewComponent::Translatable) 33 | include ViewComponentContrib::TranslationHelper 34 | 35 | def call 36 | "
#{t(".message")}
".html_safe 37 | end 38 | end 39 | end 40 | 41 | module Custom 42 | class Component < ViewComponentContrib::Base 43 | self.i18n_namespace = "components" 44 | self.i18n_scope = %w[my_test component] 45 | 46 | def call 47 | "
#{t(".message")}
".html_safe 48 | end 49 | end 50 | end 51 | 52 | def test_translate 53 | component = Test::Component.new 54 | 55 | render_inline(component) 56 | 57 | assert_selector "div", count: 1, text: "Hello from test" 58 | end 59 | 60 | def test_translate_with_custom_config 61 | component = Custom::Component.new 62 | 63 | render_inline(component) 64 | 65 | assert_selector "div", count: 1, text: "Hello from custom" 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/cases/previews_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | ActiveSupport.on_load(:view_component) do 6 | ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable 7 | end 8 | 9 | class PreviewsIntegrationTest < ActionDispatch::IntegrationTest 10 | setup do 11 | # Warmup previews 12 | ViewComponent::Preview.all 13 | end 14 | 15 | def test_previews_index_show_sidecar_preview 16 | get "/rails/view_components" 17 | 18 | assert_select "a", "Banner" 19 | end 20 | 21 | def test_previews_index_does_not_show_abstract_previews 22 | get "/rails/view_components" 23 | 24 | assert_select "a", text: "Application View Component Preview", count: 0 25 | end 26 | 27 | def test_preview_with_implicit_component_and_template 28 | get "/rails/view_components/banner/default" 29 | 30 | assert_select "div", text: "Welcome!" 31 | end 32 | 33 | def test_preview_with_explicit_component_and_implicit_template 34 | get "/rails/view_components/banner/alert" 35 | 36 | assert_select "div", text: "Alarma!" 37 | end 38 | 39 | def test_preview_with_explicit_component_and_container_class 40 | get "/rails/view_components/banner/wide" 41 | 42 | assert_select "div.w-full", text: "Wide" 43 | end 44 | 45 | def test_preview_with_implicit_component_and_content_block 46 | get "/rails/view_components/button/danger" 47 | 48 | assert_select "button.btn-danger", text: "Danger" 49 | end 50 | 51 | def test_preview_with_explicit_root_template 52 | get "/rails/view_components/custom_banner/default" 53 | 54 | assert_select "div", text: "Custom banner" 55 | end 56 | 57 | def test_preview_with_explicit_example_template 58 | get "/rails/view_components/custom_banner/example" 59 | 60 | assert_select "div", text: "Example banner" 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/cases/style_variants_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class StyledComponentTest < ViewTestCase 6 | class Component < ViewComponentContrib::Base 7 | include ViewComponentContrib::StyleVariants 8 | 9 | erb_template <<~ERB 10 |
Hello
11 | ERB 12 | 13 | style do 14 | base { %w[flex flex-col] } 15 | 16 | variants { 17 | theme { 18 | primary { %w[primary-color primary-bg] } 19 | secondary { %w[secondary-color secondary-bg] } 20 | } 21 | size { 22 | sm { %w[text-sm] } 23 | md { %w[text-md] } 24 | lg { %w[text-lg] } 25 | } 26 | disabled { 27 | yes { "opacity-50" } 28 | } 29 | } 30 | 31 | defaults { {theme: :primary, size: :sm} } 32 | end 33 | 34 | attr_reader :theme, :size, :disabled 35 | 36 | def initialize(theme: :primary, size: :md, disabled: false) 37 | @theme = theme 38 | @size = size 39 | @disabled = disabled 40 | end 41 | end 42 | 43 | def test_render_variants 44 | component = Component.new 45 | 46 | render_inline(component) 47 | 48 | assert_css "div.flex.flex-col.primary-color.primary-bg.text-md" 49 | 50 | component = Component.new(theme: :secondary, size: :md, disabled: true) 51 | 52 | render_inline(component) 53 | 54 | assert_css "div.secondary-color.secondary-bg.text-md.opacity-50" 55 | end 56 | 57 | def test_render_defaults 58 | component = Component.new(theme: nil, size: nil) 59 | 60 | render_inline(component) 61 | 62 | assert_css "div.flex.flex-col.primary-color.primary-bg.text-sm" 63 | end 64 | 65 | class SubComponent < Component 66 | erb_template <<~ERB 67 |
68 | Hello 69 | Click 70 |
71 | ERB 72 | 73 | style do 74 | base { "cursor-pointer" } 75 | 76 | variants { 77 | mode { 78 | light { %w[text-black] } 79 | dark { %w[text-white] } 80 | } 81 | } 82 | end 83 | 84 | attr_reader :mode 85 | 86 | def initialize(mode: :light, **parent_opts) 87 | super(**parent_opts) 88 | @mode = mode 89 | end 90 | end 91 | 92 | def test_inheritance 93 | component = SubComponent.new(theme: :secondary, size: :lg, mode: :dark) 94 | 95 | render_inline(component) 96 | 97 | assert_css "div.secondary-color.secondary-bg.text-lg" 98 | 99 | assert_css "a.text-white" 100 | 101 | component = SubComponent.new(mode: :light) 102 | 103 | render_inline(component) 104 | 105 | assert_css "a.text-black" 106 | end 107 | 108 | class SubComponentMerge < Component 109 | erb_template <<~ERB 110 |
Hello
111 | ERB 112 | style :component do 113 | base { "cursor-pointer" } 114 | 115 | variants(strategy: :merge) { 116 | size { 117 | lg { %w[text-larger] } 118 | } 119 | } 120 | end 121 | end 122 | 123 | def test_inheritance_with_merge_strategy 124 | # test new lg style 125 | component = SubComponentMerge.new(theme: :secondary, size: :lg) 126 | render_inline(component) 127 | assert_css "div.secondary-color.secondary-bg.text-larger" 128 | 129 | # test inherited md styule 130 | component = SubComponentMerge.new(theme: :secondary, size: :md) 131 | render_inline(component) 132 | assert_css "div.secondary-color.secondary-bg.text-md" 133 | end 134 | 135 | class SubComponentExtend < Component 136 | erb_template <<~ERB 137 |
Hello
138 | ERB 139 | style :component do 140 | base { "cursor-pointer" } 141 | 142 | variants(strategy: :extend) { 143 | size { 144 | lg { %w[text-larger] } 145 | } 146 | } 147 | end 148 | end 149 | 150 | def test_inheritance_with_extend_strategy 151 | # test new lg style 152 | component = SubComponentExtend.new(theme: :secondary, size: :lg) 153 | render_inline(component) 154 | assert_css "div.secondary-color.secondary-bg.text-larger" 155 | 156 | # test does not inherit md styule 157 | component = SubComponentExtend.new(theme: :secondary, size: :md) 158 | render_inline(component) 159 | assert_no_css "div.secondary-color.secondary-bg.text-md" 160 | end 161 | 162 | class PostProcessedComponent < Component 163 | style_config.postprocess_with do |compiled| 164 | compiled.join(" ").gsub("primary", "karamba") 165 | end 166 | 167 | erb_template <<~ERB 168 |
">Hello
169 | ERB 170 | end 171 | 172 | def test_postprocessor 173 | component = PostProcessedComponent.new 174 | 175 | render_inline(component) 176 | 177 | assert_css "div.karamba-color.karamba-bg.text-md" 178 | end 179 | 180 | class DiffStyleSubcomponent < Component 181 | erb_template <<~ERB 182 |
Hello
183 | ERB 184 | 185 | # sibling component name, shouldn't conflict 186 | style(:sub) do 187 | variants { 188 | mode { 189 | white { %w[text-white] } 190 | red { %w[text-red] } 191 | } 192 | size { 193 | sm { %w[font-sm] } 194 | md { %w[font-md] } 195 | lg { %w[font-lg] } 196 | } 197 | } 198 | end 199 | end 200 | 201 | def test_style_config_inheritance 202 | component = SubComponent.new(theme: :secondary, size: :lg, mode: :dark) 203 | 204 | render_inline(component) 205 | 206 | assert_css "a.text-white" 207 | 208 | component = DiffStyleSubcomponent.new 209 | 210 | render_inline(component) 211 | 212 | assert_css "div.text-white.font-md" 213 | end 214 | 215 | class CompoundComponent < Component 216 | style do 217 | variants { 218 | size { 219 | sm { %w[text-sm] } 220 | md { %w[text-md] } 221 | lg { %w[text-lg] } 222 | } 223 | theme { 224 | primary do |size:, **| 225 | %w[primary-color primary-bg].tap do 226 | _1 << "uppercase" if size == :lg 227 | end 228 | end 229 | 230 | secondary { %w[secondary-color secondary-bg] } 231 | } 232 | } 233 | 234 | compound(size: :sm, theme: :primary) { %w[rounded] } 235 | compound(size: :md, theme: :secondary) { "underline" } 236 | end 237 | end 238 | 239 | def test_dynamic_variants 240 | component = CompoundComponent.new(theme: :primary, size: :md) 241 | 242 | render_inline(component) 243 | 244 | assert_css "div.primary-color.primary-bg.text-md" 245 | 246 | component = CompoundComponent.new(theme: :primary, size: :lg) 247 | 248 | render_inline(component) 249 | 250 | assert_css "div.primary-color.primary-bg.text-lg.uppercase" 251 | 252 | component = CompoundComponent.new(theme: :primary, size: :sm) 253 | 254 | render_inline(component) 255 | 256 | assert_css "div.primary-color.primary-bg.text-sm.rounded" 257 | 258 | component = CompoundComponent.new(theme: :secondary, size: :md) 259 | 260 | render_inline(component) 261 | 262 | assert_css "div.secondary-color.secondary-bg.text-md.underline" 263 | end 264 | 265 | class AdditionalClassesComponent < Component 266 | erb_template <<~ERB 267 |
Hello
268 | ERB 269 | 270 | style do 271 | end 272 | end 273 | 274 | def test_additional_classes 275 | component = AdditionalClassesComponent.new 276 | 277 | render_inline(component) 278 | 279 | assert_css "div.bg-blue-500" 280 | end 281 | end 282 | -------------------------------------------------------------------------------- /test/cases/wrapped_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class WrapperComponentTest < ViewTestCase 6 | class Component < ViewComponentContrib::Base 7 | attr_reader :should_render 8 | 9 | def initialize(should_render: true) 10 | @should_render = should_render 11 | end 12 | 13 | alias_method :render?, :should_render 14 | 15 | def call 16 | "Hello from test".html_safe 17 | end 18 | end 19 | 20 | def test_render 21 | component = Component.new.wrapped 22 | 23 | render_inline(component) do |wrapper| 24 | "
#{wrapper.component}
".html_safe 25 | end 26 | 27 | assert_selector "div", count: 1, text: "Hello from test" 28 | end 29 | 30 | def test_does_not_render_when_component_should_not_render 31 | component = Component.new(should_render: false).wrapped 32 | 33 | render_inline(component) do |wrapper| 34 | "
#{wrapper.component}
".html_safe 35 | end 36 | 37 | assert_no_selector page, "div" 38 | end 39 | 40 | def test_double_render 41 | component = Component.new.wrapped 42 | 43 | assert_raises ViewComponentContrib::WrapperComponent::DoubleRenderError do 44 | render_inline(component) do |wrapper| 45 | "
  • #{wrapper.component}
  • #{wrapper.component}
  • ".html_safe 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby "~> 3.0" 6 | 7 | gem "rails" 8 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "boot" 4 | require "action_controller/railtie" 5 | 6 | module Dummy 7 | class Application < Rails::Application 8 | config.logger = ActiveSupport::TaggedLogging.new(Logger.new(nil)) 9 | config.log_level = :fatal 10 | config.eager_load = true 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 5 | 6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 8 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: my_database 4 | host: localhost 5 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative "application" 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | config.cache_classes = false 5 | 6 | # Do not eager load code on boot. 7 | config.eager_load = false 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/basic_rails_app/config/initializers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palkan/view_component-contrib/2105ff97cb5e95734a2ebe82bd8c301ecb683c62/test/fixtures/basic_rails_app/config/initializers/.keep -------------------------------------------------------------------------------- /test/internal/app/frontend/components/application_view_component_preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/banner/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Banner::Component < ViewComponentContrib::Base 4 | attr_reader :text 5 | 6 | def initialize(text: "Welcome!") 7 | @text = text 8 | end 9 | 10 | def call 11 | "
    #{text}
    ".html_safe 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/banner/preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Banner::Preview < ApplicationViewComponentPreview 4 | def default 5 | end 6 | 7 | def alert 8 | render_component Banner::Component.new(text: "Alarma!") 9 | end 10 | 11 | def wide 12 | render_with( 13 | component: Banner::Component.new(text: "Wide"), 14 | container_class: "w-full" 15 | ) 16 | end 17 | 18 | def with_custom_text 19 | render_component do 20 | "Custom text" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/button/component.html.erb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/button/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Button::Component < ViewComponentContrib::Base 4 | attr_reader :type, :kind 5 | 6 | def initialize(type: "button", kind: "primary") 7 | @type = type 8 | @kind = kind 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/custom_banner/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomBanner::Component < ViewComponentContrib::Base 4 | attr_reader :text 5 | 6 | def initialize(text:) 7 | @text = text 8 | end 9 | 10 | def call 11 | "
    #{text}
    ".html_safe 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/custom_banner/preview.html.erb: -------------------------------------------------------------------------------- 1 | <%= render CustomBanner::Component.new(text: "Custom banner") %> 2 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/custom_banner/preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomBanner::Preview < ApplicationViewComponentPreview 4 | def default 5 | end 6 | 7 | def example 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/internal/app/frontend/components/custom_banner/previews/example.html.erb: -------------------------------------------------------------------------------- 1 | <%= render CustomBanner::Component.new(text: "Example banner") %> 2 | -------------------------------------------------------------------------------- /test/internal/app/frontend/previews/button_preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ButtonPreview < ApplicationViewComponentPreview 4 | def info 5 | render_component(kind: :info) { "Info" } 6 | end 7 | 8 | def danger 9 | render_component(kind: :danger) { "Danger" } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/template/template_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | return unless defined?(::RubyBytes) 6 | 7 | class TemplateTest < Minitest::Test 8 | def test_template_compiles 9 | assert RubyBytes::Compiler.new(File.join(__dir__, "../../templates/install/template.rb")).render 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | 5 | require "ruby-next/language/runtime" unless ENV["CI"] 6 | 7 | ENV["RAILS_ENV"] = "test" 8 | 9 | # https://github.com/rails/rails/issues/54263 10 | require "logger" 11 | require "combustion" 12 | require "view_component_contrib" 13 | 14 | Combustion.path = "test/internal" 15 | Combustion.initialize! :action_controller, :action_view do 16 | config.logger = ActiveSupport::TaggedLogging.new(Logger.new(nil)) 17 | config.log_level = :fatal 18 | 19 | config.view_component.show_previews = true 20 | 21 | config.autoload_paths << Rails.root.join("app", "frontend", "components") 22 | config.view_component.preview_paths << Rails.root.join("app", "frontend", "components") 23 | config.view_component.preview_paths << Rails.root.join("app", "frontend", "previews") 24 | end 25 | 26 | class ApplicationController < ActionController::Base 27 | end 28 | 29 | require "minitest/autorun" 30 | require "minitest/focus" 31 | require "minitest/reporters" 32 | Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new 33 | 34 | require "action_controller/test_case" 35 | class ViewTestCase < Minitest::Test 36 | include ViewComponent::TestHelpers 37 | end 38 | 39 | # rbytes is only available for Ruby 3.0+ 40 | begin 41 | require "rbytes" 42 | require "ruby_bytes/test_case" 43 | 44 | class GeneratorTestCase < RubyBytes::TestCase 45 | root File.join(__dir__, "../templates/install") 46 | dummy_app File.join(__dir__, "fixtures", "basic_rails_app") 47 | end 48 | rescue LoadError 49 | end 50 | -------------------------------------------------------------------------------- /view_component-contrib.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/view_component_contrib/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "view_component-contrib" 7 | s.version = ViewComponentContrib::VERSION 8 | s.authors = ["Vladimir Dementyev"] 9 | s.email = ["dementiev.vm@gmail.com"] 10 | s.homepage = "http://github.com/palkan/view_component-contrib" 11 | s.summary = "A collection of extensions and developer tools for ViewComponent" 12 | s.description = "A collection of extensions and developer tools for ViewComponent" 13 | 14 | s.metadata = { 15 | "bug_tracker_uri" => "http://github.com/palkan/view_component-contrib/issues", 16 | "changelog_uri" => "https://github.com/palkan/view_component-contrib/blob/master/CHANGELOG.md", 17 | "documentation_uri" => "http://github.com/palkan/view_component-contrib", 18 | "homepage_uri" => "http://github.com/palkan/view_component-contrib", 19 | "source_code_uri" => "http://github.com/palkan/view_component-contrib" 20 | } 21 | 22 | s.license = "MIT" 23 | 24 | s.files = Dir.glob("app/**/*") + Dir.glob("lib/.rbnext/**/*") + Dir.glob("lib/**/*") + %w[README.md LICENSE.txt CHANGELOG.md] 25 | s.require_paths = ["lib"] 26 | s.required_ruby_version = ">= 2.7" 27 | 28 | s.add_dependency "view_component" 29 | 30 | # When gem is installed from source, we add `ruby-next` as a dependency 31 | # to auto-transpile source files during the first load 32 | if ENV["RELEASING_GEM"].nil? && File.directory?(File.join(__dir__, ".git")) 33 | s.add_runtime_dependency "ruby-next", ">= 0.15.0" 34 | else 35 | s.add_dependency "ruby-next-core", ">= 0.15.0" 36 | end 37 | 38 | s.add_development_dependency "bundler", ">= 1.15" 39 | s.add_development_dependency "capybara" 40 | s.add_development_dependency "combustion", ">= 1.1" 41 | s.add_development_dependency "minitest", "~> 5.0" 42 | s.add_development_dependency "minitest-focus" 43 | s.add_development_dependency "minitest-reporters" 44 | s.add_development_dependency "rake", ">= 13.0" 45 | end 46 | --------------------------------------------------------------------------------