Skip to content

[Twig] Embedded components #317

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

Merged
merged 1 commit into from
May 18, 2022
Merged

Conversation

kbond
Copy link
Member

@kbond kbond commented May 1, 2022

Q A
Bug fix? no
New feature? yes
Tickets Fix #300
License MIT

This adds a {% component name %} tag to allow rendering components and overriding their slots (by using twig's native block system).

Example:

{# templates/components/alert.html.twig #}
<div{{ attributes.defaults({class: 'alert alert-'~type}) }}>
    {% block default %}<strong>{{ type }}</strong> {{ message }}{% endblock %}
</div>

{# "embed" in another template: #}
{% component 'alert' with {message: 'embedded message', class: 'extra-class', role: 'alert'} %}
    {% block default %} {# override "default slot (block)" #}
        {{ parent() }} {# call parent "slot" #}
        Custom stuff...
        {{ this.message }} {# access component properties/methods as normal #}
        {{ computed.someMethod }} {# all component features available #}
    {% endblock %}
{% endcomponent %}

{# can still use the function as normal #}
{{ component('alert', {message: 'some message'}) }}

More Advanced Example:

{# templates/components/dialog.html.twig #}
<div{{ attributes }}>
    <h1>{% block title %}Default Title{% endblock %}</h1>
    <p>{% block body %}Default Body{% endblock %}</p>
    <div>{% block footer %}Default Footer{% endblock %}</div>
</div>

{# "embed" in another template: #}
{% component dialog %} {# quotes can optionally be left off the component name #}
    {% block title %}Custom Title{% endblock %}
    {% block body %}Custom Body{% endblock %}
    {% block footer %}Custom Footer{% endblock %}
{% endcomponent %}

Some notes:

  1. Most code is copied and modified from twig's native embed tag
  2. I really wanted to have a default slot to avoid the need for overriding the default block (see first example above) but couldn't figure out how to do this or if it's even possible. Maybe someone with a deeper knowledge of twig internals could help?
  3. The PreRender event is still dispatched like normal but because of the nature of how this feature works, modifying the template via a listener is not supported.
  4. Using with LiveComponent's is not supported and throws an exception if attempted. Perhaps some trickery could be added in the future to achieve but I think this feature adds enough value to stand on its own.
  5. This with [RFC][Twig] "Anonymous" components #283 could add more value I think - it would remove the need for simple components to be backed by a component service.

TODO:

  • Update changelog
  • Tests
  • Docs
  • Clarify nomenclature, nested components vs embedded components

@kbond kbond force-pushed the embedded-component branch from c1da7ba to 89b4f62 Compare May 1, 2022 14:28
@kbond kbond mentioned this pull request May 1, 2022
@kbond
Copy link
Member Author

kbond commented May 1, 2022

Some context on what I would like the default slot feature to look like (using the first example above):

{# "embed" in another template: #}
{% component 'alert' with {message: 'embedded message', class: 'extra-class', role: 'alert'} %}
    {# anything not wrapped in a block is considered the default slot #}
    {{ parent() }} {# call parent "slot" #}
    Custom stuff...
    {{ this.message }} {# access component properties/methods as normal #}
    {{ computed.someMethod }} {# all component features available #}

    {% block title %}
        {# can still override "named slots" #}
        Custom Title
    {% endblock %}
{% endcomponent %}

I certainly don't think not having this feature is a showstopper - just a nice DX improvement.

@kbond kbond force-pushed the embedded-component branch from 89b4f62 to 2bb2a81 Compare May 1, 2022 14:52
@gharlan
Copy link
Contributor

gharlan commented May 3, 2022

Some context on what I would like the default slot feature to look like (using the first example above):

I'm not sure about the mixture of default slot and other blocks. But if only the default block is needed, I would also prefer the notation without {% block %}.

I certainly don't think not having this feature is a showstopper - just a nice DX improvement.

100% agree!

@WebMamba
Copy link
Contributor

WebMamba commented May 4, 2022

Thanks for your work kbond ! I tried it out, and it's working great ! I was thinking about the default slots with parent(). What do you think about doing it in the other way. We can use a children function in the component:

{# templates/components/alert.html.twig #}
<div{{ attributes.defaults({class: 'alert alert-'~type}) }}>
		{{ children() }}
    {% block default %}<strong>{{ type }}</strong> {{ message }}{% endblock %}
</div>`

{# "embed" in another template: #}
{% component 'alert' with {message: 'embedded message', class: 'extra-class', type: 'alert'} %}
    {# anything not wrapped in a block is considered the children slot #}
    <p>My custom stuff...</p>
{% endcomponent %}

This children function is only accessible inside a component. I think it can be much easier to read, and closer to front-end framework like react. 🤔

@gharlan
Copy link
Contributor

gharlan commented May 4, 2022

Some context on what I would like the default slot feature to look like (using the first example above):

fyi: found an issue regarding similar functionality for original embed tag: twigphp/Twig#3572

@weaverryan
Copy link
Member

I love this! The code already looks clean. Let's finish this.

I really wanted to have a default slot to avoid the need for overriding the default block (see first example above) but couldn't figure out how to do this or if it's even possible. Maybe someone with a deeper knowledge of twig internals could help?

I agree & also agree that NOT having this is NOT a show stopper. If we merge this, perhaps some brave curious person will come along and add this. My "hunch" is that it the implementation might involve "hacking in" a {% block default %} and then {% endblock %} into the token stream inside ComponentTokenParser, similar to how a fake {% extends %} is already added there. The tricky part would be to figure out IF there is already any blocks defined. My thinking is that (in part for simplicity of implementation) you would either (A) define zero blocks, and thus all content is the default block else (B) you define ALL blocks, including default. If we have this assumption, then we just need to determine if the content contains ANY blocks. And if not, hack in the {% block default %}. However, with more work, someone could probably make this even smarter.

Using with LiveComponent's is not supported and throws an exception if attempted. Perhaps some trickery could be added in the future to achieve but I think this feature adds enough value to stand on its own.

Agree 👍. In theory, we could stash the raw Twig contents into a system cache, and re-render using that. A bit crazy, but I actually don't see any issue with it. But, later.

Thanks!

@kbond kbond force-pushed the embedded-component branch from 2bb2a81 to 0fb0a38 Compare May 16, 2022 14:24
@kbond kbond requested a review from weaverryan May 16, 2022 15:40
@kbond
Copy link
Member Author

kbond commented May 16, 2022

I believe this to be ready for a final review.

Copy link
Member

@weaverryan weaverryan left a comment

Choose a reason for hiding this comment

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

Looks great! Minor comments on the docs

@kbond kbond force-pushed the embedded-component branch 2 times, most recently from 5a98633 to 3692519 Compare May 18, 2022 15:46
@kbond kbond requested a review from weaverryan May 18, 2022 15:46
@weaverryan weaverryan force-pushed the embedded-component branch from 3692519 to b114594 Compare May 18, 2022 16:34
@weaverryan
Copy link
Member

Thanks Kevin!

@weaverryan weaverryan merged commit e3c248e into symfony:2.x May 18, 2022
@kbond kbond deleted the embedded-component branch May 18, 2022 17:04
weaverryan added a commit that referenced this pull request Aug 18, 2023
… live components (sneakyvv)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[TwigComponent] [LiveComponent] Add support for embedded live components

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

## Context

Using embedded components was introduced in #317, but support for Live embedded components was not added. The issue is that on a re-render of a Live component you lose the blocks defined within an embedded component. This PR solves that issue.

## Example

To explain the solution, take this example:

```twig
{# someTemplate.html.twig #}
{% component Foo %}
  {% block content %}
    Override content
  {% endblock %}
{% endcomponent %}
```
```twig
{# Foo.html.twig #}
<div {{ attributes }}>
  {% block content %}
    Default content
  {% endblock %}
</div>
```

Of course, Foo is a Live component.

This obviously also works with the new Twig syntax.

## Background

1. Each `{% component %}` tag is compiled by `ComponentNode`.
It adds an embedded Template to the Template class compiled for `someTemplate.html.twig`. This is a second class inside the same php file, with a suffix in the form of `___%d`. That number at the end is normally random, and is called the embedded template index.
2. `ComponentNode` would generate Template code which fetches the `embeddedContext` from the `ComponentRenderer` and passed that to the `loadTemplate('Foo.html.twig', $index)->display()`
3. When a component is re-rendered (via an action callback) it uses the template of the component `(Foo.html.twig`), which does not have the original block content, because that's part of the host Template (`someTemplate.html.twig`).

## Solution

We only need to use the embedded Template instead of the component Template to re-render a component.

To make this happen, we need to:

1. Use a deterministic index for an embedded template during compilation.
2. Store info on the rendered component's HTML (via the attributes) about the host template and the embedded template's index.
3. Load the embedded Template during re-render using the info passed along with the other attributes/props.

## Remaining

1. I use `loadTemplate` now in the `ComponentRender`, which is marked as internal in the Twig package. Can we ignore that within this package (as both are "Symfony")?
2. The `PreRenderEvent::EMBEDDED` constant and the `isEmbedded` function were introduced to block live embedded components. Should this PR remove those as well?

### Tasks
- [ ] Remove `isEmbedded`?
- [ ] Remove `PreRenderEvent::EMBEDDED`?
- [ ] Add CHANGELOG

Commits
-------

d9dd3fc [TwigComponent] [LiveComponent] Add support for embedded live components
symfony-splitter pushed a commit to symfony/ux-live-component that referenced this pull request Aug 18, 2023
… live components (sneakyvv)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[TwigComponent] [LiveComponent] Add support for embedded live components

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

## Context

Using embedded components was introduced in symfony/ux#317, but support for Live embedded components was not added. The issue is that on a re-render of a Live component you lose the blocks defined within an embedded component. This PR solves that issue.

## Example

To explain the solution, take this example:

```twig
{# someTemplate.html.twig #}
{% component Foo %}
  {% block content %}
    Override content
  {% endblock %}
{% endcomponent %}
```
```twig
{# Foo.html.twig #}
<div {{ attributes }}>
  {% block content %}
    Default content
  {% endblock %}
</div>
```

Of course, Foo is a Live component.

This obviously also works with the new Twig syntax.

## Background

1. Each `{% component %}` tag is compiled by `ComponentNode`.
It adds an embedded Template to the Template class compiled for `someTemplate.html.twig`. This is a second class inside the same php file, with a suffix in the form of `___%d`. That number at the end is normally random, and is called the embedded template index.
2. `ComponentNode` would generate Template code which fetches the `embeddedContext` from the `ComponentRenderer` and passed that to the `loadTemplate('Foo.html.twig', $index)->display()`
3. When a component is re-rendered (via an action callback) it uses the template of the component `(Foo.html.twig`), which does not have the original block content, because that's part of the host Template (`someTemplate.html.twig`).

## Solution

We only need to use the embedded Template instead of the component Template to re-render a component.

To make this happen, we need to:

1. Use a deterministic index for an embedded template during compilation.
2. Store info on the rendered component's HTML (via the attributes) about the host template and the embedded template's index.
3. Load the embedded Template during re-render using the info passed along with the other attributes/props.

## Remaining

1. I use `loadTemplate` now in the `ComponentRender`, which is marked as internal in the Twig package. Can we ignore that within this package (as both are "Symfony")?
2. The `PreRenderEvent::EMBEDDED` constant and the `isEmbedded` function were introduced to block live embedded components. Should this PR remove those as well?

### Tasks
- [ ] Remove `isEmbedded`?
- [ ] Remove `PreRenderEvent::EMBEDDED`?
- [ ] Add CHANGELOG

Commits
-------

d9dd3fc4 [TwigComponent] [LiveComponent] Add support for embedded live components
symfony-splitter pushed a commit to symfony/ux-twig-component that referenced this pull request Aug 18, 2023
… live components (sneakyvv)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[TwigComponent] [LiveComponent] Add support for embedded live components

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

## Context

Using embedded components was introduced in symfony/ux#317, but support for Live embedded components was not added. The issue is that on a re-render of a Live component you lose the blocks defined within an embedded component. This PR solves that issue.

## Example

To explain the solution, take this example:

```twig
{# someTemplate.html.twig #}
{% component Foo %}
  {% block content %}
    Override content
  {% endblock %}
{% endcomponent %}
```
```twig
{# Foo.html.twig #}
<div {{ attributes }}>
  {% block content %}
    Default content
  {% endblock %}
</div>
```

Of course, Foo is a Live component.

This obviously also works with the new Twig syntax.

## Background

1. Each `{% component %}` tag is compiled by `ComponentNode`.
It adds an embedded Template to the Template class compiled for `someTemplate.html.twig`. This is a second class inside the same php file, with a suffix in the form of `___%d`. That number at the end is normally random, and is called the embedded template index.
2. `ComponentNode` would generate Template code which fetches the `embeddedContext` from the `ComponentRenderer` and passed that to the `loadTemplate('Foo.html.twig', $index)->display()`
3. When a component is re-rendered (via an action callback) it uses the template of the component `(Foo.html.twig`), which does not have the original block content, because that's part of the host Template (`someTemplate.html.twig`).

## Solution

We only need to use the embedded Template instead of the component Template to re-render a component.

To make this happen, we need to:

1. Use a deterministic index for an embedded template during compilation.
2. Store info on the rendered component's HTML (via the attributes) about the host template and the embedded template's index.
3. Load the embedded Template during re-render using the info passed along with the other attributes/props.

## Remaining

1. I use `loadTemplate` now in the `ComponentRender`, which is marked as internal in the Twig package. Can we ignore that within this package (as both are "Symfony")?
2. The `PreRenderEvent::EMBEDDED` constant and the `isEmbedded` function were introduced to block live embedded components. Should this PR remove those as well?

### Tasks
- [ ] Remove `isEmbedded`?
- [ ] Remove `PreRenderEvent::EMBEDDED`?
- [ ] Add CHANGELOG

Commits
-------

d9dd3fc4 [TwigComponent] [LiveComponent] Add support for embedded live components
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.

[Twig] Add tag component
4 participants