Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from '@jest/globals';
import { equalByValue } from '@js/core/utils/common';

const createNestedValue = (leaf: unknown): Record<string, unknown> => ({ a: { b: { c: leaf } } });

const createSelfReferencingObject = (id: number): Record<string, unknown> => {
const value: Record<string, unknown> = { 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);
});
});
});
44 changes: 33 additions & 11 deletions packages/devextreme/js/__internal/core/utils/m_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -325,19 +340,26 @@ 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;
}
};

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -25,4 +27,16 @@ export class TagBoxModel {
public getTags(): NodeListOf<HTMLElement> {
return this.root.querySelectorAll(`.${TAG_CLASS}`);
}

public getListItems(): NodeListOf<HTMLElement> {
return this.getInstance().content().querySelectorAll(`.${LIST_ITEM_CLASS}`);
}

public getSelectedListItems(): NodeListOf<HTMLElement> {
return this.getInstance().content().querySelectorAll(`.${SELECTED_LIST_ITEM_CLASS}`);
}

public clickListItem(index: number): void {
this.getListItems()[index].click();
}
}
116 changes: 116 additions & 0 deletions packages/devextreme/js/__internal/ui/__tests__/drop_down_box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,70 @@ 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';

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<string, unknown> => {
let node: Record<string, unknown> = { 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[] = [];

Expand Down Expand Up @@ -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 });
});
});
});
Original file line number Diff line number Diff line change
@@ -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<Properties> = {}): FileUploader => {
const $element = $('<div>').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);
});
});
});
Loading
Loading