Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Vue/assets/dist/render_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ export default class extends Controller<Element & {
private props;
private app;
readonly componentValue: string;
readonly propsValue: Record<string, unknown> | null | undefined;
readonly hasPropsValue: boolean;
propsValue: Record<string, unknown> | 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;
}
45 changes: 42 additions & 3 deletions src/Vue/assets/dist/render_controller.js
Original file line number Diff line number Diff line change
@@ -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();
}
Expand All @@ -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,
Expand Down
76 changes: 70 additions & 6 deletions src/Vue/assets/src/render_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element & { __vue_app__?: App<Element> }> {
private props: Record<string, unknown> | null;
private props: ShallowReactive<Record<string, unknown>>;
private app: App<Element>;
declare readonly componentValue: string;
declare readonly propsValue: Record<string, unknown> | null | undefined;
declare readonly hasPropsValue: boolean;
declare propsValue: Record<string, unknown> | 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();
Expand Down Expand Up @@ -62,4 +105,25 @@ export default class extends Controller<Element & { __vue_app__?: App<Element> }
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;
},
])
),
});
},
});
}
}
194 changes: 194 additions & 0 deletions src/Vue/assets/test/controller_reactivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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(`
<div data-testid="component"
data-controller="vue"
data-vue-component-value="SimpleForm"
data-vue-props-value="{&quot;value1&quot;:&quot;Derron Macgregor&quot;,&quot;value2&quot;:&quot;Tedrick Speers&quot;,&quot;value3&quot;:&quot;Janell Highfill&quot;}" />
`);

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(`
<div data-testid="component"
data-controller="vue"
data-vue-component-value="SimpleForm"
data-vue-props-value="{&quot;value1&quot;:&quot;Marshawn Caley&quot;,&quot;value2&quot;:&quot;Ontario Hopper&quot;,&quot;value3&quot;:&quot;Latria Gibb&quot;}" />
`);

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(`
<div data-testid="component"
data-controller="vue"
data-vue-component-value="SimpleForm"
data-vue-props-value="{&quot;value1&quot;:&quot;Marshawn Caley&quot;}" />
`);

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(`
<div data-testid="component"
data-controller="vue"
data-vue-component-value="SimpleForm"
data-vue-props-value="{&quot;value1&quot;:&quot;Trista Elbert&quot;,&quot;value2&quot;:&quot;Mistina Truax&quot;,&quot;value3&quot;:&quot;Chala Paddock&quot;}" />
`);

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'));
});
});
Loading
Loading