diff --git a/projects/igniteui-angular/core/src/core/utils.spec.ts b/projects/igniteui-angular/core/src/core/utils.spec.ts index 2a6bb842080..9d9f8a4af2c 100644 --- a/projects/igniteui-angular/core/src/core/utils.spec.ts +++ b/projects/igniteui-angular/core/src/core/utils.spec.ts @@ -1,5 +1,6 @@ import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; -import { cloneValue, isObject, isDate } from './utils'; +import { cloneValue, cloneValueCached, cloneHierarchicalArray, compareMaps, getComponentCssSizeVar, + intoChunks, isDate, isLeftToRight, isObject, showMessage, uniqueDates } from './utils'; describe('Utils', () => { const complexObject = { @@ -169,6 +170,171 @@ describe('Utils', () => { const undefinedClone = cloneValue(undefined); expect(undefinedClone).toBeUndefined(); }); + + it('Should skip the `externalObject` key', () => { + const source = { Number: 1, externalObject: { framework: 'reference that must not be cloned' } }; + + const clone = cloneValue(source); + + expect(clone.Number).toBe(1); + expect('externalObject' in clone).toBeFalsy(); + }); + }); + + describe('Utils - cloneValueCached() unit tests', () => { + it('Should clone primitives, dates and arrays the same way cloneValue does', () => { + const cache = new Map(); + const date = new Date(10 * 1000 * 60 * 60 * 24); + const array = [1, { a: 1 }, 'string']; + + expect(cloneValueCached(1, cache)).toBe(1); + expect(cloneValueCached('string', cache)).toBe('string'); + expect(cloneValueCached(true, cache)).toBe(true); + expect(cloneValueCached(null, cache)).toBeNull(); + expect(cloneValueCached(undefined, cache)).toBeUndefined(); + + const clonedDate = cloneValueCached(date, cache); + expect(clonedDate).toEqual(date); + expect(clonedDate).not.toBe(date); + + // Arrays are shallow copied - the array itself is new, its items are not. + const clonedArray = cloneValueCached(array, cache); + expect(clonedArray).toEqual(array); + expect(clonedArray).not.toBe(array); + expect(clonedArray[1]).toBe(array[1]); + }); + + it('Should not clone Map or Set', () => { + const cache = new Map(); + const map = new Map([['key', 'value']]); + const set = new Set(['value']); + + expect(cloneValueCached(map, cache)).toBe(map); + expect(cloneValueCached(set, cache)).toBe(set); + }); + + it('Should deep clone objects', () => { + const cache = new Map(); + const clone = cloneValueCached(complexObject, cache); + + expect(clone).toEqual(complexObject); + expect(clone).not.toBe(complexObject); + expect(clone.Object10).not.toBe(complexObject.Object10); + expect(clone.Object10.Object100).not.toBe(complexObject.Object10.Object100); + }); + + it('Should reuse the cached clone for repeated references', () => { + const cache = new Map(); + const shared = { value: 'shared' }; + const source = { first: shared, second: shared }; + + const clone = cloneValueCached(source, cache); + + expect(clone).toEqual(source); + // The same source reference has to resolve to the same clone, not to two separate copies. + expect(clone.first).toBe(clone.second); + expect(clone.first).not.toBe(shared); + }); + + it('Should handle circular references', () => { + const cache = new Map(); + const source: any = { name: 'root' }; + source.self = source; + source.child = { parent: source }; + + const clone = cloneValueCached(source, cache); + + expect(clone.name).toBe('root'); + expect(clone.self).toBe(clone); + expect(clone.child.parent).toBe(clone); + expect(clone).not.toBe(source); + }); + }); + + describe('Utils - uniqueDates() unit tests', () => { + it('Should keep only the first entry for every distinct label', () => { + const first = { label: '1/1/2024', value: new Date(2024, 0, 1) }; + const duplicate = { label: '1/1/2024', value: new Date(2024, 0, 1) }; + const second = { label: '2/1/2024', value: new Date(2024, 1, 1) }; + + expect(uniqueDates([first, duplicate, second, second])).toEqual([first, second]); + expect(uniqueDates([])).toEqual([]); + }); + }); + + describe('Utils - compareMaps() unit tests', () => { + it('Should compare maps by size, keys and values', () => { + const map = new Map([['a', 1], ['b', 2]]); + + expect(compareMaps(map, new Map([['a', 1], ['b', 2]]))).toBeTruthy('equal maps'); + expect(compareMaps(map, new Map([['a', 1], ['b', 3]]))).toBeFalsy('different value'); + expect(compareMaps(map, new Map([['a', 1], ['c', 2]]))).toBeFalsy('different key'); + expect(compareMaps(map, new Map([['a', 1]]))).toBeFalsy('different size'); + expect(compareMaps(new Map(), new Map())).toBeTruthy('two empty maps'); + }); + + it('Should treat a missing second map as equal only to a missing first one', () => { + expect(compareMaps(null, null)).toBeTruthy('both missing'); + expect(compareMaps(new Map([['a', 1]]), null)).toBeFalsy('only the second one missing'); + }); + }); + + describe('Utils - intoChunks() unit tests', () => { + it('Should split an array into chunks of the requested size', () => { + const array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + expect(Array.from(intoChunks(array, 2))).toEqual([[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]); + // The last chunk holds the remainder when the size is not a divisor of the length. + expect(Array.from(intoChunks(array, 3))).toEqual([[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]); + expect(Array.from(intoChunks(array, 20))).toEqual([array]); + expect(Array.from(intoChunks([], 3))).toEqual([]); + }); + + it('Should throw for a chunk size below one', () => { + expect(() => Array.from(intoChunks([1, 2, 3], 0))).toThrowError('size must be an integer >= 1'); + expect(() => Array.from(intoChunks([1, 2, 3], -3))).toThrowError('size must be an integer >= 1'); + }); + }); + + describe('Utils - getComponentCssSizeVar() unit tests', () => { + it('Should map the numeric size to the matching CSS variable', () => { + expect(getComponentCssSizeVar('1')).toBe('var(--ig-size, var(--ig-size-small))'); + expect(getComponentCssSizeVar('2')).toBe('var(--ig-size, var(--ig-size-medium))'); + expect(getComponentCssSizeVar('3')).toBe('var(--ig-size, var(--ig-size-large))'); + // Anything unrecognized falls back to the large size. + expect(getComponentCssSizeVar('')).toBe('var(--ig-size, var(--ig-size-large))'); + }); + }); + + describe('Utils - showMessage() unit tests', () => { + it('Should always report the message as shown', () => { + // The warning itself is a console side effect - what the callers act on is the returned flag, + // which has to stay `true` both for the call that logs and for the one that skips it. + expect(showMessage('Deprecated', false)).toBeTruthy('not shown yet'); + expect(showMessage('Deprecated', true)).toBeTruthy('already shown'); + }); + }); + + describe('Utils - cloneHierarchicalArray() unit tests', () => { + it('Should clone nested arrays and tolerate a missing source', () => { + const source = [ + { id: 1, children: [{ id: 11, children: [] }] }, + { id: 2 } + ]; + + const clone = cloneHierarchicalArray(source, 'children'); + expect(clone).toEqual(source); + expect(clone).not.toBe(source); + expect(clone[0].children).not.toBe(source[0].children); + + expect(cloneHierarchicalArray(null, 'children')).toEqual([]); + }); + }); + + describe('Utils - isLeftToRight() unit tests', () => { + it('Should default to left-to-right when there is no element', () => { + expect(isLeftToRight(null)).toBeTruthy('no element'); + }); }); describe('Utils - isObject() unit tests', () => { diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts index a8f8d0ade0e..756a4b154ba 100644 --- a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts +++ b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts @@ -1,6 +1,8 @@ import { IgxStringFilteringOperand, IgxNumberFilteringOperand, IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxTimeFilteringOperand, IgxBooleanFilteringOperand, IgxFilteringOperand} from './filtering-condition'; @@ -143,6 +145,188 @@ describe('Unit testing FilteringCondition', () => { expect(!f.condition('notNull').logic(null) && f.condition('notNull').logic(undefined) && f.condition('notNull').logic(false)) .toBeTruthy('notNull'); }); + it('tests dateTime conditions', () => { + const fdt = IgxDateTimeFilteringOperand.instance(); + const now = new Date(); + const yesterday = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const lastMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() - 1)); +})(new Date()); + const nextMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() + 1)); +})(new Date()); + const lastYear = ((d) => new Date(d.setFullYear(d.getFullYear() - 1)))(new Date()); + const nextYear = ((d) => new Date(d.setFullYear(d.getFullYear() + 1)))(new Date()); + + expect(fdt.condition('before').logic(yesterday, now) && + !fdt.condition('before').logic(now, yesterday) && + !fdt.condition('before').logic(null, now)) + .toBeTruthy('before'); + expect(fdt.condition('after').logic(now, yesterday) && + !fdt.condition('after').logic(yesterday, now) && + !fdt.condition('after').logic(null, now)) + .toBeTruthy('after'); + expect(fdt.condition('today').logic(now) && + !fdt.condition('today').logic(nextYear) && + !fdt.condition('today').logic(null)) + .toBeTruthy('today'); + expect(fdt.condition('yesterday').logic(yesterday) && + !fdt.condition('yesterday').logic(nextYear) && + !fdt.condition('yesterday').logic(null)) + .toBeTruthy('yesterday'); + expect(fdt.condition('thisMonth').logic(now) && + !fdt.condition('thisMonth').logic(nextYear) && + !fdt.condition('thisMonth').logic(null)) + .toBeTruthy('thisMonth'); + expect(fdt.condition('lastMonth').logic(lastMonth) && + !fdt.condition('lastMonth').logic(now) && + !fdt.condition('lastMonth').logic(null)) + .toBeTruthy('lastMonth'); + expect(fdt.condition('nextMonth').logic(nextMonth) && + !fdt.condition('nextMonth').logic(now) && + !fdt.condition('nextMonth').logic(null)) + .toBeTruthy('nextMonth'); + expect(fdt.condition('thisYear').logic(now) && + !fdt.condition('thisYear').logic(nextYear) && + !fdt.condition('thisYear').logic(null)) + .toBeTruthy('thisYear'); + expect(fdt.condition('lastYear').logic(lastYear) && + !fdt.condition('lastYear').logic(now) && + !fdt.condition('lastYear').logic(null)) + .toBeTruthy('lastYear'); + expect(fdt.condition('nextYear').logic(nextYear) && + !fdt.condition('nextYear').logic(now) && + !fdt.condition('nextYear').logic(null)) + .toBeTruthy('nextYear'); + }); + it('tests dateTime conditions when the current month rolls over a year boundary', () => { + const fdt = IgxDateTimeFilteringOperand.instance(); + const fd = IgxDateFilteringOperand.instance(); + jasmine.clock().install(); + try { + // In January `lastMonth` has to roll back to December of the previous year. + jasmine.clock().mockDate(new Date(2024, 0, 15)); + expect(fdt.condition('lastMonth').logic(new Date(2023, 11, 31)) && + !fdt.condition('lastMonth').logic(new Date(2024, 0, 1)) && + !fdt.condition('lastMonth').logic(new Date(2023, 10, 30))) + .toBeTruthy('dateTime lastMonth in January'); + expect(fd.condition('lastMonth').logic(new Date(2023, 11, 31)) && + !fd.condition('lastMonth').logic(new Date(2024, 0, 1))) + .toBeTruthy('date lastMonth in January'); + expect(fdt.condition('nextMonth').logic(new Date(2024, 1, 1)) && + !fdt.condition('nextMonth').logic(new Date(2024, 0, 31))) + .toBeTruthy('dateTime nextMonth in January'); + + // In December `nextMonth` has to roll forward to January of the next year. + jasmine.clock().mockDate(new Date(2024, 11, 15)); + expect(fdt.condition('nextMonth').logic(new Date(2025, 0, 1)) && + !fdt.condition('nextMonth').logic(new Date(2024, 11, 31)) && + !fdt.condition('nextMonth').logic(new Date(2026, 0, 1))) + .toBeTruthy('dateTime nextMonth in December'); + expect(fd.condition('nextMonth').logic(new Date(2025, 0, 1)) && + !fd.condition('nextMonth').logic(new Date(2024, 11, 31))) + .toBeTruthy('date nextMonth in December'); + expect(fdt.condition('lastMonth').logic(new Date(2024, 10, 30)) && + !fdt.condition('lastMonth').logic(new Date(2024, 11, 1))) + .toBeTruthy('dateTime lastMonth in December'); + } finally { + jasmine.clock().uninstall(); + } + }); + it('tests time conditions', () => { + const ft = IgxTimeFilteringOperand.instance(); + const at = new Date(2024, 4, 17, 10, 30, 30); + const earlierHours = new Date(2024, 4, 17, 9, 30, 30); + const earlierMinutes = new Date(2024, 4, 17, 10, 15, 30); + const earlierSeconds = new Date(2024, 4, 17, 10, 30, 15); + const laterHours = new Date(2024, 4, 17, 11, 30, 30); + const laterMinutes = new Date(2024, 4, 17, 10, 45, 30); + const laterSeconds = new Date(2024, 4, 17, 10, 30, 45); + // The date part is deliberately different - only the time part participates. + const sameTimeOtherDay = new Date(2020, 0, 1, 10, 30, 30); + + expect(ft.condition('at_before').logic(at, at) && + ft.condition('at_before').logic(sameTimeOtherDay, at) && + ft.condition('at_before').logic(earlierHours, at) && + ft.condition('at_before').logic(earlierMinutes, at) && + ft.condition('at_before').logic(earlierSeconds, at)) + .toBeTruthy('at_before matches the exact time and everything before it'); + expect(!ft.condition('at_before').logic(laterHours, at) && + !ft.condition('at_before').logic(laterMinutes, at) && + !ft.condition('at_before').logic(laterSeconds, at) && + !ft.condition('at_before').logic(null, at)) + .toBeTruthy('at_before does not match later times'); + + expect(ft.condition('at_after').logic(at, at) && + ft.condition('at_after').logic(sameTimeOtherDay, at) && + ft.condition('at_after').logic(laterHours, at) && + ft.condition('at_after').logic(laterMinutes, at) && + ft.condition('at_after').logic(laterSeconds, at)) + .toBeTruthy('at_after matches the exact time and everything after it'); + expect(!ft.condition('at_after').logic(earlierHours, at) && + !ft.condition('at_after').logic(earlierMinutes, at) && + !ft.condition('at_after').logic(earlierSeconds, at) && + !ft.condition('at_after').logic(null, at)) + .toBeTruthy('at_after does not match earlier times'); + + // `in` matches on the locale time string, so the date part is irrelevant here as well. + const times = new Set([at.toLocaleTimeString()]); + expect(ft.condition('in').logic(at, times) && + ft.condition('in').logic(sameTimeOtherDay, times) && + !ft.condition('in').logic(laterHours, times) && + !ft.condition('in').logic(null, times)) + .toBeTruthy('in'); + }); + it('tests the shared date-time helpers', () => { + const date = new Date(2024, 4, 17, 13, 24, 35, 678); + + // Without a date, or without a format, every part stays null. + const noDate = IgxDateFilteringOperand.getDateParts(null, 'yMdhmsf'); + const noFormat = IgxDateFilteringOperand.getDateParts(date); + expect(Object.values(noDate).every(part => part === null)).toBeTruthy('no date'); + expect(Object.values(noFormat).every(part => part === null)).toBeTruthy('no format'); + + // Each part is resolved only when the format asks for it. + expect(IgxDateFilteringOperand.getDateParts(date, 'yMdhmsf')).toEqual({ + year: 2024, month: 4, day: 17, hours: 13, minutes: 24, seconds: 35, milliseconds: 678 + }); + const dateOnly = IgxDateFilteringOperand.getDateParts(date, 'yMd'); + expect(dateOnly.hours === null && dateOnly.minutes === null && + dateOnly.seconds === null && dateOnly.milliseconds === null) + .toBeTruthy('parts outside of the format stay null'); + + // Each operand matches against the set through its own string form - the full ISO string for + // dateTime, the date part only for date, and the locale time for time. + const fdt = IgxDateTimeFilteringOperand.instance(); + expect(fdt.condition('in').logic(date, new Set([date.toISOString()])) && + !fdt.condition('in').logic(date, new Set([new Date(2020, 0, 1).toISOString()])) && + fdt.condition('in').logic('plain value', new Set(['plain value'])) && + !fdt.condition('in').logic(null, new Set([date.toISOString()]))) + .toBeTruthy('dateTime in'); + + const fd = IgxDateFilteringOperand.instance(); + expect(fd.condition('in').logic(date, new Set([date.toDateString()])) && + !fd.condition('in').logic(date, new Set([new Date(2020, 0, 1).toDateString()])) && + !fd.condition('in').logic(null, new Set([date.toDateString()]))) + .toBeTruthy('date in'); + + expect(() => fd.condition('before').logic('not a date', date)) + .toThrowError( + 'Could not perform filtering on \'date\' column because the datasource object type is not \'Date\'.'); + }); + it('tests nested query conditions', () => { + const f = IgxStringFilteringOperand.instance(); + const values = new Set(['a', 'b']); + + expect(f.condition('inQuery').logic('a', values) && !f.condition('inQuery').logic('c', values)) + .toBeTruthy('inQuery'); + expect(f.condition('notInQuery').logic('c', values) && !f.condition('notInQuery').logic('a', values)) + .toBeTruthy('notInQuery'); + // Nested query conditions are hidden from the plain condition list, but not from the extended one. + expect(f.conditionList()).not.toContain('inQuery'); + expect(f.extendedConditionList()).toContain('inQuery'); + expect(f.extendedConditionList()).toContain('notInQuery'); + }); it('tests custom conditions', () => { const f = CustomFilter.instance(); expect(f.condition('Custom').logic('Asd', 'asd')).toBeFalsy(); diff --git a/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts new file mode 100644 index 00000000000..33eb9ad25f5 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts @@ -0,0 +1,127 @@ +import { ByLevelTreeGridMergeStrategy, DefaultMergeStrategy, DefaultTreeGridMergeStrategy } from './merge-strategy'; + +describe('Unit testing MergeStrategy', () => { + // `merge` asks the grid to classify every record before it considers it for merging. + const gridStub = { + isDetailRecord: () => false, + isGroupByRecord: () => false, + isChildGridRecord: () => false, + isSummaryRow: () => false, + isGhostRecord: () => false + } as any; + + it('tests the default `comparer`', () => { + const strategy = DefaultMergeStrategy.instance(); + + expect(strategy).toBe(DefaultMergeStrategy.instance(), 'the strategy is a singleton'); + + expect(strategy.comparer({ name: 'a' }, { name: 'a' }, 'name')) + .toBeTruthy('equal values'); + expect(strategy.comparer({ name: 'a' }, { name: 'b' }, 'name')) + .toBeFalsy('different values'); + // Two missing values are considered the same, a missing one next to a present one is not. + expect(strategy.comparer({ name: null }, { name: undefined }, 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer({ name: null }, { name: 'a' }, 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer({ name: 'a' }, { name: null }, 'name')) + .toBeFalsy('only the current value nullish'); + }); + + it('tests the date and time flags of the default `comparer`', () => { + const strategy = DefaultMergeStrategy.instance(); + const morning = { date: new Date(2024, 4, 17, 8, 30) }; + const sameMoment = { date: new Date(2024, 4, 17, 8, 30) }; + const evening = { date: new Date(2024, 4, 17, 20, 45) }; + const nextDaySameTime = { date: new Date(2024, 4, 18, 8, 30) }; + + // Date and time - the whole timestamp has to match. + expect(strategy.comparer(morning, sameMoment, 'date', true, true)) + .toBeTruthy('date + time, same moment'); + expect(strategy.comparer(morning, evening, 'date', true, true)) + .toBeFalsy('date + time, different time'); + + // Date only - the time part is dropped, so the same day merges regardless of the hour. + expect(strategy.comparer(morning, evening, 'date', true, false)) + .toBeTruthy('date only, same day'); + expect(strategy.comparer(morning, nextDaySameTime, 'date', true, false)) + .toBeFalsy('date only, different day'); + + // Time only - the date part is dropped, so the same hour merges across days. + expect(strategy.comparer(morning, nextDaySameTime, 'date', false, true)) + .toBeTruthy('time only, same time'); + expect(strategy.comparer(morning, evening, 'date', false, true)) + .toBeFalsy('time only, different time'); + + // Values that are not `Date` instances yet are parsed first. + expect(strategy.comparer({ date: '2024-05-17T08:30:00' }, { date: '2024-05-17T20:45:00' }, 'date', true, false)) + .toBeTruthy('parsed date only, same day'); + expect(strategy.comparer({ date: '2024-05-17T08:30:00' }, { date: '2024-05-18T08:30:00' }, 'date', true, false)) + .toBeFalsy('parsed date only, different day'); + }); + + it('tests `merge`', () => { + const strategy = DefaultMergeStrategy.instance(); + const data = [{ name: 'a' }, { name: 'a' }, { name: 'b' }, { name: 'b' }, { name: 'b' }]; + + // The optional arguments are left out on purpose - `merge` has to fall back to its own comparer. + const result = strategy.merge(data, 'name', undefined, [], [], undefined, undefined, gridStub); + + expect(result.length).toBe(5); + expect(result[0].cellMergeMeta.get('name').rowSpan).toBe(2, 'the first group spans two rows'); + expect(result[1].cellMergeMeta.get('name').root).toBe(result[0], 'the second record points at the group root'); + expect(result[2].cellMergeMeta.get('name').rowSpan).toBe(3, 'the second group spans three rows'); + expect(result[0].cellMergeMeta.get('name').childRecords).toEqual([result[1]]); + }); + + it('tests `merge` with an active row breaking the sequence', () => { + const strategy = DefaultMergeStrategy.instance(); + const data = [{ name: 'a' }, { name: 'a' }, { name: 'b' }, { name: 'b' }, { name: 'b' }]; + + const result = strategy.merge(data, 'name', undefined, [], [1], undefined, undefined, gridStub); + + // The active row is added untouched and resets the merging sequence around it. + expect(result[1]).toBe(data[1], 'the active row is kept as-is'); + expect(result[0].cellMergeMeta.get('name').rowSpan).toBe(1, 'the row before the active one no longer merges'); + expect(result[2].cellMergeMeta.get('name').rowSpan).toBe(3, 'the group after it is unaffected'); + }); + + it('tests the tree grid `comparer`', () => { + const strategy = new DefaultTreeGridMergeStrategy(); + const record = (name: any, level = 0) => ({ data: { name }, level }); + + expect(strategy.comparer(record('a'), record('a'), 'name')) + .toBeTruthy('equal values'); + expect(strategy.comparer(record('a'), record('b'), 'name')) + .toBeFalsy('different values'); + // The tree grid strategy reads the values off `data`, but treats missing ones the same way. + expect(strategy.comparer(record(null), record(undefined), 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer(record(null), record('a'), 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer(record('a'), record(null), 'name')) + .toBeFalsy('only the current value nullish'); + // The level is irrelevant here - only the value decides. + expect(strategy.comparer(record('a', 1), record('a', 2), 'name')) + .toBeTruthy('equal values on different levels'); + }); + + it('tests the by-level tree grid `comparer`', () => { + const strategy = new ByLevelTreeGridMergeStrategy(); + const record = (name: any, level = 0) => ({ data: { name }, level }); + + expect(strategy.comparer(record('a', 1), record('a', 1), 'name')) + .toBeTruthy('equal values on the same level'); + // Unlike the plain tree grid strategy, records on different levels never merge. + expect(strategy.comparer(record('a', 1), record('a', 2), 'name')) + .toBeFalsy('equal values on different levels'); + expect(strategy.comparer(record('a', 1), record('b', 1), 'name')) + .toBeFalsy('different values'); + expect(strategy.comparer(record(null, 1), record(undefined, 2), 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer(record(null, 1), record('a', 1), 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer(record('a', 1), record(null, 1), 'name')) + .toBeFalsy('only the current value nullish'); + }); +}); diff --git a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts index f4c1948e49f..47452304fe4 100644 --- a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts +++ b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts @@ -1,5 +1,6 @@ import { DataGenerator } from './test-util/data-generator'; -import { DefaultSortingStrategy, SortingDirection } from './sorting-strategy'; +import { DefaultSortingStrategy, FormattedValuesSortingStrategy, GroupMemberCountSortingStrategy, + SortingDirection } from './sorting-strategy'; import { IgxSorting } from './grid-sorting-strategy'; describe('Unit testing SortingStrategy', () => { @@ -66,4 +67,93 @@ describe('Unit testing SortingStrategy', () => { .toEqual([3, 1, 4, 0, 2]); }); + it('tests `compareObjects` of the default strategy', () => { + const strategy = new TestSortingStrategy(); + const resolver = (obj: any, key: string) => obj[key]; + + // 'ROW' sorts before 'row' while the case matters, and equals it once it does not. + expect(strategy.compare({ string: 'ROW' }, { string: 'row' }, 'string', 1, false, resolver)).toBe(-1); + expect(strategy.compare({ string: 'ROW' }, { string: 'row' }, 'string', 1, true, resolver)).toBe(0); + // Values without `toLowerCase` are passed through untouched even when the case is ignored. + expect(strategy.compare({ number: 2 }, { number: 1 }, 'number', 1, true, resolver)).toBe(1); + // A reversed comparison flips the result. + expect(strategy.compare({ number: 2 }, { number: 1 }, 'number', -1, false, resolver)).toBe(-1); + }); + + it('tests `GroupMemberCountSortingStrategy`', () => { + const strategy = GroupMemberCountSortingStrategy.instance(); + const records = [ + { brand: 'Ford' }, { brand: 'BMW' }, { brand: 'Ford' }, + { brand: 'Audi' }, { brand: 'BMW' }, { brand: 'Ford' } + ]; + + expect(strategy).toBe(GroupMemberCountSortingStrategy.instance(), 'the strategy is a singleton'); + + const grouped = strategy.groupBy(records, 'brand'); + expect(Object.keys(grouped).sort()).toEqual(['Audi', 'BMW', 'Ford']); + expect(grouped.Ford.length === 3 && grouped.BMW.length === 2 && grouped.Audi.length === 1) + .toBeTruthy('every record ends up in the group of its field value'); + + // Ascending orders the groups from the smallest to the largest member count, + // with the members of equally sized groups kept in alphabetical order. + expect(strategy.sort([...records], 'brand', SortingDirection.Asc).map(r => r.brand)) + .toEqual(['Audi', 'BMW', 'BMW', 'Ford', 'Ford', 'Ford']); + expect(strategy.sort([...records], 'brand', SortingDirection.Desc).map(r => r.brand)) + .toEqual(['Ford', 'Ford', 'Ford', 'BMW', 'BMW', 'Audi']); + }); + + it('tests `FormattedValuesSortingStrategy`', () => { + const strategy = FormattedValuesSortingStrategy.instance(); + const records = [{ status: 1 }, { status: 3 }, { status: 2 }]; + const resolver = (obj: any, key: string) => obj[key]; + const labels = { 1: 'cancelled', 2: 'Delivered', 3: 'ON HOLD' }; + const gridWithFormatter = { + getColumnByName: () => ({ formatter: (value: number) => labels[value] }) + } as any; + + expect(strategy).toBe(FormattedValuesSortingStrategy.instance(), 'the strategy is a singleton'); + + // Without a grid there is nothing to format, so the raw values decide the order. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver).map(r => r.status)) + .toEqual([1, 2, 3]); + + // With a grid the formatted values decide it: 'Delivered' < 'ON HOLD' < 'cancelled'. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([2, 3, 1]); + // Ignoring the case reorders them again: 'cancelled' < 'delivered' < 'on hold'. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, true, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([1, 2, 3]); + expect(strategy.sort([...records], 'status', SortingDirection.Desc, false, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([1, 3, 2]); + + // A column without a formatter, or no column at all, falls back to the raw values. + const gridWithoutFormatter = { getColumnByName: () => ({}) } as any; + const gridWithoutColumn = { getColumnByName: () => null } as any; + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithoutFormatter) + .map(r => r.status)).toEqual([1, 2, 3]); + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithoutColumn) + .map(r => r.status)).toEqual([1, 2, 3]); + }); + }); + +/** + * Exposes the protected `compareObjects` of the default strategy so that it can be tested directly - + * the public `sort` no longer routes through it since it prepares the sort values up front. + */ +class TestSortingStrategy extends DefaultSortingStrategy { + constructor() { + super(); + } + + public compare( + obj1: any, + obj2: any, + key: string, + reverse: number, + ignoreCase: boolean, + valueResolver: (obj: any, key: string) => any + ): number { + return this.compareObjects(obj1, obj2, key, reverse, ignoreCase, valueResolver, false, false); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts index a8188308b1a..e4ae907a401 100644 --- a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts @@ -310,6 +310,112 @@ describe('Scroll Inertia Directive - Scrolling', () => { }); }); +describe('Scroll Inertia Directive - Child scrolling', () => { + let fix: ComponentFixture; + let directive: IgxTestScrollInertiaDirective; + const elements: HTMLElement[] = []; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxTestScrollInertiaDirective, + ScrollInertiaComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ScrollInertiaComponent); + fix.detectChanges(); + directive = fix.componentInstance.scrInertiaDir; + }); + + afterEach(() => { + elements.forEach(element => element.remove()); + elements.length = 0; + fix = null; + }); + + /** + * Creates a real element in the document - `didChildScroll` reads both the layout box and the + * computed overflow of every element on the event path, so a detached node would not do. + */ + const createElement = (styles: string, innerStyles: string, tagName = 'div') => { + const element = document.createElement(tagName); + element.style.cssText = styles; + const inner = document.createElement('div'); + inner.style.cssText = innerStyles; + element.appendChild(inner); + document.body.appendChild(element); + elements.push(element); + return element; + }; + + const wheelEventOver = (...path: HTMLElement[]) => ({ composedPath: () => path }) as any; + + it('should report a child that can still scroll vertically', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 50px; height: 500px;'); + const evt = wheelEventOver(scrollable); + + // At the top there is room to scroll down, but none to scroll up. + expect(directive.didChildScroll(evt, 0, 10)).toBeTruthy('scrolling down from the top'); + expect(directive.didChildScroll(evt, 0, -10)).toBeFalsy('scrolling up from the top'); + + // At the bottom it is the other way round. + scrollable.scrollTop = scrollable.scrollHeight - scrollable.clientHeight; + expect(directive.didChildScroll(evt, 0, 10)).toBeFalsy('scrolling down from the bottom'); + expect(directive.didChildScroll(evt, 0, -10)).toBeTruthy('scrolling up from the bottom'); + + // A wheel event with no vertical delta never consults the vertical axis. + expect(directive.didChildScroll(evt, 0, 0)).toBeFalsy('no vertical delta'); + }); + + it('should report a child that can still scroll horizontally', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow-x: auto; overflow-y: hidden;', 'width: 500px; height: 50px;'); + const evt = wheelEventOver(scrollable); + + expect(directive.didChildScroll(evt, 10, 0)).toBeTruthy('scrolling right from the start'); + expect(directive.didChildScroll(evt, -10, 0)).toBeFalsy('scrolling left from the start'); + + scrollable.scrollLeft = scrollable.scrollWidth - scrollable.clientWidth; + expect(directive.didChildScroll(evt, 10, 0)).toBeFalsy('scrolling right from the end'); + expect(directive.didChildScroll(evt, -10, 0)).toBeTruthy('scrolling left from the end'); + + expect(directive.didChildScroll(evt, 0, 0)).toBeFalsy('no horizontal delta'); + }); + + it('should ignore children that cannot scroll', () => { + // Overflowing content that is clipped rather than scrolled. + const hidden = createElement( + 'width: 100px; height: 100px; overflow: hidden;', 'width: 500px; height: 500px;'); + expect(directive.didChildScroll(wheelEventOver(hidden), 10, 10)) + .toBeFalsy('overflow is neither auto nor scroll'); + + // Content that fits, so there is no overflow to begin with. + const fits = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 10px; height: 10px;'); + expect(directive.didChildScroll(wheelEventOver(fits), 10, 10)) + .toBeFalsy('nothing overflows'); + + // An empty path has nothing to look at. + expect(directive.didChildScroll(wheelEventOver(), 10, 10)).toBeFalsy('empty path'); + }); + + it('should stop looking once it reaches the display container', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 500px; height: 500px;'); + const displayContainer = createElement('width: 100px; height: 100px;', '', 'igx-display-container'); + + // Anything below the display container belongs to the virtualized grid itself and is skipped. + expect(directive.didChildScroll(wheelEventOver(displayContainer, scrollable), 0, 10)) + .toBeFalsy('the scrollable ancestor is above the display container'); + expect(directive.didChildScroll(wheelEventOver(scrollable, displayContainer), 0, 10)) + .toBeTruthy('the scrollable child is below it'); + }); +}); + /** igxScroll inertia for testing */ @Directive({ selector: '[igxTestScrollInertia]', @@ -334,6 +440,10 @@ export class IgxTestScrollInertiaDirective extends IgxScrollInertiaDirective { public override _inertiaInit(speedX, speedY) { super._inertiaInit(speedX, speedY); } + + public override didChildScroll(evt, scrollDeltaX, scrollDeltaY) { + return super.didChildScroll(evt, scrollDeltaX, scrollDeltaY); + } } /** igxScroll inertia component */ diff --git a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts new file mode 100644 index 00000000000..b85fd22b596 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts @@ -0,0 +1,186 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; + +import { IgxGridDragSelectDirective } from './drag-select.directive'; + +describe('IgxGridDragSelectDirective', () => { + let fix: ComponentFixture; + let component: DragSelectTestComponent; + let directive: IgxGridDragSelectDirective; + let element: HTMLElement; + let deltas: { left: number; top: number }[]; + let stops: boolean[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DragSelectTestComponent] + }).compileComponents(); + })); + + /** + * The fixture is created inside the test itself so that the change detection triggered by + * `detectChanges` runs in the same fake async zone as the assertions that follow it. + */ + const setup = () => { + fix = TestBed.createComponent(DragSelectTestComponent); + fix.detectChanges(); + + component = fix.componentInstance; + directive = component.dragSelect; + element = directive.nativeElement; + + deltas = []; + stops = []; + directive.dragScroll.subscribe(delta => deltas.push(delta)); + directive.dragStop.subscribe(state => stops.push(state)); + }; + + /** + * The element is 200x100 and pinned to the top left corner of the viewport, so the client + * coordinates below are also the offsets inside it. The directive treats the outer 15% of + * each side as a scroll zone - x <= 30 / x >= 170 and y <= 15 / y >= 85. + */ + const pointerOver = (x: number, y: number) => { + element.dispatchEvent(new PointerEvent('pointerover', { clientX: x, clientY: y })); + tick(16); + }; + + const lastDelta = () => deltas[deltas.length - 1]; + + it('should emit the matching scroll delta for every edge and corner', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + expect(lastDelta()).toEqual({ left: -1, top: -1 }, 'top left'); + + pointerOver(190, 5); + expect(lastDelta()).toEqual({ left: 1, top: -1 }, 'top right'); + + pointerOver(10, 95); + expect(lastDelta()).toEqual({ left: -1, top: 1 }, 'bottom left'); + + pointerOver(190, 95); + expect(lastDelta()).toEqual({ left: 1, top: 1 }, 'bottom right'); + + pointerOver(100, 5); + expect(lastDelta()).toEqual({ left: 0, top: -1 }, 'top'); + + pointerOver(100, 95); + expect(lastDelta()).toEqual({ left: 0, top: 1 }, 'bottom'); + + pointerOver(10, 50); + expect(lastDelta()).toEqual({ left: -1, top: 0 }, 'left'); + + pointerOver(190, 50); + expect(lastDelta()).toEqual({ left: 1, top: 0 }, 'right'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should keep emitting the same delta while the pointer stays in the same zone', fakeAsync(() => { + setup(); + + pointerOver(10, 50); + const afterFirstFrame = deltas.length; + expect(afterFirstFrame).toBeGreaterThan(0, 'the subscription starts emitting'); + + // Moving inside the same zone must not resubscribe, but the interval keeps running. + pointerOver(20, 60); + expect(deltas.length).toBeGreaterThan(afterFirstFrame, 'the interval is still emitting'); + expect(deltas.every(delta => delta.left === -1 && delta.top === 0)).toBeTruthy('same delta throughout'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should not scroll while the pointer is in the middle of the element', fakeAsync(() => { + setup(); + + pointerOver(100, 50); + + expect(deltas.length).toBe(0, 'no scrolling in the neutral zone'); + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should stop scrolling when the pointer leaves the element', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + const beforeLeave = deltas.length; + expect(beforeLeave).toBeGreaterThan(0); + + element.dispatchEvent(new PointerEvent('pointerleave')); + tick(16); + + expect(stops).toEqual([false], 'dragStop reports the drag as over'); + expect(deltas.length).toBe(beforeLeave, 'no further emissions after leaving'); + + // The direction is reset, so re-entering the very same zone starts scrolling again. + pointerOver(10, 5); + expect(deltas.length).toBeGreaterThan(beforeLeave, 'the same zone is picked up again'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should ignore pointer events while the drag is not active', fakeAsync(() => { + setup(); + + // The directive is set directly rather than through the host binding - it is the very + // same setter the `igxGridDragSelect` input writes to. + directive.activeDrag = false; + + pointerOver(10, 5); + element.dispatchEvent(new PointerEvent('pointerleave')); + tick(16); + + expect(deltas.length).toBe(0, 'no scrolling'); + expect(stops.length).toBe(0, 'no dragStop'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should stop scrolling when the drag is deactivated or the directive is destroyed', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + const whileActive = deltas.length; + expect(whileActive).toBeGreaterThan(0); + + // The directive is set directly rather than through the host binding - it is the very + // same setter the `igxGridDragSelect` input writes to. + directive.activeDrag = false; + tick(16); + expect(deltas.length).toBe(whileActive, 'deactivating the drag unsubscribes'); + + directive.activeDrag = true; + pointerOver(190, 50); + const whileActiveAgain = deltas.length; + expect(whileActiveAgain).toBeGreaterThan(whileActive, 'reactivating it resumes'); + + fix.destroy(); + tick(16); + expect(deltas.length).toBe(whileActiveAgain, 'destroying the directive unsubscribes'); + + // The listeners are detached on destroy, so the element no longer reacts at all. + element.dispatchEvent(new PointerEvent('pointerover', { clientX: 10, clientY: 5 })); + tick(16); + expect(deltas.length).toBe(whileActiveAgain, 'no listeners left on the element'); + discardPeriodicTasks(); + })); +}); + +@Component({ + template: `
`, + imports: [IgxGridDragSelectDirective] +}) +class DragSelectTestComponent { + @ViewChild(IgxGridDragSelectDirective, { static: true }) + public dragSelect: IgxGridDragSelectDirective; + + public activeDrag = true; +} diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts index faf2b0d0d2a..a62830324d2 100644 --- a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts @@ -569,6 +569,369 @@ describe('PDF Exporter', () => { }); }); + /** + * `exportData` re-wraps every element it is given as a plain `DataRecord`, so it cannot be used + * to exercise the pivot and summary code paths. These tests hand the exporter the already built + * export records instead, which is what the grid itself does. + */ + describe('Export record types', () => { + const exportRecords = (records: IExportRecord[]) => { + (exporter as any).options = options; + (exporter as any).exportGridRecordsData(records); + }; + + const pivotOwner = (columns: IColumnInfo[]): IColumnList => ({ + columns, + columnWidths: columns.map(() => 200), + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 1 + }); + + it('should export a pivot grid with a single row dimension', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', London: 100, Paris: 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + }, + { + data: { Product: 'Product B', London: 150, Paris: 250 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Paris', field: 'Paris', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should resolve row dimension values through every fallback', (done) => { + // Only the first dimension has a column of its own. The remaining ones have to be + // resolved straight from the record data - by name, by a fuzzy name match, and finally + // by position among the simple keys of the record. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Tools', London: 100, Paris: 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category', 'Cat', 'Missing'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Paris', field: 'Paris', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should infer the row dimensions from the record data when there are no dimension keys', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', London: 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + // The row dimension column carries a matching field, so the dimension is picked up from it. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.MultiRowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should fall back to the simple record keys when no row dimension column matches', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + // Neither the field nor the column group of the row dimension column appears in the + // record data, so the exporter has to guess the dimensions from the simple keys. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Unmatched', field: 'Unmatched', skip: false, + headerType: ExportHeaderType.PivotMergedHeader, level: 0, startIndex: 0, + columnSpan: 1, columnGroup: 'AlsoUnmatched' + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should export a hierarchical grid with child and grandchild islands', (done) => { + // Every record names the island it belongs to, and the exporter keeps one column list per island. + const rootOwner = 'root'; + const childIsland = 'childIsland'; + const grandChildIsland = 'grandChildIsland'; + + const records: IExportRecord[] = [ + { + data: { Id: 1, Name: 'Parent 1' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + }, + { + // Header records carry the header captions as a plain array and are skipped + // rather than drawn as data rows. + data: ['ChildId', 'Title'], + references: [ + { header: 'ChildId', field: 'ChildId', skip: false }, + { header: 'Title', field: 'Title', skip: false } + ] as IColumnInfo[], + level: 1, type: ExportRecordType.HeaderRecord, owner: childIsland + }, + { + data: { ChildId: 11, Title: 'Child A' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland + }, + { + data: { GrandId: 111, Label: 'Grandchild of A' }, + level: 2, type: ExportRecordType.HierarchicalGridRecord, owner: grandChildIsland + }, + { + data: { ChildId: 12, Title: 'Child B' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland + }, + { + // Collapsed rows are not rendered at all. + data: { ChildId: 13, Title: 'Collapsed child' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland, hidden: true + }, + { + data: { Id: 2, Name: 'Parent 2' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + } + ]; + + const ownerFor = (fields: string[]): IColumnList => ({ + columns: fields.map((field, index) => ({ + header: field, field, skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: index, columnSpan: 1 + })), + columnWidths: fields.map(() => 200), + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }); + + (exporter as any)._ownersMap.set(rootOwner, ownerFor(['Id', 'Name'])); + (exporter as any)._ownersMap.set(childIsland, ownerFor(['ChildId', 'Title'])); + (exporter as any)._ownersMap.set(grandChildIsland, ownerFor(['GrandId', 'Label'])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should skip a child island that has no columns of its own', (done) => { + const rootOwner = 'root'; + const emptyIsland = 'islandWithoutColumns'; + + const records: IExportRecord[] = [ + { + data: { Id: 1, Name: 'Parent 1' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + }, + { + data: { ChildId: 11 }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: emptyIsland + } + ]; + + (exporter as any)._ownersMap.set(rootOwner, { + columns: [ + { + header: 'Id', field: 'Id', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Name', field: 'Name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(emptyIsland, { + columns: [], + columnWidths: [], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should render every shape of summary result', (done) => { + const records: IExportRecord[] = [ + { + data: { Name: 'John', Age: 30 }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + // Both a label and a value - rendered as `label: value`. + data: { Name: { label: 'Count', value: 2 }, Age: { label: 'Avg', value: 27.5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + }, + { + // Only one of the two, and an empty pair that renders as nothing. + data: { Name: { label: 'Count' }, Age: { value: 27.5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + }, + { + data: { Name: { label: '', value: '' }, Age: { summaryResult: 5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'Name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Age', field: 'Age', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should truncate headers and cell values that do not fit their column', (done) => { + const longText = 'A very long value that has no chance of fitting inside the column it is drawn in'.repeat(4); + const records: IExportRecord[] = [ + { + data: { Description: longText, Note: longText }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: longText, field: 'Description', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: longText, field: 'Note', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + }); + describe('Pivot Grid Export', () => { it('should export pivot grid with single dimension', (done) => { const pivotData: IExportRecord[] = [ diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts index 7c2caa4887d..2f5d5b3a189 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts @@ -583,4 +583,93 @@ describe('IgxPivotGrid - Keyboard navigation #pivotGrid', () => { expect(IgxGridNavigationService.prototype.headerNavigation).toHaveBeenCalled(); }); }); + + describe('Row header navigation for the vertical row layout', () => { + let fixture: ComponentFixture; + let pivotGrid: IgxPivotGridComponent; + let pivotNav: IgxPivotGridNavigationService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridMultipleRowComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(async () => { + fixture = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fixture.detectChanges(); + pivotGrid = fixture.componentInstance.pivotGrid; + pivotNav = pivotGrid.navigation as IgxPivotGridNavigationService; + await fixture.whenStable(); + fixture.detectChanges(); + }); + + /** + * The row header navigation only runs while `isRowHeaderActive` is set, which the grid does + * when a row dimension cell takes focus. The key is dispatched at the service so that the + * navigation is exercised without depending on which cell the browser happens to focus. + */ + const pressKey = async (key: string, ctrlKey = false) => { + pivotNav.isRowHeaderActive = true; + await pivotNav.handleNavigation(new KeyboardEvent('keydown', { key, ctrlKey })); + fixture.detectChanges(); + }; + + it('should move up through the row headers and then out to the row dimension headers', async () => { + pivotNav.activeNode = { row: 2, column: 0 }; + + await pressKey('ArrowUp'); + expect(pivotNav.activeNode.row).toBe(1, 'one row up'); + + await pressKey('ArrowUp', true); + expect(pivotNav.activeNode.row).toBe(0, 'ctrl jumps to the first row'); + + // Moving up from the first row leaves the body and activates the dimension headers. + await pressKey('ArrowUp'); + expect(pivotNav.activeNode.row).toBe(-1, 'the active node leaves the body'); + expect(pivotNav.activeNode.column).toBe(0, 'the column falls back to the first dimension'); + expect(pivotNav.isRowDimensionHeaderActive).toBeTrue(); + expect(pivotNav.isRowHeaderActive).toBeFalse(); + }); + + it('should move down through the row headers', async () => { + pivotNav.activeNode = { row: 0, column: 0 }; + + await pressKey('ArrowDown'); + expect(pivotNav.activeNode.row).toBe(1, 'one row down'); + + await pressKey('ArrowDown', true); + expect(pivotNav.activeNode.row).toBeGreaterThan(1, 'ctrl jumps to the last row'); + // The focus stays in the body - only moving up past the first row leaves it. + expect(pivotNav.isRowDimensionHeaderActive).toBeFalse(); + }); + + it('should move between the row dimensions and remember the row of each one', async () => { + pivotNav.activeNode = { row: 1, column: 0 }; + + await pressKey('ArrowRight'); + expect(pivotNav.activeNode.column).toBe(1, 'one dimension to the right'); + expect(pivotNav.activeNode.mchCache).toEqual({ visibleIndex: 1, level: 0 }); + + await pressKey('ArrowLeft'); + expect(pivotNav.activeNode.column).toBe(0, 'back to the first dimension'); + // The row of the dimension that was left is restored from the cache. + expect(pivotNav.activeNode.row).toBe(1, 'the previous row is restored'); + }); + + it('should ignore keys that are not navigation keys', async () => { + pivotNav.activeNode = { row: 1, column: 0 }; + + await pressKey('a'); + + expect(pivotNav.activeNode.row).toBe(1, 'the active node is untouched'); + expect(pivotNav.activeNode.column).toBe(0); + }); + }); });