Skip to content

Introduce Symfony UX Turbo #61

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

Closed
wants to merge 6 commits into from
Closed

Conversation

tgalopin
Copy link
Contributor

@tgalopin tgalopin commented Feb 19, 2021

Q A
Bug fix? no
New feature? yes
Tickets -
License MIT

This PR introduces a new package to Symfony UX: UX Turbo, integrating the Hotwire Turbo library with Symfony. It is based on an initial prototype made by Kevin Dunglas (@dunglas) as a Mercure project.

Hotwire is an aggregation of several technologies developed by the Ruby on Rails ecosystem and the Bascamp company to build interactive applications with as less JavaScript code as possible. Stimulus, as the foundation of Symfony UX, is already well integrated into Symfony. This PR goes to the next step: integrating Turbo.

You can learn more about the Hotwire intiative on https://twitter.com/dhh/status/1341420143239450624. You can also read the README in this PR to better understand the usage in a Symfony application.

TLDR: Turbo is a generic way to build reactive applications (inline form edition, DOM changes without reload, real-time display of chat messages, etc.) without the need to develop any JavaScript (or at least as little as possible). This has two big advantages: it reduces dramatically the time necessary to build a reactive app and it eliminates in many places the need for a client-side state which could be a source of issues.

This PR is loosely based on the equivalent gem in Ruby that integrates Turbo in RoR. It has however a few key differences:

@tgalopin tgalopin force-pushed the ux-turbo branch 2 times, most recently from 1fbc6b9 to f7c6e63 Compare February 19, 2021 20:12
/**
* @author Titouan Galopin <[email protected]>
*
* @internal
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 think we need all those @internal annotations, do we?
What about @experimental also? Some have it, not all.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I may be a bit conservative regarding these annotations indeed :) . Other UX packages have these annotations as well, not sure what our policy should be on them.

Copy link
Member

Choose a reason for hiding this comment

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

I'm often adding @internal when the class should not be used in both framework and standalone usage. E.g. when someone is using this package outside the Symfony framework, they probably end up needing to initialize MercureStreamAdapter. So I wouldn't add @internal there.

As for @experimental, I'm often not adding it to @internal classes (as there is no BC promise in those anyway). Not sure if these are "Symfony policies" or only my personal policy regarding them :)

Copy link
Member

Choose a reason for hiding this comment

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

I agree with @wouterj here, we should remove @internal imho.
Maybe we can make the class final also?

@tgalopin
Copy link
Contributor Author

@Kocal I'm not familiar with eslint, any idea why it fails on this package but not others? I may be missing required config but I don't see it

@Kocal
Copy link
Member

Kocal commented Feb 20, 2021

Hi @tgalopin, I think it's because of this https://github.com/tgalopin/ux/blob/ux-turbo/package.json#L34

I don't think this glob pattern will match src/Turbo/Resources/assets/test/adapters/mercure-controller.test.js since mercure-controller.test.js is located under adapters folder.

Can you try using "src/*/Resources/assets/test/**/*.js" instead?

@tgalopin
Copy link
Contributor Author

Aah I was looking in the package, I didn't think of this file. Let me try, thanks!

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Feb 20, 2021

Missing LICENSE file, see #62 :)

<a href="/messages">Back</a>

<!-- End the frame -->
{{ turbo_frame_end() }}
Copy link
Member

Choose a reason for hiding this comment

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

shouldn't this be a tag rather than a couple of _start and _end functions ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

AFAIK, tags do things and functions display things, thus this notation. I do agree tags would probably look nicer though. Not fixed about this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Tags would help in IDE autocompletion (same as for 'apply' and 'endapply'), so it's less error-prone I guess.

Copy link
Member

Choose a reason for hiding this comment

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

Do we have any examples of tags that output something?
forms use functions for the same reason.

Copy link
Member

@wouterj wouterj Feb 22, 2021

Choose a reason for hiding this comment

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

One that is very close to this use-case is NelmioSecurityBundle's CSP tags:

{% cspscript %}
<script>
    window.api_key = '{{ api_key }}';
</script>
{% endcspscript %}

(this modifies the <script> tag to add the correct CSP digest)

Twig's {% autoescape %} is also outputting (the escaped content). {% spaceless %} used to do this (but has been moved to filter + apply, not sure if there is a particular reason for that). The {% apply %} tag itself is outputting by definition (as it processes the contents using a Twig filter).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a different use case though, as it modifies the content of the tag (a bit like autoescape would). It doesn't display anything by itself.

I think I would be in favor of keeping in functions. It matches the way the rest of Symfony works (esp. forms) and it doesn't have that big of an impact on DX IMO. IDE can usually autocomplete functions as well as tags.

Copy link

Choose a reason for hiding this comment

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

Well... what you are creating here is a particular type of block, isn't it? You always expect a start and an end so to me tags feel much more appropriate.

Suggested change
{{ turbo_frame_end() }}
{% turboframe "name" %}
Your stuff.
{% endturboframe %}

Copy link
Member

Choose a reason for hiding this comment

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

Well, these 2 functions are about rendering some HTML opening and closing tags. they are indeed not a kind of twig block

Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if I forget some turbo_frame_end() function calls?

Copy link
Member

Choose a reason for hiding this comment

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

the HTML tag is not closed, and the browser may auto-close it in a different place than where you expect it to be closed.

@tgalopin
Copy link
Contributor Author

tgalopin commented Feb 26, 2021

Fixed some reviews, checking PHP 8 support

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

We might need a pack to install mercure-bundle + ux-turbo at the same time, with a working recipe

$config = $this->processConfiguration($configuration, $configs);

// Register Turbo Streams config
if (!empty($config['streams']['adapter'])) {
Copy link
Member

Choose a reason for hiding this comment

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

we try to avoid using empty as much as possible

}

// Add Twig extension
if (class_exists(Environment::class)) {
Copy link
Member

Choose a reason for hiding this comment

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

could use an early return statement


$rootNode
->children()
->arrayNode('streams')
Copy link
Member

Choose a reason for hiding this comment

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

maybe we could remove this nesting level and rename adapter to stream_adapter? (same for options)

$rootNode
->children()
->arrayNode('streams')
->info('Enable this section to use 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.

there is nothing to enable in this config :)
maybe this should be removed? I don't see similar comments elsewhere.

/**
* @author Titouan Galopin <[email protected]>
*
* @internal
Copy link
Member

Choose a reason for hiding this comment

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

I agree with @wouterj here, we should remove @internal imho.
Maybe we can make the class final also?

}
}

return trim($html).'></div>';
Copy link
Member

Choose a reason for hiding this comment

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

return substr_replace($html, '></div>', -1);

}
}

return trim($html).'>';
Copy link
Member

Choose a reason for hiding this comment

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

return substr_replace($html, '>', -1);

public function getFunctions(): array
{
return [
new TwigFunction('turbo_stream_from', [$this, 'streamFrom'], ['needs_environment' => true, 'is_safe' => ['html']]),
Copy link
Member

Choose a reason for hiding this comment

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

turbo_stream_listen?

{
return [
new TwigFunction('turbo_frame_start', [$this, 'startFrame'], ['needs_environment' => true, 'is_safe' => ['html']]),
new TwigFunction('turbo_frame_end', [$this, 'endFrame'], ['is_safe' => ['html']]),
Copy link
Member

Choose a reason for hiding this comment

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

maybe we don't need these helpers at all and we should tell ppl to write HTML directly?

// Let's imagine $message is the message received from the request (form, query param, ...)

$publisher(new Update('chat', '
<turbo-stream action="append" target="messages">
Copy link
Member

Choose a reason for hiding this comment

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

it could be great to have a service to help generate these tags via some helper service,

the service should be able to wrap HTML content, text content or a twig template. We could take inspiration from e.g. TemplatedEmail.

Copy link

Choose a reason for hiding this comment

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

Yep definitely agree with @nicolas-grekas here, feels bizarre to ask developers to write this while they write turbo_frame_start() in Twig.

'symfony--ux-turbo--core' => [],
]).' ';

foreach ($attrs as $name => $value) {
Copy link
Member

Choose a reason for hiding this comment

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

maybe we don't need attrs at all here btw?

{
$options = array_merge($this->options, ['id' => $id]);

$html = '<div ';
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering: should this generate an html tag, or a list of attributes?

Copy link

@sroze sroze left a comment

Choose a reason for hiding this comment

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

That looks fun!

// Let's imagine $message is the message received from the request (form, query param, ...)

$publisher(new Update('chat', '
<turbo-stream action="append" target="messages">
Copy link

Choose a reason for hiding this comment

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

Yep definitely agree with @nicolas-grekas here, feels bizarre to ask developers to write this while they write turbo_frame_start() in Twig.

<a href="/messages">Back</a>

<!-- End the frame -->
{{ turbo_frame_end() }}
Copy link

Choose a reason for hiding this comment

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

Well... what you are creating here is a particular type of block, isn't it? You always expect a start and an end so to me tags feel much more appropriate.

Suggested change
{{ turbo_frame_end() }}
{% turboframe "name" %}
Your stuff.
{% endturboframe %}


{% block body %}
{# This line configures Turbo to listen to the "chat" topic on Mercure #}
{{ turbo_stream_from('chat') }}
Copy link

Choose a reason for hiding this comment

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

Though I appreciate you technically want it to live within a block in here, semantically it would make greater sense to register it within your Twig file like that:

{% listen_turbostream "chat" %}

@beberlei
Copy link

I think this should be named symfony/ux-turbo-streams, because it has nothing to do with Turbo support in general, which works without this PR by just installing the npm/yarn package.

@tgalopin tgalopin closed this Mar 22, 2021
@tgalopin tgalopin deleted the ux-turbo branch March 22, 2021 08:50
@tgalopin
Copy link
Contributor Author

Closing in favor of the other PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants