-
Notifications
You must be signed in to change notification settings - Fork 2.4k
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
Documentation/59549 add short documentation around our hotwire and viewcomponent usage #17250
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,275 @@ | ||||||||||||||||||||||||||||||||||
--- | ||||||||||||||||||||||||||||||||||
sidebar_navigation: | ||||||||||||||||||||||||||||||||||
title: Using Hotwire with ViewComponents | ||||||||||||||||||||||||||||||||||
description: An introduction of how we use Hotwire alongside ViewComponets | ||||||||||||||||||||||||||||||||||
keywords: Ruby on Rails, Hotwire, ViewComponents | ||||||||||||||||||||||||||||||||||
--- | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# Using Hotwire with ViewComponents | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
OpenProject uses [Hotwire](https://hotwired.dev/) alongside [ViewComponents](https://viewcomponent.org/) to build dynamic user interfaces. This combination allows us to create interactive features while maintaining a component-based architecture. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
The approach below is meant to be a thin abstraction layer on top of Hotwire's Turbo Streams to make them easier to use with a component based UI architecture built on ViewComponents. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
## Key Concepts | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
**Component Setup** | ||||||||||||||||||||||||||||||||||
- Components must include `OpTurbo::Streamable` module | ||||||||||||||||||||||||||||||||||
- Requires `component_wrapper` in templates for turbo-stream updates | ||||||||||||||||||||||||||||||||||
- Can specify insert targets for append/prepend operations | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
**Controller Integration** | ||||||||||||||||||||||||||||||||||
- Controllers include OpTurbo::ComponentStream module | ||||||||||||||||||||||||||||||||||
- Provides methods for turbo-stream operations: | ||||||||||||||||||||||||||||||||||
- `update_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
- `append_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
- `prepend_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
- `replace_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
- `remove_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
- `update_flash_message_via_turbo_stream` | ||||||||||||||||||||||||||||||||||
Comment on lines
+24
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Reordered in the same order as method definition in |
||||||||||||||||||||||||||||||||||
- Uses `respond_with_turbo_streams` to handle responses | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
## Example | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
Imagine we have a component that renders a list of journals for a work package. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
This is the index component: | ||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
class JournalIndexComponent < ApplicationComponent | ||||||||||||||||||||||||||||||||||
include OpTurbo::Streamable # include this module | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def initialize(work_package:) | ||||||||||||||||||||||||||||||||||
super | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
@work_package = work_package | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
attr_reader :work_package | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# optional: | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# modifier to determine if the insert target should be modified | ||||||||||||||||||||||||||||||||||
# relevant for append or prepend operations | ||||||||||||||||||||||||||||||||||
def insert_target_modified? | ||||||||||||||||||||||||||||||||||
true | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def insert_target_modifier_id | ||||||||||||||||||||||||||||||||||
"work-package-journals" | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
Comment on lines
+51
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't get what this "insert target" is supposed to be. It's also mentioned on line 19 "Can specify insert targets for append/prepend operations" but that does not makes things tick for me. What happens if |
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
with the following template: | ||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
<%= | ||||||||||||||||||||||||||||||||||
component_wrapper do # wrapper is required for turbo-stream updates! | ||||||||||||||||||||||||||||||||||
flex_layout do |journals_index_wrapper_container| | ||||||||||||||||||||||||||||||||||
journals_index_wrapper_container.with_row do | ||||||||||||||||||||||||||||||||||
flex_layout(id: insert_target_modifier_id) do |journals_index_container| | ||||||||||||||||||||||||||||||||||
work_package.journals.each do |journal| | ||||||||||||||||||||||||||||||||||
journals_index_container.with_row do | ||||||||||||||||||||||||||||||||||
render(JournalShowComponent.new(journal:)) | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
journals_index_wrapper_container.with_row do | ||||||||||||||||||||||||||||||||||
render(JournalNewComponent.new(work_package:)) | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
%> | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
And this is the show component: | ||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
class JournalShowComponent < ApplicationComponent | ||||||||||||||||||||||||||||||||||
include OpTurbo::Streamable # include this module | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def initialize(journal:) | ||||||||||||||||||||||||||||||||||
super | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
@journal = journal | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
attr_reader :journal | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
with the following template: | ||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
<%= | ||||||||||||||||||||||||||||||||||
component_wrapper do # wrapper is required for turbo-stream updates! | ||||||||||||||||||||||||||||||||||
render(border_box_container()) do |border_box_component| | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
%> | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
With this setup, we can creat turbo-stream updates in a rails controller quite easily: | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Maybe a personal taste, but I do not like subjective statements like "quite easily" in docs. It makes me feel stupid when I do not understand :) |
||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
class JournalController < ApplicationController | ||||||||||||||||||||||||||||||||||
include OpTurbo::ComponentStream # include this module! | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def update | ||||||||||||||||||||||||||||||||||
journal = Journal.find(params[:id]) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
journal.update(journal_params) # in real life this is done through a service obviosuly ;) | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# update the journal show component | ||||||||||||||||||||||||||||||||||
update_via_turbo_stream( | ||||||||||||||||||||||||||||||||||
component: JournalShowComponent.new(journal: journal) | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# respond with turbo streams which were collected in the @turbo_streams variable behind the scenes | ||||||||||||||||||||||||||||||||||
# handy if this method is just meant to respond to turbo-stream requests | ||||||||||||||||||||||||||||||||||
Comment on lines
+132
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||
respond_with_turbo_streams | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def create | ||||||||||||||||||||||||||||||||||
journal = Journal.create(journal_params) # in real life this is done through a service obviosuly ;) | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if journal.errors.empty? | ||||||||||||||||||||||||||||||||||
# append the new model to the index component | ||||||||||||||||||||||||||||||||||
# prepend is also possible | ||||||||||||||||||||||||||||||||||
append_via_turbo_stream( | ||||||||||||||||||||||||||||||||||
component: JournalShowComponent.new(journal: journal), | ||||||||||||||||||||||||||||||||||
target_component: JournalIndexComponent.new(work_package: @work_package) | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
# Note: the target_component does not get rendered | ||||||||||||||||||||||||||||||||||
# the instatiation is just required for the turbo-stream generation | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# you can use multiple turbo_stream methods in one controller action | ||||||||||||||||||||||||||||||||||
# e.g. update the new component to render an initial form | ||||||||||||||||||||||||||||||||||
update_via_turbo_stream( | ||||||||||||||||||||||||||||||||||
component: JournalNewComponent.new(work_package: journal.work_package) | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
else | ||||||||||||||||||||||||||||||||||
# optionally set a turbo status for the response | ||||||||||||||||||||||||||||||||||
@turbo_status = :bad_request | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# trigger a flash message via turbo-stream | ||||||||||||||||||||||||||||||||||
# more on this here lookbook/pages/patterns/flash_banner | ||||||||||||||||||||||||||||||||||
update_flash_message_via_turbo_stream( | ||||||||||||||||||||||||||||||||||
message: journal.errors.full_messages.join(", "), | ||||||||||||||||||||||||||||||||||
scheme: :danger | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# respond with turbo streams which were collected in the @turbo_streams variable behind the scenes | ||||||||||||||||||||||||||||||||||
# handy if this method is just meant to respond to turbo-stream requests | ||||||||||||||||||||||||||||||||||
respond_with_turbo_streams | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of setting |
||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
## Mixing turbo-streams and other responses | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
TODO: Discuss the below example | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
class JournalController < ApplicationController | ||||||||||||||||||||||||||||||||||
include OpTurbo::ComponentStream # include this module! | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
def update | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing missing in the example above is a button which triggers the update method. |
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
respond_to do |format| | ||||||||||||||||||||||||||||||||||
format.html do | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
format.turbo_stream do | ||||||||||||||||||||||||||||||||||
update_via_turbo_stream( | ||||||||||||||||||||||||||||||||||
component: JournalShowComponent.new(journal: journal) | ||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
render turbo_stream: turbo_streams, status: :ok | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
Comment on lines
+189
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I saw that update_via_turbo_stream(
component: JournalShowComponent.new(journal: journal)
)
respond_to_with_turbo_streams(status: turbo_status, &format_block) do |format|
format.html do
# ...
end
end Correct? But I think Oliver mentioned that with this code when html format is requested, then the component is rendered for nothing in the |
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
## Usage alongside Primer Forms/Buttons | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
TODO: is `turbo: true` required here? | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have no idea 😶 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should no longer be required now that turbo drive is enabled by default |
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
<%= | ||||||||||||||||||||||||||||||||||
component_wrapper do | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
primer_form_with( | ||||||||||||||||||||||||||||||||||
model: journal, | ||||||||||||||||||||||||||||||||||
method: :put, | ||||||||||||||||||||||||||||||||||
data: { turbo: true, turbo_stream: true }, # add this! | ||||||||||||||||||||||||||||||||||
url: journal_path(id: journal.id) | ||||||||||||||||||||||||||||||||||
) do |f| | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
%> | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the below example is different from the
Suggested change
It would be nice to have also the code for the |
||||||||||||||||||||||||||||||||||
```ruby | ||||||||||||||||||||||||||||||||||
<%= | ||||||||||||||||||||||||||||||||||
component_wrapper do | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
render(Primer::Beta::Button.new( | ||||||||||||||||||||||||||||||||||
scheme: :secondary, | ||||||||||||||||||||||||||||||||||
size: :medium, | ||||||||||||||||||||||||||||||||||
tag: :a, | ||||||||||||||||||||||||||||||||||
Comment on lines
+233
to
+235
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
These parameters are a distraction as they are not needed for understanding how Hotwire and Primer forms work together. Better remove them. |
||||||||||||||||||||||||||||||||||
href: cancel_edit_journal_path(journal.id), | ||||||||||||||||||||||||||||||||||
data: { turbo: true, turbo_stream: true } # add this! | ||||||||||||||||||||||||||||||||||
)) do | ||||||||||||||||||||||||||||||||||
t("button_cancel") | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
# ... | ||||||||||||||||||||||||||||||||||
end | ||||||||||||||||||||||||||||||||||
%> | ||||||||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
## Requesting turbo-streams within Stimulus controllers | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
If for some reason you need to request a turbo-stream programmatically from within a Stimulus controller, you can use the `TurboRequestsService` to do so. | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
TODO: Discuss the TurboRequestsService API | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
```typescript | ||||||||||||||||||||||||||||||||||
import { Controller } from '@hotwired/stimulus'; | ||||||||||||||||||||||||||||||||||
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
export default class IndexController extends Controller { | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
private turboRequests:TurboRequestsService; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
async connect() { | ||||||||||||||||||||||||||||||||||
const context = await window.OpenProject.getPluginContext(); | ||||||||||||||||||||||||||||||||||
this.turboRequests = context.services.turboRequests; | ||||||||||||||||||||||||||||||||||
Comment on lines
+261
to
+262
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds magic :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes it's an Angular service
|
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
private async someMethod() { | ||||||||||||||||||||||||||||||||||
// this method will automatically handle the turbo-stream response and thus trigger the DOM updates | ||||||||||||||||||||||||||||||||||
const response = await this.turboRequests.request(someUrl, { | ||||||||||||||||||||||||||||||||||
method: 'GET', | ||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
// for optional further processing of the stream html and response headers: | ||||||||||||||||||||||||||||||||||
console.log(response.html, response.headers); | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.