Skip to content

Commit 8db995c

Browse files
committed
feature #426 [Vue] Introduce Vue UX component (t-richard)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Vue] Introduce Vue UX component | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Tickets | N/A <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT <!-- Replace this notice by a short README for your feature/bugfix. This will help people understand your PR and can be used as a start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Never break backward compatibility (see https://symfony.com/bc). - Features and deprecations must be submitted against branch main. --> This PR introduces a UX Vue component which allows to render Vue.js components inside Twig templates, with the ability to pass down props to Vue components from Twig. It is implemented in the same way as the UX React component for coherence. 1. Call `registerVueControllerComponents` in `app.js` to register Vue components ```js import { registerVueControllerComponents } from "`@symfony`/ux-vue"; registerVueControllerComponents(require.context('./vue/controllers', true, /\.vue$/)); ``` 2. Create Vue "controller components" (eg. `Hello.vue`) inside the folder specified above (here `assets/vue/controllers`) ```vue // assets/vue/controllers/Hello.vue <template> <h1>Hello {{ name }}</h1> </template> ``` 3. All those components can now be used inside Twig using the helper function provided by the bundle, the second parameter are props that will be provided to the component ```twig <div {{ vue_component('Hello', { name: 'Thibault' }) }}></div> ``` The implementation was **heavily** inspired by the work from `@tgalopin` in #329 (that's why I left him as author everywhere). I'll gladly write docs and ux.symfony.com samples if accepted and once we agree on the API. NB: Only Vue.js v3 is supported Commits ------- ff95c1a [Vue] Introduce Vue UX component
2 parents 73c6569 + ff95c1a commit 8db995c

27 files changed

+907
-0
lines changed

src/Vue/.gitattributes

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/.gitattributes export-ignore
2+
/.gitignore export-ignore
3+
/.symfony.bundle.yaml export-ignore
4+
/phpunit.xml.dist export-ignore
5+
/Resources/assets/test export-ignore
6+
/Resources/assets/jest.config.js export-ignore
7+
/Tests export-ignore

src/Vue/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
vendor
2+
composer.lock
3+
.php_cs.cache
4+
.phpunit.result.cache

src/Vue/.symfony.bundle.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
branches: ["2.x"]
2+
maintained_branches: ["2.x"]
3+
doc_dir: "Resources/doc"

src/Vue/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# CHANGELOG
2+
3+
## 2.4
4+
5+
- Component added
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Vue\DependencyInjection;
13+
14+
use Symfony\Component\DependencyInjection\ContainerBuilder;
15+
use Symfony\Component\DependencyInjection\Definition;
16+
use Symfony\Component\DependencyInjection\Reference;
17+
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
18+
use Symfony\UX\Vue\Twig\VueComponentExtension;
19+
20+
/**
21+
* @author Titouan Galopin <[email protected]>
22+
* @author Thibault RICHARD <[email protected]>
23+
*
24+
* @internal
25+
*/
26+
class VueExtension extends Extension
27+
{
28+
public function load(array $configs, ContainerBuilder $container)
29+
{
30+
$container
31+
->setDefinition('twig.extension.vue', new Definition(VueComponentExtension::class))
32+
->setArgument(0, new Reference('webpack_encore.twig_stimulus_extension'))
33+
->addTag('twig.extension')
34+
->setPublic(false)
35+
;
36+
}
37+
}

src/Vue/LICENSE

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2020-2022 Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.

src/Vue/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Symfony UX Vue.js
2+
3+
Symfony UX Vue integrates [Vue.js](https://vuejs.org/) into Symfony applications.
4+
It provides tools to render Vue.js v3 components from Twig.
5+
6+
**This repository is a READ-ONLY sub-tree split**. See
7+
https://github.com/symfony/ux to create issues or submit pull requests.
8+
9+
## Resources
10+
11+
- [Documentation](https://symfony.com/bundles/ux-vue/current/index.html)
12+
- [Report issues](https://github.com/symfony/ux/issues) and
13+
[send Pull Requests](https://github.com/symfony/ux/pulls)
14+
in the [main Symfony UX repository](https://github.com/symfony/ux)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
function registerVueControllerComponents(context) {
2+
const vueControllers = {};
3+
const importAllVueComponents = (r) => {
4+
r.keys().forEach((key) => (vueControllers[key] = r(key).default));
5+
};
6+
importAllVueComponents(context);
7+
window.resolveVueComponent = (name) => {
8+
const component = vueControllers[`./${name}.vue`];
9+
if (typeof component === 'undefined') {
10+
throw new Error('Vue controller "' + name + '" does not exist');
11+
}
12+
return component;
13+
};
14+
}
15+
16+
export { registerVueControllerComponents };
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import { createApp } from 'vue';
3+
4+
class default_1 extends Controller {
5+
connect() {
6+
var _a;
7+
this.props = (_a = this.propsValue) !== null && _a !== void 0 ? _a : null;
8+
this._dispatchEvent('vue:connect', { componentName: this.componentValue, props: this.props });
9+
const component = window.resolveVueComponent(this.componentValue);
10+
this.app = createApp(component, this.props);
11+
this.app.mount(this.element);
12+
this._dispatchEvent('vue:mount', { componentName: this.componentValue, component: component, props: this.props });
13+
}
14+
disconnect() {
15+
this.app.unmount();
16+
this._dispatchEvent('vue:unmount', {
17+
componentName: this.componentValue,
18+
props: this.props,
19+
});
20+
}
21+
_dispatchEvent(name, payload) {
22+
this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
23+
}
24+
}
25+
default_1.values = {
26+
component: String,
27+
props: Object,
28+
};
29+
30+
export { default_1 as default };
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const { defaults } = require('jest-config');
2+
const jestConfig = require('../../../../jest.config.js');
3+
4+
jestConfig.moduleFileExtensions = [...defaults.moduleFileExtensions, 'vue'];
5+
jestConfig.transform['^.+\\.vue$'] = ['@vue/vue3-jest'];
6+
7+
module.exports = jestConfig;

src/Vue/Resources/assets/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@symfony/ux-vue",
3+
"description": "Integration of Vue.js in Symfony",
4+
"license": "MIT",
5+
"version": "1.0.0",
6+
"main": "dist/register_controller.js",
7+
"symfony": {
8+
"controllers": {
9+
"vue": {
10+
"main": "dist/render_controller.js",
11+
"webpackMode": "eager",
12+
"fetch": "eager",
13+
"enabled": true
14+
}
15+
}
16+
},
17+
"peerDependencies": {
18+
"@hotwired/stimulus": "^3.0.0",
19+
"vue": "^3.0"
20+
},
21+
"devDependencies": {
22+
"@hotwired/stimulus": "^3.0.0",
23+
"@types/webpack-env": "^1.16",
24+
"@vue/vue3-jest": "^27.0.0",
25+
"ts-jest": "^27.1.5",
26+
"vue": "^3.0"
27+
}
28+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
export function registerVueControllerComponents(context: __WebpackModuleApi.RequireContext) {
13+
const vueControllers: { [key: string]: object } = {};
14+
15+
const importAllVueComponents = (r: __WebpackModuleApi.RequireContext) => {
16+
r.keys().forEach((key) => (vueControllers[key] = r(key).default));
17+
};
18+
19+
importAllVueComponents(context);
20+
21+
// Expose a global Vue loader to allow rendering from the Stimulus controller
22+
(window as any).resolveVueComponent = (name: string): object => {
23+
const component = vueControllers[`./${name}.vue`];
24+
if (typeof component === 'undefined') {
25+
throw new Error(`Vue controller "${name}" does not exist`);
26+
}
27+
28+
return component;
29+
};
30+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { Controller } from '@hotwired/stimulus';
13+
import { App, Component, createApp } from 'vue';
14+
15+
export default class extends Controller {
16+
private props: Record<string, unknown> | null;
17+
private app: App<Element>;
18+
readonly componentValue: string;
19+
20+
readonly propsValue: Record<string, unknown> | null | undefined;
21+
static values = {
22+
component: String,
23+
props: Object,
24+
};
25+
26+
connect() {
27+
this.props = this.propsValue ?? null;
28+
29+
this._dispatchEvent('vue:connect', { componentName: this.componentValue, props: this.props });
30+
31+
const component: Component = window.resolveVueComponent(this.componentValue);
32+
33+
this.app = createApp(component, this.props);
34+
35+
if (this.element.__vue_app__ !== undefined) {
36+
this.element.__vue_app__.unmount();
37+
}
38+
39+
this.app.mount(this.element);
40+
41+
this._dispatchEvent('vue:mount', {
42+
componentName: this.componentValue,
43+
component: component,
44+
props: this.props,
45+
});
46+
}
47+
48+
disconnect() {
49+
this.app.unmount();
50+
51+
this._dispatchEvent('vue:unmount', {
52+
componentName: this.componentValue,
53+
props: this.props,
54+
});
55+
}
56+
57+
_dispatchEvent(name: string, payload: any) {
58+
this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true }));
59+
}
60+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<h1>Hello {{ name }}</h1>
3+
</template>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import {registerVueControllerComponents} from '../src/register_controller';
13+
import {createRequireContextPolyfill} from './util/require_context_poylfill';
14+
import Hello from './fixtures/Hello.vue'
15+
16+
require.context = createRequireContextPolyfill(__dirname);
17+
18+
describe('registerVueControllerComponents', () => {
19+
it('test', () => {
20+
registerVueControllerComponents(require.context('./fixtures', true, /\.vue$/));
21+
const resolveComponent = (window as any).resolveVueComponent;
22+
23+
expect(resolveComponent).not.toBeUndefined();
24+
expect(resolveComponent('Hello')).toBe(Hello);
25+
});
26+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { Application, Controller } from '@hotwired/stimulus';
13+
import { getByTestId, waitFor } from '@testing-library/dom';
14+
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
15+
import VueController from '../src/render_controller';
16+
17+
// Controller used to check the actual controller was properly booted
18+
class CheckController extends Controller {
19+
connect() {
20+
this.element.addEventListener('vue:connect', () => {
21+
this.element.classList.add('connected');
22+
});
23+
24+
this.element.addEventListener('vue:mount', () => {
25+
this.element.classList.add('mounted');
26+
});
27+
}
28+
}
29+
30+
const startStimulus = () => {
31+
const application = Application.start();
32+
application.register('check', CheckController);
33+
application.register('vue', VueController);
34+
};
35+
36+
const Hello = {
37+
template: "<h1>Hello {{ name ?? 'world' }}</h1>",
38+
props: ['name']
39+
};
40+
41+
(window as any).resolveVueComponent = () => {
42+
return Hello;
43+
};
44+
45+
describe('VueController', () => {
46+
it('connect with props', async () => {
47+
const container = mountDOM(`
48+
<div data-testid="component"
49+
data-controller="check vue"
50+
data-vue-component-value="Hello"
51+
data-vue-props-value="{&quot;name&quot;: &quot;Thibault Richard&quot;}" />
52+
`);
53+
54+
const component = getByTestId(container, 'component');
55+
expect(component).not.toHaveClass('connected');
56+
expect(component).not.toHaveClass('mounted');
57+
58+
startStimulus();
59+
await waitFor(() => expect(component).toHaveClass('connected'));
60+
await waitFor(() => expect(component).toHaveClass('mounted'));
61+
await waitFor(() => expect(component.innerHTML).toEqual('<h1>Hello Thibault Richard</h1>'));
62+
63+
clearDOM();
64+
});
65+
66+
it('connect without props', async () => {
67+
const container = mountDOM(`
68+
<div data-testid="component" id="container-2"
69+
data-controller="check vue"
70+
data-vue-component-value="Hello" />
71+
`);
72+
73+
const component = getByTestId(container, 'component');
74+
expect(component).not.toHaveClass('connected');
75+
expect(component).not.toHaveClass('mounted');
76+
77+
startStimulus();
78+
await waitFor(() => expect(component).toHaveClass('connected'));
79+
await waitFor(() => expect(component).toHaveClass('mounted'));
80+
await waitFor(() => expect(component.innerHTML).toEqual('<h1>Hello world</h1>'));
81+
82+
clearDOM();
83+
});
84+
});

0 commit comments

Comments
 (0)