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

Documentation/59549 add short documentation around our hotwire and viewcomponent usage #17250

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
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
275 changes: 275 additions & 0 deletions docs/development/concepts/hotwire-view-components/README.md
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:
Comment on lines +22 to +23
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
- Controllers include OpTurbo::ComponentStream module
- Provides methods for turbo-stream operations:
- Controllers must include `OpTurbo::ComponentStream` module, which 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
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
- `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`
- `update_via_turbo_stream`
- `replace_via_turbo_stream`
- `remove_via_turbo_stream`
- `modify_via_turbo_stream`
- `append_via_turbo_stream`
- `prepend_via_turbo_stream`
- `add_before_via_turbo_stream`
- `render_error_flash_message_via_turbo_stream`
- `update_flash_message_via_turbo_stream`
- `scroll_into_view_via_turbo_stream`

Reordered in the same order as method definition in app/controllers/concerns/op_turbo/component_stream.rb file, and add missing methods.

- Uses `respond_with_turbo_streams` to handle responses
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
- Uses `respond_with_turbo_streams` to handle responses
- Uses `respond_with_turbo_streams` to handle responses instead of `respond_to`/`render`.


## 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
Copy link
Member

Choose a reason for hiding this comment

The 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 insert_target_modified? returns false?


# ...
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:
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
With this setup, we can creat turbo-stream updates in a rails controller quite easily:
With this setup, turbo-stream updates can be sent from a rails controller:

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 ;)
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
journal.update(journal_params) # in real life this is done through a service obviosuly ;)
journal.update(journal_params) # in real life this would be done through a service obviously ;)


# 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
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
# 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 which were collected in the @turbo_streams variable when calling methods like `update_via_turbo_stream`.
# handy if the controller method is only meant to respond to turbo-stream requests.

respond_with_turbo_streams
end

def create
journal = Journal.create(journal_params) # in real life this is done through a service obviosuly ;)
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
journal = Journal.create(journal_params) # in real life this is done through a service obviosuly ;)
journal = Journal.create(journal_params) # in real life this would be done through a service obviously ;)


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
Copy link
Member

Choose a reason for hiding this comment

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

Instead of setting @turbo_status = :bad_request which feels a bit like breaking encapsulation, why not write respond_with_turbo_streams(status: :bad_request) directly?

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
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

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

I saw that respond_to_with_turbo_streams accepts a block that will yield the format, so the above could be written like this I suppose?

    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 update_via_turbo_stream which has a performance impact.
And honestly I do not find this syntax particularly elegant or easy to read. Why not remove the possibility of passing a block to respond_to_with_turbo_streams?


# ...
end
```

## Usage alongside Primer Forms/Buttons

TODO: is `turbo: true` required here?
Copy link
Member

Choose a reason for hiding this comment

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

I have no idea 😶

Copy link
Member

Choose a reason for hiding this comment

The 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
%>
```

Copy link
Member

Choose a reason for hiding this comment

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

If the below example is different from the

Suggested change
Rendering of a cancel button to remove the edition form. The button calls the `cancel_edit` action on the controller. The `cancel_edit` action sends a turbo stream `replace` to replace the journal edit form with the journal view.

It would be nice to have also the code for the cancel_edit controller action.

```ruby
<%=
component_wrapper do
# ...
render(Primer::Beta::Button.new(
scheme: :secondary,
size: :medium,
tag: :a,
Comment on lines +233 to +235
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
scheme: :secondary,
size: :medium,
tag: :a,

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
Copy link
Member

Choose a reason for hiding this comment

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

Sounds magic :)
Is TurboRequestsService an angular service? I guess it does not really matter as long as it works.

Copy link
Member

Choose a reason for hiding this comment

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

yes it's an Angular service

@Injectable({ providedIn: 'root' })

}

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);
}
}
```