Skip to content

Commit 7895048

Browse files
authored
fix(VueWrapper): correctly sync wrapper classes with classes added by a JS component (DevExpress#28961)
1 parent 4f63128 commit 7895048

File tree

4 files changed

+157
-15
lines changed

4 files changed

+157
-15
lines changed

apps/demos/Demos/Chat/AIAndChatbotIntegration/Vue/App.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<template>
22
<div
33
class="chat-container"
4-
:class="{'dx-chat-disabled' : isDisabled == true }"
54
>
65
<DxChat
6+
:class="{'dx-chat-disabled' : isDisabled == true }"
77
ref="chatElement"
88
:height="710"
99
:data-source="dataSource"

packages/devextreme-vue/src/core/__tests__/component.test.ts

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
33
import * as events from 'devextreme/events';
44
import config from 'devextreme/core/config';
55
import {
6-
App, createVNode, defineComponent, h, nextTick, renderSlot,
6+
App, createVNode, defineComponent, h, nextTick, renderSlot, ref,
77
} from 'vue';
88
import { createRouter, createWebHistory } from 'vue-router';
99

@@ -18,8 +18,8 @@ import { getNodeOptions } from '../vue-helper';
1818
import {
1919
prepareComponentConfig,
2020
prepareExtensionComponentConfig,
21-
prepareConfigurationComponentConfig
22-
} from "../index";
21+
prepareConfigurationComponentConfig,
22+
} from '../index';
2323

2424
interface CustomApp extends App {
2525
test: string;
@@ -156,6 +156,111 @@ describe('component rendering', () => {
156156
expect(wrapper.element.className).toBe('my-class my-class2');
157157
});
158158

159+
describe('correctly forwards classes', () => {
160+
it('forwards correct attrs in the render method', async () => {
161+
const component = defineComponent({
162+
template:
163+
`
164+
<test-component id="component" class="custom-class" :class="{'dx-chat-disabled': isDisabled}"></test-component>
165+
<button @click="toggleDisabledState($event)">Click me</button>
166+
`,
167+
components: { TestComponent },
168+
setup() {
169+
const isDisabled = ref(false);
170+
171+
function toggleDisabledState() {
172+
isDisabled.value = !isDisabled.value;
173+
}
174+
175+
return { isDisabled, toggleDisabledState };
176+
},
177+
});
178+
179+
const wrapper = mount(component);
180+
181+
const componentContainer = wrapper.find('#component');
182+
183+
await wrapper.find('button').trigger('click');
184+
185+
expect(componentContainer.element.className).toBe('custom-class dx-chat-disabled');
186+
187+
const attrsPassedToVNodeInRenderMethod = wrapper.vm.$.subTree?.children?.[0]?.component?.subTree?.props?.class;
188+
189+
const expectedClasses = 'custom-class dx-chat-disabled';
190+
191+
expect(attrsPassedToVNodeInRenderMethod).toBe(expectedClasses);
192+
expect(componentContainer.element.className).toBe(expectedClasses);
193+
});
194+
195+
it('forwards correct classes when a dynamic and static attrs were defined', async () => {
196+
const component = defineComponent({
197+
template:
198+
`
199+
<test-component id="component" class="custom-class" :class="{'dx-chat-disabled': isDisabled}"></test-component>
200+
<button @click="toggleDisabledState($event)">Click me</button>
201+
`,
202+
components: { TestComponent },
203+
setup() {
204+
const isDisabled = ref(false);
205+
206+
function toggleDisabledState() {
207+
isDisabled.value = !isDisabled.value;
208+
}
209+
210+
return { isDisabled, toggleDisabledState };
211+
},
212+
});
213+
214+
const wrapper = mount(component);
215+
216+
const componentContainer = wrapper.find('#component');
217+
218+
componentContainer.element.classList.add('should-be-removed-class', 'dx-chat', 'dx-hover');
219+
220+
await wrapper.find('button').trigger('click');
221+
222+
expect(componentContainer.element.className).toBe('custom-class dx-chat dx-hover dx-chat-disabled');
223+
224+
await wrapper.find('button').trigger('click');
225+
226+
expect(componentContainer.element.className).toBe('custom-class dx-chat dx-hover');
227+
});
228+
229+
it('forwards correct classes when only a dynamic attr was defined', async () => {
230+
const component = defineComponent({
231+
template:
232+
`
233+
<test-component id="component" :class="{'dx-chat-disabled': isDisabled}"></test-component>
234+
<button @click="toggleDisabledState($event)">Click me</button>
235+
`,
236+
components: { TestComponent },
237+
setup() {
238+
const isDisabled = ref(false);
239+
240+
function toggleDisabledState() {
241+
isDisabled.value = !isDisabled.value;
242+
}
243+
244+
return { isDisabled, toggleDisabledState };
245+
},
246+
});
247+
248+
const wrapper = mount(component);
249+
250+
const componentContainer = wrapper.find('#component');
251+
252+
componentContainer.element.classList.add('should-be-removed-class', 'dx-chat', 'dx-hover');
253+
254+
await wrapper.find('button').trigger('click');
255+
256+
expect(componentContainer.element.className).toBe('dx-chat dx-hover dx-chat-disabled');
257+
258+
await wrapper.find('button').trigger('click');
259+
260+
expect(componentContainer.element.className).toBe('dx-chat dx-hover');
261+
});
262+
});
263+
159264
it('passes styles to element', () => {
160265
const vm = defineComponent({
161266
template: '<test-component style=\'height: 10px; width: 20px;\'/>',
@@ -1213,9 +1318,9 @@ describe('component rendering', () => {
12131318

12141319
mount(vm);
12151320

1216-
const container = document.createElement("div");
1321+
const container = document.createElement('div');
12171322
renderItemTemplate({}, container);
1218-
events.triggerHandler(container.children[0], "dxremove");
1323+
events.triggerHandler(container.children[0], 'dxremove');
12191324

12201325
expect(container.children.length).toEqual(0);
12211326
});
@@ -1234,9 +1339,9 @@ describe('component rendering', () => {
12341339

12351340
mount(vm);
12361341

1237-
const container = document.createElement("div");
1342+
const container = document.createElement('div');
12381343
renderItemTemplate({}, container);
1239-
events.triggerHandler(container.children[0], "dxremove");
1344+
events.triggerHandler(container.children[0], 'dxremove');
12401345

12411346
expect(container.children.length).toEqual(0);
12421347
});
@@ -1371,7 +1476,7 @@ describe('component rendering', () => {
13711476
const vm = defineComponent({
13721477
template: `<test-component>
13731478
<template #item="{ data }">
1374-
<div class='custom-class'></div>
1479+
<div class='should-be-removed-class'></div>
13751480
</template>
13761481
</test-component>`,
13771482
components: {
@@ -1382,7 +1487,7 @@ describe('component rendering', () => {
13821487
mount(vm);
13831488
const renderedTemplate = renderItemTemplate({});
13841489

1385-
expect(renderedTemplate.className).toBe(`custom-class ${DX_TEMPLATE_WRAPPER}`);
1490+
expect(renderedTemplate.className).toBe(`should-be-removed-class ${DX_TEMPLATE_WRAPPER}`);
13861491
});
13871492

13881493
it('preserves custom-attrs', () => {

packages/devextreme-vue/src/core/__tests__/textbox.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('two-way binding', () => {
103103
const component = wrapper.getComponent('#component');
104104
await wrapper.setProps({ customClass: false });
105105
await nextTick(() => {
106-
expect(component.element.classList.toString()).toBe(' dx-show-invalid-badge dx-textbox dx-texteditor dx-editor-outlined dx-texteditor-empty dx-widget');
106+
expect(component.element.classList.toString()).toBe('dx-show-invalid-badge dx-textbox dx-texteditor dx-editor-outlined dx-texteditor-empty dx-widget');
107107
});
108108
});
109109
});

packages/devextreme-vue/src/core/component.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,31 @@ export interface IBaseComponent extends ComponentPublicInstance, IWidgetComponen
4444
}
4545

4646
const includeAttrs = ['id', 'class', 'style'];
47+
const dxClassesPrefix = 'dx-';
4748

4849
config({
4950
buyNowLink: 'https://go.devexpress.com/Licensing_Installer_Watermark_DevExtremeVue.aspx',
5051
licensingDocLink: 'https://go.devexpress.com/Licensing_Documentation_DevExtremeVue.aspx',
5152
});
5253

53-
function getAttrs(attrs, dxClasses: string[]) {
54+
function parseClassList(classList: string): string[] {
55+
return classList.trim().split(/\s+/);
56+
}
57+
58+
function prepareAttrs(attrs, dxClassesSyncedWithClassAttr: string) {
5459
const attributes = {};
5560
includeAttrs.forEach((attr) => {
5661
const attrValue = attrs[attr];
5762
if (attrValue !== undefined && attrValue !== null) {
58-
attributes[attr] = attr === 'class' && dxClasses.length ? `${attrValue} ${dxClasses.join(' ')}` : attrValue;
63+
if (attr === 'class') {
64+
const nonDXClassesFromAttr = attrValue.split(' ')
65+
.filter((classFromAttr: string) => !classFromAttr.startsWith(dxClassesPrefix) && !dxClassesSyncedWithClassAttr.includes(classFromAttr))
66+
.join(' ');
67+
68+
attributes[attr] = [nonDXClassesFromAttr, dxClassesSyncedWithClassAttr].filter((item) => item !== '').join(' ');
69+
} else {
70+
attributes[attr] = attrValue;
71+
}
5972
}
6073
});
6174

@@ -69,6 +82,7 @@ function initBaseComponent() {
6982
data() {
7083
return {
7184
eventBus: CreateCallback(),
85+
prevClassAttr: '',
7286
};
7387
},
7488

@@ -88,10 +102,11 @@ function initBaseComponent() {
88102
pullAllChildren(defaultSlots(this), children, thisComponent.$_config);
89103

90104
this.$_processChildren(children);
105+
91106
return h(
92107
'div',
93108
{
94-
...getAttrs(this.$attrs, dxClasses),
109+
...prepareAttrs(this.$attrs, dxClasses.join(' ')),
95110
},
96111
children,
97112
);
@@ -100,6 +115,8 @@ function initBaseComponent() {
100115
beforeUpdate() {
101116
const thisComponent = this as any as IBaseComponent;
102117
thisComponent.$_config.setPrevNestedOptions(thisComponent.$_config.getNestedOptionValues());
118+
119+
this.$_syncElementClassesWithClassAttr();
103120
},
104121

105122
updated() {
@@ -175,6 +192,23 @@ function initBaseComponent() {
175192
},
176193

177194
methods: {
195+
$_syncElementClassesWithClassAttr(): void {
196+
const newClassAttr = typeof this.$attrs?.class === 'string' ? this.$attrs?.class : '';
197+
198+
if (this.prevClassAttr === newClassAttr) {
199+
return;
200+
}
201+
202+
if (this.prevClassAttr.length) {
203+
this.$el.classList.remove(...parseClassList(this.prevClassAttr));
204+
}
205+
206+
if (newClassAttr.length) {
207+
this.$el.classList.add(...parseClassList(newClassAttr));
208+
}
209+
210+
this.prevClassAttr = newClassAttr;
211+
},
178212
$_applyConfigurationChanges(): void {
179213
const thisComponent = this as any as IBaseComponent;
180214
thisComponent.$_config.componentsCountChanged.forEach(({ optionPath, isCollection, removed }) => {
@@ -300,7 +334,7 @@ function cleanWidgetNode(node: Node) {
300334
}
301335

302336
function pickOutDxClasses(el: Element) {
303-
return el && Array.from(el.classList).filter((item: string) => item.startsWith('dx-'));
337+
return el && Array.from(el.classList).filter((item: string) => item.startsWith(dxClassesPrefix));
304338
}
305339

306340
function restoreNodes(el: Element, nodes: Element[]) {
@@ -335,6 +369,9 @@ function initDxComponent() {
335369
const thisComponent = this as any as IBaseComponent;
336370

337371
this.$_createWidget(this.$el);
372+
373+
this.$_syncElementClassesWithClassAttr();
374+
338375
thisComponent.$_instance.endUpdate();
339376
restoreNodes(this.$el, nodes);
340377

0 commit comments

Comments
 (0)