Skip to content

Commit

Permalink
fix(VueWrapper): correctly sync wrapper classes with classes added by… (
Browse files Browse the repository at this point in the history
  • Loading branch information
chaosmirage authored Feb 21, 2025
1 parent a381c6d commit 35e2e66
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 15 deletions.
2 changes: 1 addition & 1 deletion apps/demos/Demos/Chat/AIAndChatbotIntegration/Vue/App.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<div
class="chat-container"
:class="{'dx-chat-disabled' : isDisabled == true }"
>
<DxChat
:class="{'dx-chat-disabled' : isDisabled == true }"
ref="chatElement"
:height="710"
:data-source="dataSource"
Expand Down
123 changes: 114 additions & 9 deletions packages/devextreme-vue/src/core/__tests__/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import * as events from 'devextreme/events';
import config from 'devextreme/core/config';
import {
App, createVNode, defineComponent, h, nextTick, renderSlot,
App, createVNode, defineComponent, h, nextTick, renderSlot, ref,
} from 'vue';
import { createRouter, createWebHistory } from 'vue-router';

Expand All @@ -18,8 +18,8 @@ import { getNodeOptions } from '../vue-helper';
import {
prepareComponentConfig,
prepareExtensionComponentConfig,
prepareConfigurationComponentConfig
} from "../index";
prepareConfigurationComponentConfig,
} from '../index';

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

describe('correctly forwards classes', () => {
it('forwards correct attrs in the render method', async () => {
const component = defineComponent({
template:
`
<test-component id="component" class="custom-class" :class="{'dx-chat-disabled': isDisabled}"></test-component>
<button @click="toggleDisabledState($event)">Click me</button>
`,
components: { TestComponent },
setup() {
const isDisabled = ref(false);

function toggleDisabledState() {
isDisabled.value = !isDisabled.value;
}

return { isDisabled, toggleDisabledState };
},
});

const wrapper = mount(component);

const componentContainer = wrapper.find('#component');

await wrapper.find('button').trigger('click');

expect(componentContainer.element.className).toBe('custom-class dx-chat-disabled');

const attrsPassedToVNodeInRenderMethod = wrapper.vm.$.subTree?.children?.[0]?.component?.subTree?.props?.class;

const expectedClasses = 'custom-class dx-chat-disabled';

expect(attrsPassedToVNodeInRenderMethod).toBe(expectedClasses);
expect(componentContainer.element.className).toBe(expectedClasses);
});

it('forwards correct classes when a dynamic and static attrs were defined', async () => {
const component = defineComponent({
template:
`
<test-component id="component" class="custom-class" :class="{'dx-chat-disabled': isDisabled}"></test-component>
<button @click="toggleDisabledState($event)">Click me</button>
`,
components: { TestComponent },
setup() {
const isDisabled = ref(false);

function toggleDisabledState() {
isDisabled.value = !isDisabled.value;
}

return { isDisabled, toggleDisabledState };
},
});

const wrapper = mount(component);

const componentContainer = wrapper.find('#component');

componentContainer.element.classList.add('should-be-removed-class', 'dx-chat', 'dx-hover');

await wrapper.find('button').trigger('click');

expect(componentContainer.element.className).toBe('custom-class dx-chat dx-hover dx-chat-disabled');

await wrapper.find('button').trigger('click');

expect(componentContainer.element.className).toBe('custom-class dx-chat dx-hover');
});

it('forwards correct classes when only a dynamic attr was defined', async () => {
const component = defineComponent({
template:
`
<test-component id="component" :class="{'dx-chat-disabled': isDisabled}"></test-component>
<button @click="toggleDisabledState($event)">Click me</button>
`,
components: { TestComponent },
setup() {
const isDisabled = ref(false);

function toggleDisabledState() {
isDisabled.value = !isDisabled.value;
}

return { isDisabled, toggleDisabledState };
},
});

const wrapper = mount(component);

const componentContainer = wrapper.find('#component');

componentContainer.element.classList.add('should-be-removed-class', 'dx-chat', 'dx-hover');

await wrapper.find('button').trigger('click');

expect(componentContainer.element.className).toBe('dx-chat dx-hover dx-chat-disabled');

await wrapper.find('button').trigger('click');

expect(componentContainer.element.className).toBe('dx-chat dx-hover');
});
});

it('passes styles to element', () => {
const vm = defineComponent({
template: '<test-component style=\'height: 10px; width: 20px;\'/>',
Expand Down Expand Up @@ -1213,9 +1318,9 @@ describe('component rendering', () => {

mount(vm);

const container = document.createElement("div");
const container = document.createElement('div');
renderItemTemplate({}, container);
events.triggerHandler(container.children[0], "dxremove");
events.triggerHandler(container.children[0], 'dxremove');

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

mount(vm);

const container = document.createElement("div");
const container = document.createElement('div');
renderItemTemplate({}, container);
events.triggerHandler(container.children[0], "dxremove");
events.triggerHandler(container.children[0], 'dxremove');

expect(container.children.length).toEqual(0);
});
Expand Down Expand Up @@ -1371,7 +1476,7 @@ describe('component rendering', () => {
const vm = defineComponent({
template: `<test-component>
<template #item="{ data }">
<div class='custom-class'></div>
<div class='should-be-removed-class'></div>
</template>
</test-component>`,
components: {
Expand All @@ -1382,7 +1487,7 @@ describe('component rendering', () => {
mount(vm);
const renderedTemplate = renderItemTemplate({});

expect(renderedTemplate.className).toBe(`custom-class ${DX_TEMPLATE_WRAPPER}`);
expect(renderedTemplate.className).toBe(`should-be-removed-class ${DX_TEMPLATE_WRAPPER}`);
});

it('preserves custom-attrs', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/devextreme-vue/src/core/__tests__/textbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe('two-way binding', () => {
const component = wrapper.getComponent('#component');
await wrapper.setProps({ customClass: false });
await nextTick(() => {
expect(component.element.classList.toString()).toBe(' dx-show-invalid-badge dx-textbox dx-texteditor dx-editor-outlined dx-texteditor-empty dx-widget');
expect(component.element.classList.toString()).toBe('dx-show-invalid-badge dx-textbox dx-texteditor dx-editor-outlined dx-texteditor-empty dx-widget');
});
});
});
45 changes: 41 additions & 4 deletions packages/devextreme-vue/src/core/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,31 @@ export interface IBaseComponent extends ComponentPublicInstance, IWidgetComponen
}

const includeAttrs = ['id', 'class', 'style'];
const dxClassesPrefix = 'dx-';

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

function getAttrs(attrs, dxClasses: string[]) {
function parseClassList(classList: string): string[] {
return classList.trim().split(/\s+/);
}

function prepareAttrs(attrs, dxClassesSyncedWithClassAttr: string) {
const attributes = {};
includeAttrs.forEach((attr) => {
const attrValue = attrs[attr];
if (attrValue !== undefined && attrValue !== null) {
attributes[attr] = attr === 'class' && dxClasses.length ? `${attrValue} ${dxClasses.join(' ')}` : attrValue;
if (attr === 'class') {
const nonDXClassesFromAttr = attrValue.split(' ')
.filter((classFromAttr: string) => !classFromAttr.startsWith(dxClassesPrefix) && !dxClassesSyncedWithClassAttr.includes(classFromAttr))
.join(' ');

attributes[attr] = [nonDXClassesFromAttr, dxClassesSyncedWithClassAttr].filter((item) => item !== '').join(' ');
} else {
attributes[attr] = attrValue;
}
}
});

Expand All @@ -69,6 +82,7 @@ function initBaseComponent() {
data() {
return {
eventBus: CreateCallback(),
prevClassAttr: '',
};
},

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

this.$_processChildren(children);

return h(
'div',
{
...getAttrs(this.$attrs, dxClasses),
...prepareAttrs(this.$attrs, dxClasses.join(' ')),
},
children,
);
Expand All @@ -100,6 +115,8 @@ function initBaseComponent() {
beforeUpdate() {
const thisComponent = this as any as IBaseComponent;
thisComponent.$_config.setPrevNestedOptions(thisComponent.$_config.getNestedOptionValues());

this.$_syncElementClassesWithClassAttr();
},

updated() {
Expand Down Expand Up @@ -175,6 +192,23 @@ function initBaseComponent() {
},

methods: {
$_syncElementClassesWithClassAttr(): void {
const newClassAttr = typeof this.$attrs?.class === 'string' ? this.$attrs?.class : '';

if (this.prevClassAttr === newClassAttr) {
return;
}

if (this.prevClassAttr.length) {
this.$el.classList.remove(...parseClassList(this.prevClassAttr));
}

if (newClassAttr.length) {
this.$el.classList.add(...parseClassList(newClassAttr));
}

this.prevClassAttr = newClassAttr;
},
$_applyConfigurationChanges(): void {
const thisComponent = this as any as IBaseComponent;
thisComponent.$_config.componentsCountChanged.forEach(({ optionPath, isCollection, removed }) => {
Expand Down Expand Up @@ -300,7 +334,7 @@ function cleanWidgetNode(node: Node) {
}

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

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

this.$_createWidget(this.$el);

this.$_syncElementClassesWithClassAttr();

thisComponent.$_instance.endUpdate();
restoreNodes(this.$el, nodes);

Expand Down

0 comments on commit 35e2e66

Please sign in to comment.