diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/m_common.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/m_common.test.ts new file mode 100644 index 000000000000..c61d96033e50 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/m_common.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from '@jest/globals'; +import { equalByValue } from '@js/core/utils/common'; + +const createNestedValue = (leaf: unknown): Record => ({ a: { b: { c: leaf } } }); + +const createSelfReferencingObject = (id: number): Record => { + const value: Record = { id }; + + value.self = value; + + return value; +}; + +describe('Common utils', () => { + describe('equalByValue', () => { + it('should treat values deeper than maxDepth as equal by default', () => { + expect(equalByValue(createNestedValue(1), createNestedValue(2))).toBe(true); + }); + + it('should compare values at any depth when maxDepth is Infinity', () => { + const options = { maxDepth: Infinity }; + + expect(equalByValue(createNestedValue(1), createNestedValue(2), options)).toBe(false); + expect(equalByValue(createNestedValue(1), createNestedValue(1), options)).toBe(true); + }); + + it('should compare self-referencing objects by content when maxDepth is Infinity', () => { + const options = { maxDepth: Infinity }; + + expect(equalByValue( + createSelfReferencingObject(1), + createSelfReferencingObject(1), + options, + )).toBe(true); + expect(equalByValue( + createSelfReferencingObject(1), + createSelfReferencingObject(2), + options, + )).toBe(false); + }); + + it('should not depend on the property order when loose comparison reaches strict array items with maxDepth Infinity', () => { + const options = { maxDepth: Infinity, strict: false }; + const numberItem = { id: 1 }; + const stringItem = { id: '1' }; + + expect(equalByValue( + { loose: numberItem, items: [numberItem] }, + { loose: stringItem, items: [stringItem] }, + options, + )).toBe(false); + expect(equalByValue( + { items: [numberItem], loose: numberItem }, + { items: [stringItem], loose: stringItem }, + options, + )).toBe(false); + }); + + it('should compare non-plain objects by reference when comparePlainObjectsOnly is true', () => { + const map = new Map([[1, 'a']]); + const options = { comparePlainObjectsOnly: true }; + + expect(equalByValue(map, new Map([[1, 'a']]), options)).toBe(false); + expect(equalByValue(map, map, options)).toBe(true); + }); + + it('should still compare plain objects by content when comparePlainObjectsOnly is true', () => { + const options = { comparePlainObjectsOnly: true }; + + expect(equalByValue({ id: 1, tags: [1, 2] }, { id: 1, tags: [1, 2] }, options)).toBe(true); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/core/utils/m_common.ts b/packages/devextreme/js/__internal/core/utils/m_common.ts index 005f24444ba7..e71066fda264 100644 --- a/packages/devextreme/js/__internal/core/utils/m_common.ts +++ b/packages/devextreme/js/__internal/core/utils/m_common.ts @@ -4,7 +4,7 @@ import { toComparable } from '@js/core/utils/data'; import { Deferred, when } from '@js/core/utils/deferred'; import { each } from '@js/core/utils/iterator'; import { - isDefined, isFunction, isObject, isString, type, + isDefined, isFunction, isObject, isPlainObject, isString, type, } from '@js/core/utils/type'; // @ts-expect-error new deferred @@ -285,18 +285,20 @@ export const grep = function (elements, checkFunction, invert?) { return result; }; -const compareArrays = (array1, array2, depth, options) => { +const compareArrays = (array1, array2, depth, options, comparedPairs) => { if (array1.length !== array2.length) { return false; } + const strictComparedPairs = options.strict ? comparedPairs : comparedPairs && new Map(); + return !array1.some((item, idx) => !compareByValue(item, array2[idx], depth + 1, { ...options, strict: true, - })); + }, strictComparedPairs)); }; -const compareObjects = (object1, object2, depth, options) => { +const compareObjects = (object1, object2, depth, options, comparedPairs) => { const keys1 = Object.keys(object1); const keys2 = Object.keys(object2); @@ -305,15 +307,28 @@ const compareObjects = (object1, object2, depth, options) => { } const keys2Set = new Set(keys2); - return !keys1.some((key) => !keys2Set.has(key) || !compareByValue(object1[key], object2[key], depth + 1, options)); + return !keys1.some((key) => !keys2Set.has(key) + || !compareByValue(object1[key], object2[key], depth + 1, options, comparedPairs)); +}; + +const isPairCompared = (comparedPairs, value1, value2) => comparedPairs?.get(value1)?.has(value2) ?? false; + +const addComparedPair = (comparedPairs, value1, value2) => { + if (!comparedPairs) { + return; + } + + const pairedValues = comparedPairs.get(value1) ?? new Set(); + pairedValues.add(value2); + comparedPairs.set(value1, pairedValues); }; const DEFAULT_EQUAL_BY_VALUE_OPTS = { maxDepth: 3, strict: true, }; -const compareByValue = (value1, value2, depth, options) => { - const { strict, maxDepth } = options; +const compareByValue = (value1, value2, depth, options, comparedPairs) => { + const { strict, maxDepth, comparePlainObjectsOnly = false } = options; const comparable1 = toComparable(value1, true); const comparable2 = toComparable(value2, true); @@ -325,11 +340,16 @@ const compareByValue = (value1, value2, depth, options) => { switch (true) { case comparisonResult: case depth >= maxDepth: + case isPairCompared(comparedPairs, comparable1, comparable2): return true; - case isObject(comparable1) && isObject(comparable2): - return compareObjects(comparable1, comparable2, depth, options); + case comparePlainObjectsOnly + ? isPlainObject(comparable1) && isPlainObject(comparable2) + : isObject(comparable1) && isObject(comparable2): + addComparedPair(comparedPairs, comparable1, comparable2); + return compareObjects(comparable1, comparable2, depth, options, comparedPairs); case Array.isArray(comparable1) && Array.isArray(comparable2): - return compareArrays(comparable1, comparable2, depth, options); + addComparedPair(comparedPairs, comparable1, comparable2); + return compareArrays(comparable1, comparable2, depth, options, comparedPairs); default: return false; } @@ -337,7 +357,9 @@ const compareByValue = (value1, value2, depth, options) => { export const equalByValue = (value1, value2, options = DEFAULT_EQUAL_BY_VALUE_OPTS) => { const compareOptions = { ...DEFAULT_EQUAL_BY_VALUE_OPTS, ...options }; - return compareByValue(value1, value2, 0, compareOptions); + const comparedPairs = Number.isFinite(compareOptions.maxDepth) ? undefined : new Map(); + + return compareByValue(value1, value2, 0, compareOptions, comparedPairs); }; export default { diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tag_box.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tag_box.ts index ed429b4432b0..39f7cca44c35 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tag_box.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tag_box.ts @@ -2,6 +2,8 @@ import type dxTagBox from '@js/ui/tag_box'; import TagBox from '@js/ui/tag_box'; const TAG_CLASS = 'dx-tag'; +const LIST_ITEM_CLASS = 'dx-list-item'; +const SELECTED_LIST_ITEM_CLASS = 'dx-list-item-selected'; export class TagBoxModel { constructor(protected readonly root: HTMLElement) {} @@ -25,4 +27,16 @@ export class TagBoxModel { public getTags(): NodeListOf { return this.root.querySelectorAll(`.${TAG_CLASS}`); } + + public getListItems(): NodeListOf { + return this.getInstance().content().querySelectorAll(`.${LIST_ITEM_CLASS}`); + } + + public getSelectedListItems(): NodeListOf { + return this.getInstance().content().querySelectorAll(`.${SELECTED_LIST_ITEM_CLASS}`); + } + + public clickListItem(index: number): void { + this.getListItems()[index].click(); + } } diff --git a/packages/devextreme/js/__internal/ui/__tests__/drop_down_box.test.ts b/packages/devextreme/js/__internal/ui/__tests__/drop_down_box.test.ts index 76edfafc4702..02427426355d 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/drop_down_box.test.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/drop_down_box.test.ts @@ -3,6 +3,7 @@ import { } from '@jest/globals'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import { equalByValue } from '@js/core/utils/common'; import { getOuterWidth } from '@js/core/utils/size'; import type { Properties } from '@js/ui/drop_down_box'; import DropDownBox from '@js/ui/drop_down_box'; @@ -10,6 +11,62 @@ import DropDownBox from '@js/ui/drop_down_box'; import { DropDownEditorModel } from './__mock__/model/drop_down_editor'; const FIELD_WIDTH = 200; +const MAX_PROBED_CHAIN_DEPTH = 1_000_000; + +interface NonPlainValueScenario { + name: string; + createValue: (key: number) => unknown; +} + +class KeyHolder { + readonly #key: number; + + constructor(key: number) { + this.#key = key; + } + + getKey(): number { + return this.#key; + } +} + +const nonPlainValueScenarios: NonPlainValueScenario[] = [ + { name: 'Map', createValue: (key) => new Map([[key, 'value']]) }, + { name: 'Set', createValue: (key) => new Set([key]) }, + { name: 'RegExp', createValue: (key) => new RegExp(String(key)) }, + { name: 'File', createValue: (key) => new File([String(key)], 'file.txt') }, + { name: 'class instance with a private field', createValue: (key) => new KeyHolder(key) }, +]; + +const createChain = (depth: number, leaf: number): Record => { + let node: Record = { leaf }; + + for (let level = 0; level < depth; level += 1) { + node = { next: node }; + } + + return node; +}; + +const isChainTooDeepToCompare = (depth: number): boolean => { + try { + equalByValue(createChain(depth, 1), createChain(depth, 1), { maxDepth: Infinity }); + + return false; + } catch { + return true; + } +}; + +const findChainDepthTooDeepToCompare = (): number => { + let depth = 1024; + + while (depth < MAX_PROBED_CHAIN_DEPTH && !isChainTooDeepToCompare(depth)) { + depth *= 2; + } + + return depth; +}; const dropDownBoxes: DropDownBox[] = []; @@ -56,4 +113,63 @@ describe('DropDownBox', () => { expect(getOuterWidth(dropDownBox.getOverlay().getElement())).toBe(FIELD_WIDTH); }); }); + + describe('object value (T1336605)', () => { + const OBJECT_ITEMS = [{ id: 1, text: 'Item one' }, { id: 2, text: 'Item two' }]; + const OBJECT_OPTIONS = { dataSource: OBJECT_ITEMS, valueExpr: 'this' }; + + it('should restore the initial object on reset after another object was selected', () => { + createDropDownBox({ ...OBJECT_OPTIONS, value: OBJECT_ITEMS[0] }); + const [instance] = dropDownBoxes; + + instance.option('value', OBJECT_ITEMS[1]); + + expect(instance.option('isDirty')).toBe(true); + + instance.reset(); + + expect(instance.option('value')).toEqual(OBJECT_ITEMS[0]); + expect(instance.option('isDirty')).toBe(false); + }); + + it('should be dirty when a field deeper than three levels changes', () => { + createDropDownBox({ valueExpr: 'this', value: { a: { b: { c: 1 } } } }); + const [instance] = dropDownBoxes; + + instance.option('value', { a: { b: { c: 2 } } }); + + expect(instance.option('isDirty')).toBe(true); + }); + + it.each(nonPlainValueScenarios)('should be dirty when the value changes to another $name', ({ createValue }) => { + createDropDownBox({ valueExpr: 'this', value: createValue(1) }); + const [instance] = dropDownBoxes; + + instance.option('value', createValue(2)); + + expect(instance.option('isDirty')).toBe(true); + }); + + it('should fall back to reference comparison for a value too deep to compare by content', () => { + const depth = findChainDepthTooDeepToCompare(); + + expect(isChainTooDeepToCompare(depth)).toBe(true); + + createDropDownBox({ valueExpr: 'this', value: [createChain(depth, 1)] }); + const [instance] = dropDownBoxes; + + instance.option('value', [createChain(depth, 1)]); + + expect(instance.option('isDirty')).toBe(true); + }); + + it('should replace the object value instead of merging the fields of the previous one', () => { + createDropDownBox({ ...OBJECT_OPTIONS, value: OBJECT_ITEMS[0] }); + const [instance] = dropDownBoxes; + + instance.option('value', { id: 2 }); + + expect(instance.option('value')).toEqual({ id: 2 }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/ui/__tests__/file_uploader.test.ts b/packages/devextreme/js/__internal/ui/__tests__/file_uploader.test.ts new file mode 100644 index 000000000000..f6cb7a3824df --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/file_uploader.test.ts @@ -0,0 +1,36 @@ +import { + afterEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import type { Properties } from '@js/ui/file_uploader'; +import FileUploader from '@js/ui/file_uploader'; + +const instances: FileUploader[] = []; + +const createFileUploader = (options: Partial = {}): FileUploader => { + const $element = $('
').appendTo(document.body); + + const instance = new FileUploader($element.get(0) as HTMLElement, options); + + instances.push(instance); + + return instance; +}; + +describe('FileUploader', () => { + afterEach(() => { + instances.forEach((instance) => instance.dispose()); + instances.length = 0; + document.body.innerHTML = ''; + }); + + describe('isDirty (T1336605)', () => { + it('should be true after a file is replaced by another file with the same content', () => { + const fileUploader = createFileUploader({ value: [new File(['content'], 'file.txt')] }); + + fileUploader.option('value', [new File(['content'], 'file.txt')]); + + expect(fileUploader.option('isDirty')).toBe(true); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/__tests__/tag_box.test.ts b/packages/devextreme/js/__internal/ui/__tests__/tag_box.test.ts new file mode 100644 index 000000000000..999cd597bcd5 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/tag_box.test.ts @@ -0,0 +1,209 @@ +import { + afterEach, beforeAll, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import type { DeferredObj } from '@js/core/utils/deferred'; +import { Deferred } from '@js/core/utils/deferred'; +import CustomStore from '@js/data/custom_store'; +import type { Properties as FormProperties } from '@js/ui/form'; +import Form from '@js/ui/form'; +import type { Properties } from '@js/ui/tag_box'; +import TagBox from '@js/ui/tag_box'; + +import { TagBoxModel } from './__mock__/model/tag_box'; + +interface Employee { + ID: number; + FullName: string; +} + +const EMPLOYEES: Employee[] = [ + { ID: 1, FullName: 'John Heart' }, + { ID: 2, FullName: 'Samantha Bright' }, + { ID: 3, FullName: 'Arthur Miller' }, +]; + +const LOAD_DELAY = 100; + +const resolveLater = (result: T): DeferredObj => { + const deferred = Deferred(); + + setTimeout(() => { + deferred.resolve(result); + }, LOAD_DELAY); + + return deferred; +}; + +const createDelayedStore = (): CustomStore => new CustomStore({ + key: 'ID', + load: () => resolveLater(EMPLOYEES).promise(), + byKey: (key: number) => resolveLater(EMPLOYEES.find((employee) => employee.ID === key)).promise(), +}); + +const tagBoxes: TagBox[] = []; + +const createTagBox = (options: Partial = {}): TagBoxModel => { + const $element = $('
').appendTo(document.body); + + const instance = new TagBox($element.get(0) as HTMLElement, { + dataSource: createDelayedStore(), + valueExpr: 'ID', + displayExpr: 'FullName', + showSelectionControls: true, + deferRendering: false, + ...options, + }); + + tagBoxes.push(instance); + + return new TagBoxModel($element.get(0) as HTMLElement); +}; + +const forms: Form[] = []; + +const createFormWithTagBox = (options: Partial = {}): Form => { + const $element = $('
').appendTo(document.body); + + const form = new Form($element.get(0) as HTMLElement, { + items: [{ + dataField: 'employees', + editorType: 'dxTagBox', + editorOptions: { + dataSource: createDelayedStore(), + valueExpr: 'ID', + displayExpr: 'FullName', + showSelectionControls: true, + deferRendering: false, + }, + }], + ...options, + }); + + forms.push(form); + + return form; +}; + +describe('TagBox', () => { + beforeAll(() => { + fx.off = true; + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + tagBoxes.forEach((instance) => instance.dispose()); + tagBoxes.length = 0; + forms.forEach((form) => form.dispose()); + forms.length = 0; + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + describe('isDirty when the value order differs from the data order (T1336605)', () => { + it('should stay false without replacing the value after the delayed data load selects the value items in the list', () => { + const tagBox = createTagBox({ value: [3, 2] }); + const initialValue = tagBox.getValue(); + + jest.runAllTimers(); + + const tagTexts = Array.from(tagBox.getTags()).map((tag) => tag.textContent); + + expect(tagTexts).toEqual(['Arthur Miller', 'Samantha Bright']); + expect(tagBox.getSelectedListItems()).toHaveLength(2); + expect(tagBox.getValue()).toBe(initialValue); + expect(tagBox.getInstance().option('isDirty')).toBe(false); + }); + + it('should not raise onValueChanged when the delayed data load selects the value items in the list', () => { + const valueChangedHandler = jest.fn(); + const tagBox = createTagBox({ value: [3, 2], onValueChanged: valueChangedHandler }); + + jest.runAllTimers(); + + expect(tagBox.getSelectedListItems()).toHaveLength(2); + expect(valueChangedHandler).not.toHaveBeenCalled(); + }); + }); + + describe('isDirty in other scenarios of the delayed data load (T1336605)', () => { + it('should stay false without replacing the value when the list is first rendered on open', () => { + const valueChangedHandler = jest.fn(); + const tagBox = createTagBox({ + value: [3, 2], + deferRendering: true, + onValueChanged: valueChangedHandler, + }); + const initialValue = tagBox.getValue(); + + jest.runAllTimers(); + tagBox.getInstance().open(); + jest.runAllTimers(); + + expect(tagBox.getSelectedListItems()).toHaveLength(2); + expect(tagBox.getValue()).toBe(initialValue); + expect(tagBox.getInstance().option('isDirty')).toBe(false); + expect(valueChangedHandler).not.toHaveBeenCalled(); + }); + + it('should stay false without replacing the value for object values with search enabled', () => { + const tagBox = createTagBox({ + value: [{ ...EMPLOYEES[2] }, { ...EMPLOYEES[1] }], + valueExpr: 'this', + searchEnabled: true, + }); + const initialValue = tagBox.getValue(); + + jest.runAllTimers(); + + const tagTexts = Array.from(tagBox.getTags()).map((tag) => tag.textContent); + + expect(tagTexts).toEqual(['Arthur Miller', 'Samantha Bright']); + expect(tagBox.getSelectedListItems()).toHaveLength(2); + expect(tagBox.getValue()).toBe(initialValue); + expect(tagBox.getInstance().option('isDirty')).toBe(false); + }); + + it('should keep the form pristine without replacing the field value inside dxForm', () => { + const initialValue = [3, 2]; + const form = createFormWithTagBox({ formData: { employees: initialValue } }); + + jest.runAllTimers(); + + const tagBox = new TagBoxModel(form.getEditor('employees')?.element() as HTMLElement); + + expect(tagBox.getSelectedListItems()).toHaveLength(2); + expect(tagBox.getValue()).toBe(initialValue); + expect(form.option('isDirty')).toBe(false); + }); + + it('should be dirty and raise onValueChanged once when another item is selected after the load', () => { + const valueChangedHandler = jest.fn(); + const tagBox = createTagBox({ value: [3, 2], onValueChanged: valueChangedHandler }); + + jest.runAllTimers(); + tagBox.clickListItem(0); + + expect(tagBox.getValue()).toEqual([3, 2, 1]); + expect(tagBox.getInstance().option('isDirty')).toBe(true); + expect(valueChangedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('value with a key missing from the data (T1336605)', () => { + it('should keep every value item after the delayed data load', () => { + const tagBox = createTagBox({ value: [2, 99] }); + + jest.runAllTimers(); + + const tagTexts = Array.from(tagBox.getTags()).map((tag) => tag.textContent); + + expect(tagBox.getValue()).toEqual([2, 99]); + expect(tagTexts).toEqual(['Samantha Bright']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_editor.ts b/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_editor.ts index e29b3e7d6b00..3f9853d9d5f4 100644 --- a/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_editor.ts +++ b/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_editor.ts @@ -250,6 +250,14 @@ class DropDownEditor< }; } + _setOptionsByReference(): void { + super._setOptionsByReference(); + + extend(this._optionsByReference, { + value: true, + }); + } + _useTemplates(): boolean { return true; } diff --git a/packages/devextreme/js/__internal/ui/editor/__tests__/editor.is_dirty.test.ts b/packages/devextreme/js/__internal/ui/editor/__tests__/editor.is_dirty.test.ts new file mode 100644 index 000000000000..ea5e1becbe4b --- /dev/null +++ b/packages/devextreme/js/__internal/ui/editor/__tests__/editor.is_dirty.test.ts @@ -0,0 +1,178 @@ +import { + afterEach, beforeAll, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { componentRegistratorCallbacks } from '@ts/core/component_registrator_callbacks'; +import { readdirSync } from 'fs'; +import { + join, relative, resolve, sep, +} from 'path'; + +interface EditorInstance { + option: (name: string, value?: unknown) => unknown; + dispose: () => void; +} + +type EditorClass = new (element: HTMLElement, options: Record) => EditorInstance; + +interface DirtyScenario { + options?: Record; + createValue: () => unknown; + changedValue: unknown; +} + +const PUBLIC_UI_DIRECTORY = resolve(__dirname, '../../../../ui'); +const MODULES_FAILING_TO_LOAD_IN_JSDOM = ['diagram.js', 'diagram/ui.diagram.js', 'gantt.js']; +const COMPONENTS_NOT_UPDATING_IS_DIRTY = ['dxDateView']; + +const ITEMS = [{ id: 1, text: 'one' }, { id: 2, text: 'two' }]; +const LIST_OPTIONS = { items: ITEMS, displayExpr: 'text' }; +const ATTACHED_FILE = new File(['content'], 'file.txt'); + +const copyItem = (index: number): Record => ({ ...ITEMS[index] }); + +const textScenario: DirtyScenario = { createValue: () => 'one', changedValue: 'two' }; +const numberScenario: DirtyScenario = { createValue: () => 10, changedValue: 20 }; +const booleanScenario: DirtyScenario = { createValue: () => true, changedValue: false }; +const colorScenario: DirtyScenario = { createValue: () => '#ff0000', changedValue: '#00ff00' }; +const dateScenario: DirtyScenario = { + createValue: () => new Date(2026, 9, 12), + changedValue: new Date(2026, 9, 13), +}; +const objectItemScenario: DirtyScenario = { + options: LIST_OPTIONS, + createValue: () => ITEMS[0], + changedValue: ITEMS[1], +}; + +const scenarios: Record = { + dxAutocomplete: textScenario, + dxCalendar: dateScenario, + dxCheckBox: booleanScenario, + dxColorBox: colorScenario, + dxColorView: colorScenario, + dxDateBox: dateScenario, + dxDateRangeBox: { + createValue: () => [new Date(2026, 9, 12), new Date(2026, 9, 13)], + changedValue: [new Date(2026, 9, 14), new Date(2026, 9, 15)], + }, + dxDropDownBox: { + options: { dataSource: ITEMS, displayExpr: 'text' }, + createValue: () => ITEMS[0], + changedValue: ITEMS[1], + }, + dxDropDownEditor: { createValue: () => ITEMS[0], changedValue: ITEMS[1] }, + dxDropDownList: objectItemScenario, + dxFileUploader: { createValue: () => [ATTACHED_FILE], changedValue: [] }, + dxHtmlEditor: { createValue: () => '

one

', changedValue: '

two

' }, + dxLookup: objectItemScenario, + dxNumberBox: numberScenario, + dxProgressBar: numberScenario, + dxRadioButton: booleanScenario, + dxRadioGroup: objectItemScenario, + dxRangeSlider: { createValue: () => [10, 50], changedValue: [20, 60] }, + dxSelectBox: objectItemScenario, + dxSlider: numberScenario, + dxSwitch: booleanScenario, + dxTagBox: { options: LIST_OPTIONS, createValue: () => [copyItem(0)], changedValue: [ITEMS[1]] }, + dxTextArea: textScenario, + dxTextBox: textScenario, + dxTextEditor: textScenario, + dxTimeView: dateScenario, + dxTrackBar: numberScenario, +}; + +const registeredComponents: Record = {}; +const modulesFailedToLoad: string[] = []; +const editors: EditorInstance[] = []; + +const listModuleFiles = (directory: string): string[] => readdirSync( + directory, + { withFileTypes: true }, +).flatMap((entry) => { + const entryPath = join(directory, entry.name); + + if (entry.isDirectory()) { + return listModuleFiles(entryPath); + } + + return entry.name.endsWith('.js') ? [entryPath] : []; +}); + +const loadPublicUiModules = (): void => { + listModuleFiles(PUBLIC_UI_DIRECTORY).forEach((file) => { + try { + jest.requireActual(file); + } catch { + modulesFailedToLoad.push(relative(PUBLIC_UI_DIRECTORY, file).split(sep).join('/')); + } + }); + + componentRegistratorCallbacks.add((name: string, componentClass: EditorClass) => { + registeredComponents[name] = componentClass; + }); +}; + +const hasDirtyTrackedValue = (componentClass: EditorClass): boolean => { + const prototype = componentClass.prototype as { _getDefaultOptions: () => object }; + const defaultOptions = Object.create(prototype)._getDefaultOptions() as object; + + return 'value' in defaultOptions && 'isDirty' in defaultOptions; +}; + +const createEditor = (name: string, options: Record): EditorInstance => { + const $container = $('
').appendTo(document.body); + const element = $('
').appendTo($container).get(0) as HTMLElement; + const instance = new registeredComponents[name](element, options); + + editors.push(instance); + + return instance; +}; + +describe('Editors isDirty when the value returns to its initial content (T1336605)', () => { + beforeAll(() => { + loadPublicUiModules(); + }); + + afterEach(() => { + editors.forEach((instance) => instance.dispose()); + editors.length = 0; + document.body.innerHTML = ''; + }); + + it('should load every public ui module except the ones known to fail in jsdom', () => { + expect(modulesFailedToLoad.sort()).toEqual(MODULES_FAILING_TO_LOAD_IN_JSDOM); + }); + + it('should cover every component with value and isDirty options', () => { + const dirtyTrackedComponents = Object.keys(registeredComponents) + .filter((name) => hasDirtyTrackedValue(registeredComponents[name])) + .sort(); + const coveredComponents = [...Object.keys(scenarios), ...COMPONENTS_NOT_UPDATING_IS_DIRTY]; + + expect(coveredComponents.sort()).toEqual(dirtyTrackedComponents); + }); + + it.each(COMPONENTS_NOT_UPDATING_IS_DIRTY)('%s should keep isDirty false because it does not pass value changes to Editor', (name) => { + const instance = createEditor(name, { value: dateScenario.createValue() }); + + instance.option('value', dateScenario.changedValue); + + expect(instance.option('value')).toEqual(dateScenario.changedValue); + expect(instance.option('isDirty')).toBe(false); + }); + + it.each(Object.keys(scenarios))('%s should be dirty after a change and pristine after an equal value returns', (name) => { + const { options, createValue, changedValue } = scenarios[name]; + const instance = createEditor(name, { ...options, value: createValue() }); + + instance.option('value', changedValue); + + expect(instance.option('isDirty')).toBe(true); + + instance.option('value', createValue()); + + expect(instance.option('isDirty')).toBe(false); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/editor/__tests__/editor.test.ts b/packages/devextreme/js/__internal/ui/editor/__tests__/editor.test.ts index db6c31b08702..883be51b4fd2 100644 --- a/packages/devextreme/js/__internal/ui/editor/__tests__/editor.test.ts +++ b/packages/devextreme/js/__internal/ui/editor/__tests__/editor.test.ts @@ -15,6 +15,53 @@ interface ValidationRequestArgs { editor: unknown; } +interface ReturnScenario { + scenario: string; + createValue: () => unknown; + changedValue: unknown; +} + +const createSelfReferencingObject = (id: number): Record => { + const value: Record = { id }; + + value.self = value; + + return value; +}; + +const returnScenarios: ReturnScenario[] = [ + { + scenario: 'an array', + createValue: () => [1, 2], + changedValue: [3], + }, + { + scenario: 'an array of plain objects with nested arrays', + createValue: () => [{ name: 'a', tags: [1, { id: 2 }] }], + changedValue: [{ name: 'b', tags: [] }], + }, + { + scenario: 'a date', + createValue: () => new Date(2026, 9, 12), + changedValue: new Date(2026, 9, 13), + }, + { + scenario: 'an array of dates', + createValue: () => [new Date(2026, 9, 12), new Date(2026, 9, 13)], + changedValue: [], + }, + { + scenario: 'an array with an object nested deeper than three levels', + createValue: () => [{ a: { b: { c: { d: 1 } } } }], + changedValue: [], + }, + { + scenario: 'an array with an object that references itself', + createValue: () => [createSelfReferencingObject(1)], + changedValue: [], + }, +]; + const disposables: { dispose: () => void }[] = []; const createEditor = (options: Partial = {}): Editor => { @@ -74,3 +121,60 @@ describe('Editor initialization', () => { expect(calls[0].editor).toBe(instance); }); }); + +describe('Editor isDirty when the value returns to its initial content (T1336605)', () => { + afterEach(() => { + disposables.forEach((instance) => instance.dispose()); + disposables.length = 0; + document.body.innerHTML = ''; + }); + + it.each(returnScenarios)('should be false after $scenario returns as a new copy', ({ createValue, changedValue }) => { + const instance = createEditor({ value: createValue() }); + + instance.option('value', changedValue); + + expect(instance.option('isDirty')).toBe(true); + + instance.option('value', createValue()); + + expect(instance.option('isDirty')).toBe(false); + }); + + it('should be false when the same invalid date object returns', () => { + const invalidDate = new Date(NaN); + const instance = createEditor({ value: invalidDate }); + + instance.option('value', new Date(2026, 9, 15)); + + expect(instance.option('isDirty')).toBe(true); + + instance.option('value', invalidDate); + + expect(instance.option('isDirty')).toBe(false); + }); + + it('should be true when array items change their order', () => { + const instance = createEditor({ value: [1, 2] }); + + instance.option('value', [2, 1]); + + expect(instance.option('isDirty')).toBe(true); + }); + + it('should be true when an object in an array differs deeper than three levels', () => { + const instance = createEditor({ value: [{ a: { b: { c: 1 } } }] }); + + instance.option('value', [{ a: { b: { c: 2 } } }]); + + expect(instance.option('isDirty')).toBe(true); + }); + + it('should be true when a self-referencing object in an array changes', () => { + const instance = createEditor({ value: [createSelfReferencingObject(1)] }); + + instance.option('value', [createSelfReferencingObject(2)]); + + expect(instance.option('isDirty')).toBe(true); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/editor/editor.ts b/packages/devextreme/js/__internal/ui/editor/editor.ts index d88438069ce7..47d8332d9001 100644 --- a/packages/devextreme/js/__internal/ui/editor/editor.ts +++ b/packages/devextreme/js/__internal/ui/editor/editor.ts @@ -7,7 +7,9 @@ import Guid from '@js/core/guid'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import Callbacks from '@js/core/utils/callbacks'; +import { equalByValue } from '@js/core/utils/common'; import { extend } from '@js/core/utils/extend'; +import { isPlainObject } from '@js/core/utils/type'; import { hasWindow } from '@js/core/utils/window'; import type { DxEvent } from '@js/events'; import type PublicEditor from '@js/ui/editor/editor'; @@ -440,7 +442,7 @@ class Editor< break; case 'value': if (value != previousValue) { // eslint-disable-line eqeqeq - this.option('isDirty', this._initialValue !== value); + this.option('isDirty', !this._isSameAsInitialValue(value)); this.validationRequest.fire({ value, @@ -491,6 +493,27 @@ class Editor< } } + _isSameAsInitialValue(value: unknown): boolean { + const initialValue = this._initialValue; + + if (initialValue === value) { + return true; + } + + if (isPlainObject(initialValue) || isPlainObject(value)) { + return false; + } + + try { + return equalByValue(initialValue, value, { + maxDepth: Infinity, + comparePlainObjectsOnly: true, + }); + } catch { + return initialValue === value; + } + } + _resetToInitialValue(): void { this.option('value', this._initialValue); } diff --git a/packages/devextreme/js/__internal/ui/form/__tests__/form.is_dirty.test.ts b/packages/devextreme/js/__internal/ui/form/__tests__/form.is_dirty.test.ts new file mode 100644 index 000000000000..b006455bc074 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/form/__tests__/form.is_dirty.test.ts @@ -0,0 +1,142 @@ +import '@js/ui/calendar'; +import '@js/ui/date_box'; +import '@js/ui/date_range_box'; +import '@js/ui/drop_down_box'; +import '@js/ui/lookup'; +import '@js/ui/radio_group'; +import '@js/ui/range_slider'; +import '@js/ui/select_box'; +import '@js/ui/tag_box'; + +import { + afterEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import type { FormItemComponent, Properties } from '@js/ui/form'; +import Form from '@js/ui/form'; + +interface Item { + id: number; + text: string; +} + +interface ObjectUpdateScenario { + editorType: FormItemComponent; + way: string; + update: (form: Form, value: Item) => void; +} + +interface ReturnScenario { + editorType: FormItemComponent; + editorOptions?: Record; + createValue: () => unknown; + changedValue: unknown; +} + +const FIELD = 'field'; + +const OBJECT_VALUE_EDITORS: FormItemComponent[] = ['dxSelectBox', 'dxLookup', 'dxDropDownBox', 'dxRadioGroup']; + +const updateWays: Pick[] = [ + { way: 'updateData', update: (form, value): void => { form.updateData(FIELD, value); } }, + { way: 'option(formData.field)', update: (form, value): void => { form.option(`formData.${FIELD}`, value); } }, +]; + +const objectUpdateScenarios: ObjectUpdateScenario[] = OBJECT_VALUE_EDITORS + .flatMap((editorType) => updateWays.map((way) => ({ editorType, ...way }))); + +const returnScenarios: ReturnScenario[] = [ + { + editorType: 'dxCalendar', + createValue: () => new Date(2026, 9, 12), + changedValue: new Date(2026, 9, 13), + }, + { + editorType: 'dxDateBox', + createValue: () => new Date(2026, 9, 12), + changedValue: new Date(2026, 9, 13), + }, + { + editorType: 'dxTagBox', + editorOptions: { items: ['one', 'two'] }, + createValue: () => ['one'], + changedValue: ['two'], + }, + { + editorType: 'dxDropDownBox', + editorOptions: { dataSource: [{ id: 1 }, { id: 2 }], valueExpr: 'id' }, + createValue: () => [1], + changedValue: [2], + }, + { + editorType: 'dxRangeSlider', + createValue: () => [10, 50], + changedValue: [20, 60], + }, + { + editorType: 'dxDateRangeBox', + createValue: () => [new Date(2026, 9, 12), new Date(2026, 9, 13)], + changedValue: [new Date(2026, 9, 14), new Date(2026, 9, 15)], + }, +]; + +const createItems = (): Item[] => [{ id: 1, text: 'one' }, { id: 2, text: 'two' }]; + +const forms: Form[] = []; + +const createForm = (options: Partial): Form => { + const $element = $('
').appendTo(document.body); + const form = new Form($element.get(0) as HTMLElement, options); + + forms.push(form); + + return form; +}; + +describe('Form', () => { + afterEach(() => { + forms.forEach((form) => { form.dispose(); }); + forms.length = 0; + document.body.innerHTML = ''; + }); + + describe('isDirty (T1336605)', () => { + it.each(objectUpdateScenarios)('should be dirty after $way sets another object to $editorType', ({ editorType, update }) => { + const items = createItems(); + const form = createForm({ + formData: { [FIELD]: items[0] }, + items: [{ + dataField: FIELD, + editorType, + editorOptions: { + items, dataSource: items, displayExpr: 'text', valueExpr: 'this', + }, + }], + }); + const editor = form.getEditor(FIELD); + + update(form, items[1]); + + expect(editor?.option('value')).toBe(items[1]); + expect(editor?.option('isDirty')).toBe(true); + expect(form.option('isDirty')).toBe(true); + }); + + it.each(returnScenarios)('should be pristine after $editorType gets a value equal to the initial one as a new reference', ({ + editorType, editorOptions, createValue, changedValue, + }) => { + const form = createForm({ + formData: { [FIELD]: createValue() }, + items: [{ dataField: FIELD, editorType, editorOptions }], + }); + + form.updateData(FIELD, changedValue); + + expect(form.option('isDirty')).toBe(true); + + form.updateData(FIELD, createValue()); + + expect(form.option('isDirty')).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/tag_box.ts b/packages/devextreme/js/__internal/ui/tag_box.ts index efff404e2f4f..f0b84b7e7e76 100644 --- a/packages/devextreme/js/__internal/ui/tag_box.ts +++ b/packages/devextreme/js/__internal/ui/tag_box.ts @@ -1510,9 +1510,7 @@ class TagBox< this._updateWidgetHeight(); - const { selectedItemKeys } = this._list?.option() ?? {}; - - if (!equalByValue(selectedItemKeys, currentValue)) { + if (!equalByValue(value, currentValue)) { const listSelectionChangeEvent = this._list?._getSelectionChangeEvent(); if (listSelectionChangeEvent) { this._saveValueChangeEvent(listSelectionChangeEvent); diff --git a/packages/devextreme/js/core/utils/common.d.ts b/packages/devextreme/js/core/utils/common.d.ts index c68cacf7ba3d..62dc3304125e 100644 --- a/packages/devextreme/js/core/utils/common.d.ts +++ b/packages/devextreme/js/core/utils/common.d.ts @@ -3,6 +3,7 @@ import { DeferredObj } from './deferred'; export interface EqualByValueOptions { strict?: boolean; maxDepth?: number; + comparePlainObjectsOnly?: boolean; } export function noop(): void;