From 4c320544e1e43b6a8ef0b93a356e242b83d22529 Mon Sep 17 00:00:00 2001 From: Stanislau Kviatkouski <7zete7@gmail.com> Date: Fri, 13 Sep 2024 01:20:51 +0300 Subject: [PATCH 1/5] Make RenderController are reactive --- src/Vue/assets/dist/render_controller.d.ts | 6 +- src/Vue/assets/dist/render_controller.js | 45 ++++++++++++- src/Vue/assets/src/render_controller.ts | 76 ++++++++++++++++++++-- 3 files changed, 117 insertions(+), 10 deletions(-) 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..ca670ea5a14 100644 --- a/src/Vue/assets/dist/render_controller.js +++ b/src/Vue/assets/dist/render_controller.js @@ -1,12 +1,35 @@ 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); + }, { 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 +56,22 @@ class default_1 extends Controller { dispatchEvent(name, payload) { this.dispatch(name, { detail: payload, prefix: 'vue' }); } + wrapComponent(component) { + return defineComponent({ + setup: () => { + const props = this.props; + return () => 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..06aed2b9189 100644 --- a/src/Vue/assets/src/render_controller.ts +++ b/src/Vue/assets/src/render_controller.ts @@ -8,27 +8,70 @@ */ 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); + }, + { 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 +105,25 @@ export default class extends Controller } private dispatchEvent(name: string, payload: any) { this.dispatch(name, { detail: payload, prefix: 'vue' }); } + + private wrapComponent(component: Component): Component { + return defineComponent({ + setup: () => { + const props = this.props; + + return () => + h(component, { + ...props, + ...Object.fromEntries( + Object.keys(props).map((propName) => [ + `onUpdate:${propName}`, + (value: unknown) => { + props[propName] = value; + }, + ]) + ), + }); + }, + }); + } } From b679e9c2f12579ccc6e3dd2f638818d97daf5b0f Mon Sep 17 00:00:00 2001 From: Stanislau Kviatkouski <7zete7@gmail.com> Date: Sat, 28 Sep 2024 22:18:32 +0300 Subject: [PATCH 2/5] Add tests --- .../assets/test/controller_reactivity.test.ts | 194 ++++++++++++++++++ src/Vue/assets/test/fixtures/SimpleForm.vue | 46 +++++ 2 files changed, 240 insertions(+) create mode 100644 src/Vue/assets/test/controller_reactivity.test.ts create mode 100644 src/Vue/assets/test/fixtures/SimpleForm.vue 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 @@ + + + From edf49f45a20d3900348c383c2419a532172b23df Mon Sep 17 00:00:00 2001 From: Stanislau Kviatkouski <7zete7@gmail.com> Date: Sat, 28 Sep 2024 23:51:49 +0300 Subject: [PATCH 3/5] Add docs and simple example --- src/Vue/doc/index.rst | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) 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 ---------------------- From f599fdb9e281be13ca4035516aa2b4c51836533d Mon Sep 17 00:00:00 2001 From: Stanislau Kviatkouski <7zete7@gmail.com> Date: Sun, 29 Sep 2024 00:17:29 +0300 Subject: [PATCH 4/5] Simplify component wrapper method --- src/Vue/assets/dist/render_controller.js | 24 +++++++---------- src/Vue/assets/src/render_controller.ts | 34 +++++++++++------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/Vue/assets/dist/render_controller.js b/src/Vue/assets/dist/render_controller.js index ca670ea5a14..aeb0d45e09a 100644 --- a/src/Vue/assets/dist/render_controller.js +++ b/src/Vue/assets/dist/render_controller.js @@ -57,20 +57,16 @@ class default_1 extends Controller { this.dispatch(name, { detail: payload, prefix: 'vue' }); } wrapComponent(component) { - return defineComponent({ - setup: () => { - const props = this.props; - return () => h(component, { - ...props, - ...Object.fromEntries(Object.keys(props).map((propName) => [ - `onUpdate:${propName}`, - (value) => { - props[propName] = value; - }, - ])), - }); - }, - }); + const { props } = this; + return defineComponent(() => () => h(component, { + ...props, + ...Object.fromEntries(Object.keys(props).map((propName) => [ + `onUpdate:${propName}`, + (value) => { + props[propName] = value; + }, + ])), + })); } } default_1.values = { diff --git a/src/Vue/assets/src/render_controller.ts b/src/Vue/assets/src/render_controller.ts index 06aed2b9189..ab647857952 100644 --- a/src/Vue/assets/src/render_controller.ts +++ b/src/Vue/assets/src/render_controller.ts @@ -107,23 +107,21 @@ export default class extends Controller } } private wrapComponent(component: Component): Component { - return defineComponent({ - setup: () => { - const props = this.props; - - return () => - h(component, { - ...props, - ...Object.fromEntries( - Object.keys(props).map((propName) => [ - `onUpdate:${propName}`, - (value: unknown) => { - props[propName] = value; - }, - ]) - ), - }); - }, - }); + const { props } = this; + + return defineComponent( + () => () => + h(component, { + ...props, + ...Object.fromEntries( + Object.keys(props).map((propName) => [ + `onUpdate:${propName}`, + (value: unknown) => { + props[propName] = value; + }, + ]) + ), + }) + ); } } From 937de26f3bb16e646c73ba80932b3da4d3b0b398 Mon Sep 17 00:00:00 2001 From: Stanislau Kviatkouski <7zete7@gmail.com> Date: Sun, 29 Sep 2024 00:20:48 +0300 Subject: [PATCH 5/5] Add 'props-update' event dispatching --- src/Vue/assets/dist/render_controller.js | 5 +++++ src/Vue/assets/src/render_controller.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Vue/assets/dist/render_controller.js b/src/Vue/assets/dist/render_controller.js index aeb0d45e09a..e6da838cd6a 100644 --- a/src/Vue/assets/dist/render_controller.js +++ b/src/Vue/assets/dist/render_controller.js @@ -23,6 +23,11 @@ class default_1 extends Controller { 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() { diff --git a/src/Vue/assets/src/render_controller.ts b/src/Vue/assets/src/render_controller.ts index ab647857952..96e4865b9a8 100644 --- a/src/Vue/assets/src/render_controller.ts +++ b/src/Vue/assets/src/render_controller.ts @@ -60,6 +60,11 @@ export default class extends Controller } this.props, (props) => { this.propsValue = toRaw(props); + this.dispatchEvent('props-update', { + componentName: this.componentValue, + props: this.props, + app: this.app, + }); }, { flush: 'post' } );