Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document a propshaft approach to JS/CSS #2160

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ nav_order: 5

## main

* Add documentation for JavaScript and CSS without webpacker
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Add documentation for JavaScript and CSS without webpacker
* Add documentation for JavaScript and CSS without webpacker.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion Joel. It looks like Github uses a Catalyst approach instead of Stimulus. I have to admit, I wasn't aware of Catalyst at all and it's really interesting.

It helps explain the shadow root web component approach described in Encapsulating assets. Separately, that section appears to be unchanged since the original gem documentation was released. When I came across it, my immediate tendency was to overlook it (and the Webpacker section) given the Rails buzz around import maps / Stimulus / eliminating node atm. Perhaps there's an opportunity to mention Catalyst or contextualise Web Components a bit more in that section to help justify it's approach?

I believe it was @reeganviljoen who suggested documentation for this would be useful. I was blocked for awhile trying to figure this out in lieu of anointed documentation. Now that Rails defaults have been updated to remove Webpacker and include stimulus-rails, I suspect I'm not the only one per the activity in Issue #1064.

ViewComponent's Governance documentation suggests there are several folks with commit access to this gem. Does GitHub have a process for allocating reviewers to pull requests? I'm not very familiar with the project prior to this so wouldn't know who to approach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jkotchoff sorry I have been very bust lately, I will see if I can get some more 👀 on this


*Jason Kotchoff*

* Ensure HTML output safety wrapper is used for all inline templates.

*Joel Hawksley*
Expand Down
244 changes: 193 additions & 51 deletions docs/guide/javascript_and_css.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,199 @@ parent: How-to guide

While ViewComponent doesn't provide any built-in tooling to do so, it’s possible to include JavaScript and CSS alongside components.

## Propshaft / Stimulus

To use a [transpiler-less and bundler-less approach to JavaScript](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755) (the default for Rails 8), Stimulus and CSS can be used inside ViewComponents one of two ways:
jkotchoff marked this conversation as resolved.
Show resolved Hide resolved

### Upgrading a pre-Rails 8 app
jkotchoff marked this conversation as resolved.
Show resolved Hide resolved

```ruby
# Gemfile (then run `bundle install`)
gem "importmap-rails" # JavaScript version/digests without transpiling/bundling
gem "propshaft" # Load static assets like JavaScript/CSS/images without transpilation/webpacker
gem "stimulus-rails" # Hotwire JavaScript approach
```

```js
// app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"

const application = Application.start()

application.debug = false
window.Stimulus = application

export { application }
```

```js
// app/javascript/controllers/index.js
import { application } from "controllers/application"
```

```ruby
# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "application", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
```

### Approach 1 - Default _app/components_ ViewComponent directory using named Stimulus controllers, no autoloading
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really like to only have one section. Since app/components is the conventional place to put components, let's keep this section and remove the other one. It looks like propshaft now supports putting stimulus controllers in app/components anyway, so I don't know if there's much use in documenting another approach.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also be super keen on showing a single approach. However, importing each stimulus controller with a named key:

import HelloWorldComponentController from "examples/hello_world_component/hello_world_component_controller"
application.register("examples--hello-world-component", HelloWorldComponentController)

is quite painful compared to autoloading them with:

`pin_all_from "app/frontend/components", under: "controllers", to: "components"`

and as far as I'm aware, the issue described here hasn't been resolved in order to allow autoloading of stimulus controllers without the 'frontend' style subdirectory. I just tried all these steps again on a new Rails 8.0.1 project but the problem described in that issue remains.

Seems like there's some patching to be done in a library somewhere. For the time being, I thought it would be useful to show both approaches and then remove one of them when this is fixed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm ok, any idea why rails/importmap-rails#192 didn't fix the problem?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case, it sounds like this needs to be fixed upstream for the ideal experience. My concern is that the two approaches are confusing (not your fault) - we should just pick one and recommend it without expecting devs to make a choice. Such is the Rails Way™.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why that didn't fix the problem. I've tried various approaches, maybe I'm missing something but it seems everyone is feeling the same pain per the issue this is addressing.

I'm not sure which approach would be best to demonstrate at the moment, they're both suboptimal. I see a lot of examples of folks using the frontend subdirectory workaround however I really don't like that. I've gone with Approach 1 for now for my own project however I really don't like that either and would prefer to be able to use Approach 2 without the subdirectory 😂.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @camertron, I note the thumbs up however I'm not sure how to interpret that. Are you suggesting you're happy to stick with both examples for the time being until the issue is resolved or are you suggesting you would prefer just one approach to be documented and if so, which one?


Locate CSS and Stimulus js with a ViewComponent. This example demonstrates a _HelloWorldComponent_ in an _examples_ namespace with a sidecar file naming approach:

```console
app/components
├── ...
├── examples
| ├── hello_world_component
| | ├── hello_world_component_controller.js
| | ├── hello_world_component.css
| | └── hello_world_component.html.erb
| └── hello_world_component.rb
├── ...
```

#### 1. Prepare _app/components_ as an asset path for css and ensure hot reloads of Stimulus JavaScript

```ruby
# config/application.rb
config.assets.paths << "app/components"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one small issue I has with this config in the past is it digests everything in this path even your erb files, one way to solve this is to get all the files in components and remove any mot asset files

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be interested in a better approach. I'm not sure how to avoid this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do something like this?

config.assets.paths += Dir.glob("app/components/**/*.js")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to avoid autoloading all assets in that app/components directory but when I have tried various approaches like the one you have suggested, I've been unable to make that work.

What problems did you run into?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.assets.paths for the purpose of loading javascript with importmap-rails expects this to contain an array of directories, not files.

Theoretically, you could probably create a directory structure for your view components like:

app/components
├── ...
├── examples
|   ├── hello_world_component
|   |   ├── hello_world_component_javascript
|   |    |        ├── hello_world_component_controller.js
|   |   ├── hello_world_component.css
|   |   └── hello_world_component.html.erb
|   └── hello_world_component.rb
├── ...

and then do something like:
config.assets.paths += Dir["app/components/**/"].select { |d| d.include?('javascript') }

but that doesn't seem particularly nice.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @camertron, I note the thumbs up here but I'm not sure how to interpret that in order to update the pull request. Are you suggesting we look to try and demonstrate a more complicated folder structure or are you suggesting you're now comfortable with the perhaps unavoidable usage of:
config.assets.paths << "app/components"

config.importmap.cache_sweepers << config.root.join("app/components")
```

#### 2. Pin ViewComponent Stimulus import map entries

```ruby
# config/importmap.rb
pin_all_from "app/components"
```

#### 3. Expose the Stimulus controller with a named key:

```ruby
# app/javascript/controllers/index.js
import HelloWorldComponentController from "examples/hello_world_component/hello_world_component_controller"
application.register("examples--hello-world-component", HelloWorldComponentController)
```

#### 4. Implement the ViewComponent with custom CSS and Stimulus behaviour:

```ruby
# app/components/examples/hello_world_component.rb
class Examples::HelloWorldComponent < ViewComponent::Base
def initialize(title:)
@title = title
super
end
end
```

```erb
<!-- app/components/examples/hello_world_component/hello_world_component.html.erb -->
<%= stylesheet_link_tag "examples/hello_world_component/hello_world_component" %>

<h1><%= @title %></h1>
<p><%= content %></p>

<div data-controller="examples--hello-world-component">
<p class="hello-world" id="<%= @id %>" data-examples--hello-world-component-target="output">
This div will be updated by the controller
</p>

<button data-action="click->examples--hello-world-component#greet">Toggle Greeting</button>
</div>
```

```css
/* app/components/examples/hello_world_component/hello_world_component.css */
.hello-world {
color: blue;
}
```

```js
// app/components/examples/hello_world_component/hello_world_component_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["output"]

initialize() {
console.log("Component initialized!");
}

connect() {
console.log("Component connected!");

this.outputTarget.textContent = "This div has been initialised by stimulus and will be updated when you click the button"
}

greet() {
const currentText = this.outputTarget.textContent;
this.outputTarget.textContent = currentText === "Hello from Stimulus!"
? "Goodbye from Stimulus!"
: "Hello from Stimulus!";
}
}
```

#### 5. Render the component in a rails view (or a [ViewComponent preview](previews.md)) to see the end result:

```erb
<!-- app/views/layouts/application.html.erb -->
<body>
...
<%= render(Examples::HelloWorldComponent.new(title: "Hello World!")) {
"<em>This</em> will demonstrate the use of <b>Stimulus</b> and <b>CSS</b> in a ViewComponent".html_safe
}
%>
jkotchoff marked this conversation as resolved.
Show resolved Hide resolved
...
```

### Approach 2 - Autoloaded ViewComponents in a sub-directory

Stimulus controllers [won't currently autoload](https://github.com/ViewComponent/view_component/issues/1064#issuecomment-1163314487) if ViewComponents are located at:

```console
app/components
```

a workaround is to put ViewComponents in a subdirectory:

```console
app/frontend/components
```

and then autoload them in import map:

```ruby
# config/importmap.rb
pin_all_from "app/frontend/components", under: "controllers", to: "components"
```

which also requires adjustment of the ViewComponent defaults to account for the sub-directory path:

```ruby
# config/application.rb
config.autoload_paths << Rails.root.join("app/frontend/components")
config.importmap.cache_sweepers << Rails.root.join("app/frontend")
config.assets.paths << Rails.root.join("app/frontend")
config.view_component.view_component_path = "app/frontend/components"
```

allowing the autoloaded Stimulus controllers in views eg.

```erb
<!-- app/components/examples/hello_world_component/hello_world_component.html.erb -->
...
<div data-controller="examples--hello-world-component--hello-world-component">
...
```

## Webpacker

To use the Webpacker gem to compile assets located in `app/components`:

1. In `config/webpacker.yml`, add `"app/components"` to the `additional_paths` array (for example `additional_paths: ["app/components"]`).
Expand Down Expand Up @@ -115,54 +308,3 @@ class Comment extends HTMLElement {
}
customElements.define('my-comment', Comment)
```

## Stimulus

In Stimulus, create a 1:1 mapping between a Stimulus controller and a component. To load in Stimulus controllers from the `app/components` tree, amend the Stimulus boot code in `app/javascript/controllers/index.js`:

```js
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()
const context = require.context("controllers", true, /\.js$/)
const contextComponents = require.context("../../components", true, /_controller\.js$/)
application.load(
definitionsFromContext(context).concat(
definitionsFromContext(contextComponents)
)
)
```

This enables the creation of files such as `app/components/widget_controller.js`, where the controller identifier matches the `data-controller` attribute in the component's HTML template.

After configuring Webpack to load Stimulus controller files from the `components` directory, add the path to `additional_paths` in `config/webpacker.yml`:

```yml
additional_paths: ["app/components"]
```

When placing a Stimulus controller inside a sidecar directory, be aware that when referencing the controller [each forward slash in a namespaced controller file’s path becomes two dashes in its identifier](
https://stimulusjs.org/handbook/installing#controller-filenames-map-to-identifiers):

```console
app/components
├── ...
├── example
| ├── component.rb
| ├── component.css
| ├── component.html.erb
| └── component_controller.js
├── ...
```

`component_controller.js`'s Stimulus identifier becomes: `example--component`:

```erb
<div data-controller="example--component">
<input type="text">
<button data-action="click->example--component#greet">Greet</button>
</div>
```

See [Generators Options](generators.html#generate-a-stimulus-controller) to generate a Stimulus controller alongside the component using the generator.
Loading