diff --git a/src/Vue/assets/dist/render_controller.d.ts b/src/Vue/assets/dist/render_controller.d.ts index 205d99e8377..fc93c5fe3b2 100644 --- a/src/Vue/assets/dist/render_controller.d.ts +++ b/src/Vue/assets/dist/render_controller.d.ts @@ -6,12 +6,16 @@ export default class extends Controller | null | undefined; + readonly hasPropsValue: boolean; + propsValue: Record | null | undefined; static values: { component: StringConstructor; props: ObjectConstructor; }; + propsValueChanged(newProps: typeof this.propsValue, oldProps: typeof this.propsValue): void; + initialize(): void; connect(): void; disconnect(): void; private dispatchEvent; + private wrapComponent; } diff --git a/src/Vue/assets/dist/render_controller.js b/src/Vue/assets/dist/render_controller.js index 316c8be3a5d..e6da838cd6a 100644 --- a/src/Vue/assets/dist/render_controller.js +++ b/src/Vue/assets/dist/render_controller.js @@ -1,12 +1,40 @@ import { Controller } from '@hotwired/stimulus'; -import { createApp } from 'vue'; +import { shallowReactive, watch, toRaw, createApp, defineComponent, h } from 'vue'; class default_1 extends Controller { + propsValueChanged(newProps, oldProps) { + if (oldProps) { + let removedPropNames = Object.keys(oldProps); + if (newProps) { + removedPropNames = removedPropNames.filter((propName) => !Object.prototype.hasOwnProperty.call(newProps, propName)); + } + removedPropNames.forEach((propName) => { + delete this.props[propName]; + }); + } + if (newProps) { + Object.entries(newProps).forEach(([propName, propValue]) => { + this.props[propName] = propValue; + }); + } + } + initialize() { + const props = this.hasPropsValue && this.propsValue ? this.propsValue : {}; + this.props = shallowReactive({ ...props }); + watch(this.props, (props) => { + this.propsValue = toRaw(props); + this.dispatchEvent('props-update', { + componentName: this.componentValue, + props: this.props, + app: this.app, + }); + }, { flush: 'post' }); + } connect() { - this.props = this.propsValue ?? null; this.dispatchEvent('connect', { componentName: this.componentValue, props: this.props }); const component = window.resolveVueComponent(this.componentValue); - this.app = createApp(component, this.props); + const wrappedComponent = this.wrapComponent(component); + this.app = createApp(wrappedComponent); if (this.element.__vue_app__ !== undefined) { this.element.__vue_app__.unmount(); } @@ -33,6 +61,18 @@ class default_1 extends Controller { dispatchEvent(name, payload) { this.dispatch(name, { detail: payload, prefix: 'vue' }); } + wrapComponent(component) { + const { props } = this; + return defineComponent(() => () => h(component, { + ...props, + ...Object.fromEntries(Object.keys(props).map((propName) => [ + `onUpdate:${propName}`, + (value) => { + props[propName] = value; + }, + ])), + })); + } } default_1.values = { component: String, diff --git a/src/Vue/assets/src/render_controller.ts b/src/Vue/assets/src/render_controller.ts index fac9375d156..96e4865b9a8 100644 --- a/src/Vue/assets/src/render_controller.ts +++ b/src/Vue/assets/src/render_controller.ts @@ -8,27 +8,75 @@ */ import { Controller } from '@hotwired/stimulus'; -import { type App, createApp } from 'vue'; +import { + type App, + type Component, + createApp, + defineComponent, + h, + type ShallowReactive, + shallowReactive, + toRaw, + watch, +} from 'vue'; export default class extends Controller }> { - private props: Record | null; + private props: ShallowReactive>; private app: App; declare readonly componentValue: string; - declare readonly propsValue: Record | null | undefined; + declare readonly hasPropsValue: boolean; + declare propsValue: Record | null | undefined; static values = { component: String, props: Object, }; - connect() { - this.props = this.propsValue ?? null; + propsValueChanged(newProps: typeof this.propsValue, oldProps: typeof this.propsValue) { + if (oldProps) { + let removedPropNames = Object.keys(oldProps); + + if (newProps) { + removedPropNames = removedPropNames.filter( + (propName) => !Object.prototype.hasOwnProperty.call(newProps, propName) + ); + } + + removedPropNames.forEach((propName) => { + delete this.props[propName]; + }); + } + if (newProps) { + Object.entries(newProps).forEach(([propName, propValue]) => { + this.props[propName] = propValue; + }); + } + } + initialize() { + const props = this.hasPropsValue && this.propsValue ? this.propsValue : {}; + this.props = shallowReactive({ ...props }); + watch( + this.props, + (props) => { + this.propsValue = toRaw(props); + this.dispatchEvent('props-update', { + componentName: this.componentValue, + props: this.props, + app: this.app, + }); + }, + { flush: 'post' } + ); + } + + connect() { this.dispatchEvent('connect', { componentName: this.componentValue, props: this.props }); const component = window.resolveVueComponent(this.componentValue); + const wrappedComponent = this.wrapComponent(component); - this.app = createApp(component, this.props); + this.app = createApp(wrappedComponent); if (this.element.__vue_app__ !== undefined) { this.element.__vue_app__.unmount(); @@ -62,4 +110,23 @@ export default class extends Controller } private dispatchEvent(name: string, payload: any) { this.dispatch(name, { detail: payload, prefix: 'vue' }); } + + private wrapComponent(component: Component): Component { + const { props } = this; + + return defineComponent( + () => () => + h(component, { + ...props, + ...Object.fromEntries( + Object.keys(props).map((propName) => [ + `onUpdate:${propName}`, + (value: unknown) => { + props[propName] = value; + }, + ]) + ), + }) + ); + } } diff --git a/src/Vue/assets/test/controller_reactivity.test.ts b/src/Vue/assets/test/controller_reactivity.test.ts new file mode 100644 index 00000000000..ea767ade691 --- /dev/null +++ b/src/Vue/assets/test/controller_reactivity.test.ts @@ -0,0 +1,194 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import VueController from '../src/render_controller'; +import SimpleForm from './fixtures/SimpleForm.vue'; + +const startStimulus = () => { + const application = Application.start(); + application.register('vue', VueController); +}; + +window.resolveVueComponent = () => { + return SimpleForm; +}; + +describe('VueController', () => { + it('reacts on field value changed', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Derron Macgregor","value2":"Tedrick Speers","value3":"Janell Highfill"}' + ); + + startStimulus(); + + await waitFor(() => expect(component).toHaveAttribute('data-v-app')); + + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Derron Macgregor","value2":"Tedrick Speers","value3":"Janell Highfill"}' + ); + + const field1 = getByTestId(container, 'field-1') as HTMLInputElement; + const field2 = getByTestId(container, 'field-2') as HTMLInputElement; + const field3 = getByTestId(container, 'field-3') as HTMLInputElement; + + field1.value = 'Devi Sund'; + field1.dispatchEvent(new Event('input')); + + field2.value = 'Shanai Nance'; + field2.dispatchEvent(new Event('input')); + + field3.value = 'Georgios Baylor'; + field3.dispatchEvent(new Event('input')); + + await waitFor(() => + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Devi Sund","value2":"Shanai Nance","value3":"Georgios Baylor"}' + ) + ); + + clearDOM(); + }); + + it('reacts on props changed', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Marshawn Caley","value2":"Ontario Hopper","value3":"Latria Gibb"}' + ); + + startStimulus(); + + await waitFor(() => expect(component).toHaveAttribute('data-v-app')); + + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Marshawn Caley","value2":"Ontario Hopper","value3":"Latria Gibb"}' + ); + + const field1 = getByTestId(container, 'field-1') as HTMLInputElement; + const field2 = getByTestId(container, 'field-2') as HTMLInputElement; + const field3 = getByTestId(container, 'field-3') as HTMLInputElement; + + expect(field1).toHaveValue('Marshawn Caley'); + expect(field2).toHaveValue('Ontario Hopper'); + expect(field3).toHaveValue('Latria Gibb'); + + component.dataset.vuePropsValue = '{"value1":"Shon Pahl","value2":"Simi Kester","value3":"Shenelle Corso"}'; + + await waitFor(() => expect(field1).toHaveValue('Shon Pahl')); + await waitFor(() => expect(field2).toHaveValue('Simi Kester')); + await waitFor(() => expect(field3).toHaveValue('Shenelle Corso')); + + clearDOM(); + }); + + it('reacts on props adding', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).toHaveAttribute('data-vue-props-value', '{"value1":"Marshawn Caley"}'); + + startStimulus(); + + await waitFor(() => expect(component).toHaveAttribute('data-v-app')); + + expect(component).toHaveAttribute('data-vue-props-value', '{"value1":"Marshawn Caley"}'); + + const field1 = getByTestId(container, 'field-1') as HTMLInputElement; + const field2 = getByTestId(container, 'field-2') as HTMLInputElement; + const field3 = getByTestId(container, 'field-3') as HTMLInputElement; + + expect(field1).toHaveValue('Marshawn Caley'); + expect(field2).toHaveValue(''); + expect(field3).toHaveValue(''); + + component.dataset.vuePropsValue = '{"value1":"Marshawn Caley","value2":"Abelino Dollard"}'; + + await waitFor(() => expect(field1).toHaveValue('Marshawn Caley')); + await waitFor(() => expect(field2).toHaveValue('Abelino Dollard')); + await waitFor(() => expect(field3).toHaveValue('')); + + component.dataset.vuePropsValue = + '{"value1":"Marshawn Caley","value2":"Abelino Dollard","value3":"Ravan Farr"}'; + + await waitFor(() => expect(field1).toHaveValue('Marshawn Caley')); + await waitFor(() => expect(field2).toHaveValue('Abelino Dollard')); + await waitFor(() => expect(field3).toHaveValue('Ravan Farr')); + }); + + it('reacts on props removing', async () => { + const container = mountDOM(` +
+ `); + + const component = getByTestId(container, 'component'); + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Trista Elbert","value2":"Mistina Truax","value3":"Chala Paddock"}' + ); + + startStimulus(); + + await waitFor(() => expect(component).toHaveAttribute('data-v-app')); + + expect(component).toHaveAttribute( + 'data-vue-props-value', + '{"value1":"Trista Elbert","value2":"Mistina Truax","value3":"Chala Paddock"}' + ); + + const field1 = getByTestId(container, 'field-1') as HTMLInputElement; + const field2 = getByTestId(container, 'field-2') as HTMLInputElement; + const field3 = getByTestId(container, 'field-3') as HTMLInputElement; + + expect(field1).toHaveValue('Trista Elbert'); + expect(field2).toHaveValue('Mistina Truax'); + expect(field3).toHaveValue('Chala Paddock'); + + component.dataset.vuePropsValue = '{"value1":"Trista Elbert","value3":"Chala Paddock"}'; + + await waitFor(() => expect(field1).toHaveValue('Trista Elbert')); + await waitFor(() => expect(field2).toHaveValue('')); + await waitFor(() => expect(field3).toHaveValue('Chala Paddock')); + + component.dataset.vuePropsValue = '{"value3":"Chala Paddock"}'; + + await waitFor(() => expect(field1).toHaveValue('')); + await waitFor(() => expect(field2).toHaveValue('')); + await waitFor(() => expect(field3).toHaveValue('Chala Paddock')); + }); +}); diff --git a/src/Vue/assets/test/fixtures/SimpleForm.vue b/src/Vue/assets/test/fixtures/SimpleForm.vue new file mode 100644 index 00000000000..828d2c65e62 --- /dev/null +++ b/src/Vue/assets/test/fixtures/SimpleForm.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/Vue/doc/index.rst b/src/Vue/doc/index.rst index 3f19e3afa9b..0d80de205c1 100644 --- a/src/Vue/doc/index.rst +++ b/src/Vue/doc/index.rst @@ -169,6 +169,70 @@ used for all the Vue routes: .. _using-with-asset-mapper: +Keep properties are reactive +---------------------------- + +All Vue component properties are reactive up to the Stimulus controller `props` value. + +Value changes are two-way: + +* Any changes of the Stimulus component `props` value will + reactively pass new values to the Vue component without re-creating it, + as would be the case when passing props between Vue components. + +* Any changes to the properties in the Vue component, + if those properties are or replicate the behavior of models, + will change the Stimulus controller `props` value. + +.. code-block:: javascript + + // assets/vue/controllers/Likes.vue + + + + +.. code-block:: html+twig + + {# templates/likes.html.twig #} +
+ +.. code-block:: javascript + + // update likes component props + document.getElementById('likes-component').dataset.vuePropsValue = JSON.stringify({ + likes: newLikesCount, + alreadyLike: isAlreadyLike, + }); + +.. code-block:: javascript + + // get likes component actual props + const { likes, alreadyLike } = JSON.parse(document.getElementById('likes-component').dataset.vuePropsValue); + Using with AssetMapper ----------------------