diff --git a/packages/dx-grid-core/src/index.ts b/packages/dx-grid-core/src/index.ts index d28079439d..2cdc0f50fd 100644 --- a/packages/dx-grid-core/src/index.ts +++ b/packages/dx-grid-core/src/index.ts @@ -186,7 +186,26 @@ export { getGroupCellTargetIndex } from './utils/group-panel'; /** @internal */ export { - getCollapsedGrid, TABLE_STUB_TYPE, getColumnWidthGetter, + getCollapsedGrid, + getCollapsedGrids, + getColumnsVisibleBoundary, + getRowsVisibleBoundary, + getColumnsRenderBoundary, + getRowsRenderBoundary, + getColumnWidthGetter, + TABLE_STUB_TYPE, } from './utils/virtual-table'; +/** @internal */ +export * from './plugins/virtual-table/computeds'; +/** @internal */ +export * from './plugins/virtual-table/helpers'; + +/** @internal */ +export * from './plugins/virtual-table-state/computeds'; +/** @internal */ +export * from './plugins/virtual-table-state/utils'; +/** @internal */ +export * from './plugins/virtual-table-state/helpers'; + export * from './types'; diff --git a/packages/dx-grid-core/src/plugins/table/computeds.ts b/packages/dx-grid-core/src/plugins/table/computeds.ts index 4608074e71..3d0a2e93a2 100644 --- a/packages/dx-grid-core/src/plugins/table/computeds.ts +++ b/packages/dx-grid-core/src/plugins/table/computeds.ts @@ -19,13 +19,16 @@ export const tableColumnsWithDataRows: PureComputed<[any[], GridColumnExtension[ }; }); -export const tableRowsWithDataRows: PureComputed<[Row[], GetRowIdFn]> = (rows, getRowId) => ( - !rows.length +export const tableRowsWithDataRows: PureComputed<[Row[], GetRowIdFn, number]> = ( + rows, getRowId, isRemoteRowsLoading, +) => ( + !rows.length && !isRemoteRowsLoading ? [{ key: TABLE_NODATA_TYPE.toString(), type: TABLE_NODATA_TYPE }] - : rows.map((row) => { + : rows.map((row, dataIndex) => { const rowId = getRowId(row); return { row, + // dataIndex, rowId, type: TABLE_DATA_TYPE, key: `${TABLE_DATA_TYPE.toString()}_${rowId}`, diff --git a/packages/dx-grid-core/src/plugins/table/helpers.ts b/packages/dx-grid-core/src/plugins/table/helpers.ts index feabbc0019..d558b57ae8 100644 --- a/packages/dx-grid-core/src/plugins/table/helpers.ts +++ b/packages/dx-grid-core/src/plugins/table/helpers.ts @@ -2,6 +2,7 @@ import { TABLE_DATA_TYPE, TABLE_NODATA_TYPE } from './constants'; import { IsSpecificCellFn, IsSpecificRowFn, TableRow, TableColumn, } from '../../types'; +import { TABLE_STUB_TYPE } from '../../utils/virtual-table'; export const isDataTableCell: IsSpecificCellFn = ( tableRow, tableColumn, @@ -14,3 +15,6 @@ export const isNoDataTableRow: IsSpecificRowFn = tableRow => tableRow.type === T export const isNoDataTableCell: IsSpecificCellFn = ( tableColumn, tableColumns, ) => tableColumns.indexOf(tableColumn as any) === 0; +export const isStubTableCell: IsSpecificRowFn = tableRow => ( + tableRow.type === TABLE_STUB_TYPE +); diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.test.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.test.ts new file mode 100644 index 0000000000..644f5fd25a --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.test.ts @@ -0,0 +1,29 @@ +import { virtualRowsWithCache } from './computeds'; +import { mergeRows } from './helpers'; +import { createVirtualRows, createInterval } from './test-utils'; + +jest.mock('./helpers', () => ({ + mergeRows: jest.fn(), +})); + +describe('VirtualTableState computeds', () => { + describe('#virtualRowsWithCache', () => { + it('should call mergeRows with correct parameters', () => { + const rowsInterval = createInterval(20, 30); + const cacheInterval = createInterval(15, 25); + const { skip, rows } = createVirtualRows(rowsInterval); + const cache = createVirtualRows(cacheInterval); + + virtualRowsWithCache(skip, rows, cache); + + expect(mergeRows).toHaveBeenCalledWith( + { start: 20, end: 30 }, + { start: 15, end: 25 }, + rows, + cache.rows, + 20, + 15, + ); + }); + }); +}); diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.ts new file mode 100644 index 0000000000..162af4ed86 --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/computeds.ts @@ -0,0 +1,14 @@ +import { mergeRows } from './helpers'; +import { intervalUtil } from './utils'; +import { VirtualRowsWithCacheFn, PlainRowsFn, LoadedRowsStartFn } from '../../types'; + +export const virtualRowsWithCache: VirtualRowsWithCacheFn = (skip, rows, cache) => { + const rowsInterval = intervalUtil.getRowsInterval({ skip, rows }); + const cacheInterval = intervalUtil.getRowsInterval(cache); + + return mergeRows(rowsInterval, cacheInterval, rows, cache.rows, skip, cache.skip); +}; + +export const plainRows: PlainRowsFn = virtualRows => virtualRows.rows; + +export const loadedRowsStart: LoadedRowsStartFn = virtualRows => virtualRows.skip; diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.test.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.test.ts new file mode 100644 index 0000000000..fc475f6eff --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.test.ts @@ -0,0 +1,321 @@ +import { + mergeRows, calculateRequestedRange, rowToPageIndex, + recalculateBounds, trimRowsToInterval, +} from './helpers'; +import { intervalUtil } from './utils'; +import { createInterval, generateRows, createVirtualRows } from './test-utils'; + +describe('VirtualTableState helpers', () => { + describe('#mergeRows', () => { + describe('nonoverlapping', () => { + it('should merge rows when cache is before rows', () => { + const cacheInterval = createInterval(10, 14); + const rowsInterval = createInterval(14, 18); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 14, 10)).toEqual({ + skip: 10, + rows: [ + ...cache, ...rows, + ], + }); + }); + + it('should merge rows when rows are before cache', () => { + const cacheInterval = createInterval(14, 18); + const rowsInterval = createInterval(10, 14); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 10, 14)).toEqual({ + skip: 10, + rows: [ + ...rows, ...cache, + ], + }); + }); + }); + + describe('overlapping', () => { + it('should merge rows when cache is before rows', () => { + const cacheInterval = createInterval(10, 20); + const rowsInterval = createInterval(15, 25); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 15, 10)).toEqual({ + skip: 10, + rows: [ + ...cache.slice(0, 5), ...rows, + ], + }); + }); + + it('should merge rows when rows are before cache', () => { + const cacheInterval = createInterval(15, 25); + const rowsInterval = createInterval(10, 20); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 10, 15)).toEqual({ + skip: 10, + rows: [ + ...rows, ...cache.slice(5), + ], + }); + }); + + it('should merge rows when cache contains rows', () => { + const cacheInterval = createInterval(10, 30); + const rowsInterval = createInterval(15, 25); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 15, 10)).toEqual({ + skip: 10, + rows: [ + ...cache.slice(0, 5), ...rows, ...cache.slice(15), + ], + }); + }); + + it('should merge rows when rows contain cache', () => { + const cacheInterval = createInterval(15, 25); + const rowsInterval = createInterval(10, 30); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 10, 15)).toEqual({ + skip: 10, + rows, + }); + }); + }); + + describe('empty interval', () => { + it('should merge rows when cache is empty', () => { + const cacheInterval = intervalUtil.empty; + const rowsInterval = createInterval(10, 20); + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 10, 15)).toEqual({ + skip: 10, + rows, + }); + }); + + it('should merge rows when rows are empty', () => { + const cacheInterval = createInterval(10, 20); + const rowsInterval = intervalUtil.empty; + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 15, 10)).toEqual({ + skip: 10, + rows: cache, + }); + }); + + it('should merge rows when both rows and cache are empty', () => { + const cacheInterval = intervalUtil.empty; + const rowsInterval = intervalUtil.empty; + const cache = generateRows(cacheInterval, 'cache'); + const rows = generateRows(rowsInterval, 'rows'); + + expect(mergeRows(rowsInterval, cacheInterval, rows, cache, 15, 10)).toEqual({ + skip: undefined, + rows: [], + }); + }); + }); + + describe('partial merge', () => { + it('should merge rows according to intervals', () => { + const fullCacheInterval = createInterval(0, 30); + const fullRowsInterval = createInterval(20, 50); + const cache = generateRows(fullCacheInterval, 'cache'); + const rows = generateRows(fullRowsInterval, 'rows'); + const visibleCacheInterval = createInterval(10, 30); + const visibleRowsInterval = createInterval(20, 40); + + expect(mergeRows(visibleRowsInterval, visibleCacheInterval, rows, cache, 20, 0)).toEqual({ + skip: 10, + rows: [ + ...cache.slice(10, 20), ...rows.slice(0, 20), + ], + }); + }); + }); + }); + + describe('#calculateRequestedRange', () => { + const pageSize = 100; + + describe('simple cases', () => { + it('should caclulate requested range for next page', () => { + const loadedInterval = createInterval(100, 400); + const newInterval = createInterval(200, 500); + + expect(calculateRequestedRange(loadedInterval, newInterval, 310, pageSize)) + .toEqual({ start: 400, end: 500 }); + }); + + it('should caclulate requested range for previous page', () => { + const loadedInterval = createInterval(200, 500); + const newInterval = createInterval(100, 400); + + expect(calculateRequestedRange(loadedInterval, newInterval, 310, pageSize)) + .toEqual({ start: 100, end: 200 }); + }); + + it('should caclulate requested range for next 2 pages', () => { + const loadedInterval = createInterval(100, 400); + const newInterval = createInterval(400, 700); + + expect(calculateRequestedRange(loadedInterval, newInterval, 580, pageSize)) + .toEqual({ start: 500, end: 700 }); + }); + + it('should caclulate requested range for previous 2 pages', () => { + const loadedInterval = createInterval(300, 600); + const newInterval = createInterval(100, 400); + + expect(calculateRequestedRange(loadedInterval, newInterval, 270, pageSize)) + .toEqual({ start: 200, end: 400 }); + }); + }); + + describe('edge cases', () => { + it('should correctly process start of page', () => { + const loadedInterval = createInterval(0, 200); + const newInterval = createInterval(200, 500); + + expect(calculateRequestedRange(loadedInterval, newInterval, 300, pageSize)) + .toEqual({ start: 200, end: 400 }); + }); + + it('should correctly process end of page', () => { + const loadedInterval = createInterval(0, 200); + const newInterval = createInterval(200, 500); + + expect(calculateRequestedRange(loadedInterval, newInterval, 399, pageSize)) + .toEqual({ start: 300, end: 500 }); + }); + }); + + describe('fast scroll', () => { + const loadedInterval = createInterval(200, 500); + const newInterval = createInterval(1000, 1300); + + it('should return current and next page if page middle index passed', () => { + expect(calculateRequestedRange(loadedInterval, newInterval, 1170, pageSize)) + .toEqual({ start: 1100, end: 1300 }); + }); + + it('should return current and previous page if page middle index is not passed', () => { + expect(calculateRequestedRange(loadedInterval, newInterval, 1120, pageSize)) + .toEqual({ start: 1000, end: 1200 }); + }); + }); + }); + + describe('#rowToPageIndex', () => { + it('should return virtual page index', () => { + expect(rowToPageIndex(0, 100)).toBe(0); + expect(rowToPageIndex(50, 100)).toBe(0); + expect(rowToPageIndex(99, 100)).toBe(0); + expect(rowToPageIndex(100, 100)).toBe(1); + }); + }); + + describe('#calculateBounds', () => { + it('should return boundaries from previous page start to next page end', () => { + expect(recalculateBounds(350, 100, 1000)).toEqual({ + start: 200, + end: 500, + }); + }); + + it('should correctly process start index of page', () => { + expect(recalculateBounds(300, 100, 1000)).toEqual({ + start: 200, + end: 500, + }); + }); + + it('should correctly process end index of page', () => { + expect(recalculateBounds(299, 100, 1000)).toEqual({ + start: 100, + end: 400, + }); + }); + + it('should be bounded below by 0', () => { + expect(recalculateBounds(30, 100, 1000)).toEqual({ + start: 0, + end: 200, + }); + }); + + it('should be bounded above by total rows count', () => { + expect(recalculateBounds(950, 100, 1000)).toEqual({ + start: 800, + end: 1000, + }); + }); + }); + + describe('#trimRowsToInterval', () => { + it('should trim right side', () => { + const rowsInterval = createInterval(10, 20); + const targetInterval = createInterval(5, 15); + const virtualRows = createVirtualRows(rowsInterval); + + expect(trimRowsToInterval(virtualRows, targetInterval)).toEqual({ + skip: 10, + rows: [ + ...virtualRows.rows.slice(0, 5), + ], + }); + }); + + it('should trim left side', () => { + const rowsInterval = createInterval(10, 20); + const targetInterval = createInterval(15, 25); + const virtualRows = createVirtualRows(rowsInterval); + + expect(trimRowsToInterval(virtualRows, targetInterval)).toEqual({ + skip: 15, + rows: [ + ...virtualRows.rows.slice(5, 10), + ], + }); + }); + + it('should trim both sides', () => { + const rowsInterval = createInterval(10, 30); + const targetInterval = createInterval(15, 25); + const virtualRows = createVirtualRows(rowsInterval); + + expect(trimRowsToInterval(virtualRows, targetInterval)).toEqual({ + skip: 15, + rows: [ + ...virtualRows.rows.slice(5, 15), + ], + }); + }); + + it('should return empty if target interval does not contain rows', () => { + const rowsInterval = createInterval(10, 20); + const targetInterval = createInterval(25, 35); + const virtualRows = createVirtualRows(rowsInterval); + + expect(trimRowsToInterval(virtualRows, targetInterval)).toEqual({ + skip: Number.POSITIVE_INFINITY, + rows: [], + }); + }); + }); +}); diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.ts new file mode 100644 index 0000000000..7aa97e8cf6 --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/helpers.ts @@ -0,0 +1,105 @@ +import { intervalUtil } from './utils'; +import { + VirtualRows, Row, MergeRowsFn, CalculateRequestedRangeFn, Interval, +} from '../../types'; +import { PureComputed } from '@devexpress/dx-core'; + +export const emptyVirtualRows: VirtualRows = { + skip: Number.POSITIVE_INFINITY, + rows: [], +}; + +const pluckSubarray: PureComputed<[Row[], ...number[]]> = (source, sourceStart, left, right) => ( + source.slice(left - sourceStart, right - sourceStart) +); + +export const mergeRows: MergeRowsFn = ( + rowsInterval, cacheInterval, rows, cacheRows, rowsStart, cacheStart, +) => { + const breakpoints = [ + rowsInterval.start, rowsInterval.end, + cacheInterval.start, cacheInterval.end, + ] + .filter(i => 0 <= i && i < Number.POSITIVE_INFINITY) + .sort((a, b) => a - b); + + let result: Row[] = []; + if (breakpoints.length > 1) { + for (let i = 0; i < breakpoints.length - 1; i += 1) { + const left = breakpoints[i]; + const right = breakpoints[i + 1]; + const chunk = rowsInterval.start <= left && right <= rowsInterval.end + ? pluckSubarray(rows, rowsStart, left, right) // rows have higher priority + : pluckSubarray(cacheRows, cacheStart, left, right); + + result = result.concat(chunk); + } + } + + return { + skip: breakpoints[0], + rows: result, + }; +}; + +export const calculateRequestedRange: CalculateRequestedRangeFn = ( + loadedInterval, newRange, referenceIndex, pageSize, +) => { + if (Math.abs(loadedInterval.start - newRange.start) >= 2 * pageSize) { + const useFirstHalf = referenceIndex % pageSize < pageSize / 2; + const start = useFirstHalf + ? newRange.start + : newRange.start + pageSize; + const end = Math.min(newRange.end, start + 2 * pageSize); + + return { start, end }; + } + return intervalUtil.difference(newRange, loadedInterval); +}; + +export const rowToPageIndex: PureComputed<[number, number]> = ( + rowIndex, pageSize, +) => Math.floor(rowIndex / pageSize); + +export const recalculateBounds: PureComputed<[number, number, number], Interval> = ( + middleIndex, pageSize, totalCount, +) => { + const currentPageIndex = rowToPageIndex(middleIndex, pageSize); + + const prevPageIndex = currentPageIndex - 1; + const nextPageIndex = currentPageIndex + 2; + const start = Math.max(0, prevPageIndex * pageSize); + const end = Math.min(nextPageIndex * pageSize, totalCount); + + return { + start, + end, + }; +}; + +export const trimRowsToInterval: PureComputed<[VirtualRows, Interval]> = ( + virtualRows, targetInterval, +) => { + const rowsInterval = intervalUtil.getRowsInterval(virtualRows); + const intersection = intervalUtil.intersect(rowsInterval, targetInterval); + if (intervalUtil.empty === intersection) { + return emptyVirtualRows; + } + + const rows = pluckSubarray( + virtualRows.rows, virtualRows.skip, intersection.start, intersection.end, + ); + + return { + rows, + skip: intersection.start, + }; +}; + +export const getAvailableRowCount: PureComputed<[boolean, number, number, number], number> = ( + infiniteScroll, newCount, lastCount, totalRowCount, +) => ( + infiniteScroll + ? Math.max(newCount, lastCount) + : totalRowCount +); diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/test-utils.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/test-utils.ts new file mode 100644 index 0000000000..8e9f6f8f6a --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/test-utils.ts @@ -0,0 +1,12 @@ +import { Interval, VirtualRows } from '../../types'; + +export const createInterval = (start: number, end: number): Interval => ({ start, end }); +export const generateRows = (interval: Interval, type = 'rows') => ( + Array + .from({ length: interval.end - interval.start }) + .map((_, i) => ({ id: interval.start + i, type })) +); +export const createVirtualRows = (interval: Interval): VirtualRows => ({ + skip: interval.start, + rows: generateRows(interval), +}); diff --git a/packages/dx-grid-core/src/plugins/virtual-table-state/utils.ts b/packages/dx-grid-core/src/plugins/virtual-table-state/utils.ts new file mode 100644 index 0000000000..ccf7553970 --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table-state/utils.ts @@ -0,0 +1,60 @@ +import { PureComputed } from '@devexpress/dx-core'; +import { emptyVirtualRows } from './helpers'; +import { Interval, VirtualRows } from '../../types'; + +const empty: Interval = { + start: Number.POSITIVE_INFINITY, + end: Number.NEGATIVE_INFINITY, +}; + +const getRowsInterval: PureComputed<[VirtualRows], Interval> = r => ( + r === emptyVirtualRows + ? empty + : { + start: r.skip, + end: r.skip + r.rows.length, + } +); + +const getLength = (a: Interval) => a.end - a.start; + +const intersect = (a: Interval, b: Interval) => { + if (a.end < b.start || b.end < a.start) { + return empty; + } + + return { + start: Math.max(a.start, b.start), + end: Math.min(a.end, b.end), + }; +}; + +const difference = (a: Interval, b: Interval) => { + if (empty === intervalUtil.intersect(a, b)) { + return a; + } + + if (b.end < a.end) { + return { + start: b.end, + end: a.end, + }; + } + if (a.start < b.start) { + return { + start: a.start, + end: b.start, + }; + } + return empty; +}; + +export const intervalUtil = { + empty, + + getRowsInterval, + getLength, + + intersect, + difference, +}; diff --git a/packages/dx-grid-core/src/plugins/virtual-table/computeds.test.ts b/packages/dx-grid-core/src/plugins/virtual-table/computeds.test.ts new file mode 100644 index 0000000000..6349287bd5 --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table/computeds.test.ts @@ -0,0 +1,99 @@ +import { nextPageReferenceIndex } from './computeds'; +import { pageTriggersMeta } from './helpers'; + +jest.mock('./helpers', () => ({ + ...require.requireActual('./helpers'), + pageTriggersMeta: jest.fn(), +})); + +describe('#nextPageReferenceIndex', () => { + afterEach(jest.resetAllMocks); + + const getters = {}; + const defaultPayload = { + viewportTop: 0, + estimatedRowHeight: 40, + containerHeight: 400, + }; + + it('should calculate page triggers', () => { + pageTriggersMeta.mockImplementationOnce(() => null); + const payload = {}; + + nextPageReferenceIndex(payload, getters); + + expect(pageTriggersMeta) + .toHaveBeenCalledWith(payload, getters); + }); + + it('should return null if pageTriggers not exist', () => { + pageTriggersMeta.mockImplementationOnce(() => null); + + expect(nextPageReferenceIndex(defaultPayload, getters)) + .toBeNull(); + }); + + it('should return null if none of page trigger is reached', () => { + const payload = { + ...defaultPayload, + viewportTop: 5000, + }; + pageTriggersMeta.mockImplementationOnce(() => ({ + topTriggerIndex: 100, + topTriggerPosition: 3800, + bottomTriggerIndex: 200, + bottomTriggerPosition: 7800, + })); + + expect(nextPageReferenceIndex(payload, getters)) + .toBeNull(); + }); + + it('should calculate reference index for previous page', () => { + const payload = { + ...defaultPayload, + viewportTop: 3000, + }; + pageTriggersMeta.mockImplementationOnce(() => ({ + topTriggerIndex: 100, + topTriggerPosition: 3800, + bottomTriggerIndex: 200, + bottomTriggerPosition: 7800, + })); + + expect(nextPageReferenceIndex(payload, getters)) + .toBe(85); + }); + + it('should calculate reference index for next page', () => { + const payload = { + ...defaultPayload, + viewportTop: 8000, + }; + pageTriggersMeta.mockImplementationOnce(() => ({ + topTriggerIndex: 100, + topTriggerPosition: 3800, + bottomTriggerIndex: 200, + bottomTriggerPosition: 7800, + })); + + expect(nextPageReferenceIndex(payload, getters)) + .toBe(210); + }); + + it('should calculate reference index for arbitary page', () => { + const payload = { + ...defaultPayload, + viewportTop: 90000, + }; + pageTriggersMeta.mockImplementationOnce(() => ({ + topTriggerIndex: 100, + topTriggerPosition: 3800, + bottomTriggerIndex: 200, + bottomTriggerPosition: 7800, + })); + + expect(nextPageReferenceIndex(payload, getters)) + .toBe(2260); + }); +}); diff --git a/packages/dx-grid-core/src/plugins/virtual-table/computeds.ts b/packages/dx-grid-core/src/plugins/virtual-table/computeds.ts new file mode 100644 index 0000000000..3c843021db --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table/computeds.ts @@ -0,0 +1,66 @@ +import { Getters } from '@devexpress/dx-react-core'; +import { + getRowsVisibleBoundary, +} from '../../utils/virtual-table'; +import { RowsVisibleBoundaryFn } from '../../types'; +import { pageTriggersMeta } from './helpers'; + +export const getVisibleRowsBounds: RowsVisibleBoundaryFn = ( + state, getters, estimatedRowHeight, getRowHeight, +) => { + const { + viewportTop, containerHeight, headerHeight, footerHeight, + } = state; + const { + loadedRowsStart, + bodyRows, + headerRows = [], + footerRows = [], + } = getters; + + return { + viewportTop, + header: getRowsVisibleBoundary( + headerRows, 0, headerHeight, + getRowHeight, 0, estimatedRowHeight, + ), + body: getRowsVisibleBoundary( + bodyRows, viewportTop, containerHeight - headerHeight - footerHeight, + getRowHeight, loadedRowsStart, estimatedRowHeight, + ), + footer: getRowsVisibleBoundary( + footerRows, 0, footerHeight, + getRowHeight, 0, estimatedRowHeight, + ), + }; +}; + +export const nextPageReferenceIndex = ( + payload: any, + getters: Getters, +) => { + const triggersMeta = pageTriggersMeta(payload, getters); + if (triggersMeta === null) { + return null; + } + + const { + topTriggerPosition, bottomTriggerPosition, topTriggerIndex, bottomTriggerIndex, + } = triggersMeta; + const { viewportTop, estimatedRowHeight, containerHeight } = payload; + const referencePosition = viewportTop + containerHeight / 2; + + const getReferenceIndex = (triggetIndex: number, triggerPosition: number) => ( + triggetIndex + Math.round((referencePosition - triggerPosition) / estimatedRowHeight) + ); + + let referenceIndex: number | null = null; + if (referencePosition < topTriggerPosition) { + referenceIndex = getReferenceIndex(topTriggerIndex, topTriggerPosition); + } + if (bottomTriggerPosition < referencePosition) { + referenceIndex = getReferenceIndex(bottomTriggerIndex, bottomTriggerPosition); + } + + return referenceIndex; +}; diff --git a/packages/dx-grid-core/src/plugins/virtual-table/helpers.test.ts b/packages/dx-grid-core/src/plugins/virtual-table/helpers.test.ts new file mode 100644 index 0000000000..828995ae81 --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table/helpers.test.ts @@ -0,0 +1,139 @@ +import { pageTriggersMeta } from './helpers'; + +export const generateArguments = ({ + viewportTop = 0, visibleRowsStart = 0, visibleRowsEnd = 20, + loadedRowsStart = 0, rowsLength = 200, +}) => ({ + gridGeometry: { + visibleRowBoundaries: { + viewportTop, + body: { + start: visibleRowsStart, + end: visibleRowsEnd, + }, + }, + viewportTop, + containerHeight: 400, + estimatedRowHeight: 40, + }, + getters: { + virtualRows: { + skip: loadedRowsStart, + rows: Array.from({ length: rowsLength }).map(() => {}), + }, + pageSize: 100, + }, +}); + +describe('#pageTriggersMeta', () => { + it('should return null when rows not loaded', () => { + const { gridGeometry, getters } = generateArguments({ rowsLength: 0 }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toBeNull(); + }); + + describe('inside visible range', () => { + it('should calculate page triggers when grid is not scrolled', () => { + const { gridGeometry, getters } = generateArguments({}); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 0, + topTriggerPosition: -200, // 10 rows up + bottomTriggerIndex: 100, // next page boundary + bottomTriggerPosition: 3800, // 100'th row position in the midlle of viewport + }); + }); + + it('should calculate same page triggers when grid is scrolled half a page', () => { + const { gridGeometry, getters } = generateArguments({ + visibleRowsStart: 30, + visibleRowsEnd: 50, + viewportTop: 1200, + }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 0, + topTriggerPosition: -200, + bottomTriggerIndex: 100, + bottomTriggerPosition: 3800, + }); + }); + + it('should consider virtual rows skip', () => { + const { gridGeometry, getters } = generateArguments({ + visibleRowsStart: 230, + visibleRowsEnd: 250, + viewportTop: 9200, + loadedRowsStart: 100, + rowsLength: 300, + }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 200, // border between 2 and 3 pages + topTriggerPosition: 7800, + bottomTriggerIndex: 300, // next page boundary + bottomTriggerPosition: 11800, + }); + }); + + it('should correctly calculate triggers when only part of loaded rows is visible', () => { + const { gridGeometry, getters } = generateArguments({ + visibleRowsStart: 390, + visibleRowsEnd: 410, + viewportTop: 15600, + loadedRowsStart: 100, + rowsLength: 300, + }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 200, + topTriggerPosition: 7800, + bottomTriggerIndex: 300, // border between 3 and 4 pages + bottomTriggerPosition: 11800, + }); + }); + }); + + describe('outside the visible range', () => { + it('should calculate page triggers when loaded rows are below viewport', () => { + const { gridGeometry, getters } = generateArguments({ + visibleRowsStart: 200, + visibleRowsEnd: 220, + viewportTop: 8000, + loadedRowsStart: 400, + rowsLength: 300, + }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 500, + topTriggerPosition: 19800, + bottomTriggerIndex: 600, + bottomTriggerPosition: 23800, + }); + }); + + it('should calculate page triggers when loaded rows are above viewport', () => { + const { gridGeometry, getters } = generateArguments({ + visibleRowsStart: 1000, + visibleRowsEnd: 1020, + viewportTop: 40000, + loadedRowsStart: 400, + rowsLength: 300, + }); + + expect(pageTriggersMeta(gridGeometry, getters)) + .toEqual({ + topTriggerIndex: 500, + topTriggerPosition: 19800, + bottomTriggerIndex: 600, + bottomTriggerPosition: 23800, + }); + }); + }); +}); diff --git a/packages/dx-grid-core/src/plugins/virtual-table/helpers.ts b/packages/dx-grid-core/src/plugins/virtual-table/helpers.ts new file mode 100644 index 0000000000..ac031828fc --- /dev/null +++ b/packages/dx-grid-core/src/plugins/virtual-table/helpers.ts @@ -0,0 +1,34 @@ +import { PageTriggersMetaFn } from '../../types'; + +/** how many rows up and down before next page request */ +export const pageTriggersMeta: PageTriggersMetaFn = ( + { containerHeight, visibleRowBoundaries, estimatedRowHeight }, + { pageSize, virtualRows }, +) => { + const loadedCount = virtualRows.rows.length; + if (loadedCount === 0) { + return null; + } + + const loadedRowsStart = virtualRows.skip; + const topTriggerIndex = loadedRowsStart > 0 ? loadedRowsStart + pageSize : 0; + const bottomTriggerIndex = loadedRowsStart + loadedCount - pageSize; + const bodyBoundaries = visibleRowBoundaries.body; + const firstRowIndex = bodyBoundaries.start; + const visibleCount = bodyBoundaries.end - bodyBoundaries.start; + const middleIndex = firstRowIndex + Math.round(visibleCount / 2); + + const middlePosition = visibleRowBoundaries.viewportTop + containerHeight / 2; + + const topTriggerOffset = (middleIndex - topTriggerIndex) * estimatedRowHeight; + const bottomTriggerOffset = (bottomTriggerIndex - middleIndex) * estimatedRowHeight; + const topTriggerPosition = middlePosition - topTriggerOffset; + const bottomTriggerPosition = middlePosition + bottomTriggerOffset; + + return { + topTriggerIndex, + topTriggerPosition, + bottomTriggerIndex, + bottomTriggerPosition, + }; +}; diff --git a/packages/dx-grid-core/src/types/index.ts b/packages/dx-grid-core/src/types/index.ts index 106041792f..33a4fae5d0 100644 --- a/packages/dx-grid-core/src/types/index.ts +++ b/packages/dx-grid-core/src/types/index.ts @@ -17,3 +17,4 @@ export * from './summary.types'; export * from './merge-sort.types'; export * from './paging.types'; export * from './column-chooser.types'; +export * from './virtual-table-state.types'; diff --git a/packages/dx-grid-core/src/types/table.types.ts b/packages/dx-grid-core/src/types/table.types.ts index 1bd646fe14..36d95410a0 100644 --- a/packages/dx-grid-core/src/types/table.types.ts +++ b/packages/dx-grid-core/src/types/table.types.ts @@ -15,6 +15,8 @@ export interface TableRow { row?: any; /** Specifies the table row height. */ height?: number; + /** @internal */ + dataIndex?: number; } /** Describes properties of a table column that the Table plugin renders. */ diff --git a/packages/dx-grid-core/src/types/virtual-table-state.types.ts b/packages/dx-grid-core/src/types/virtual-table-state.types.ts new file mode 100644 index 0000000000..ac09265230 --- /dev/null +++ b/packages/dx-grid-core/src/types/virtual-table-state.types.ts @@ -0,0 +1,32 @@ +import { PureComputed } from '@devexpress/dx-core'; +import { Row } from '..'; + +/** @internal */ +export type Interval = { + start: number, + end: number, +}; +/** @internal */ +export type VirtualRows = { + skip: number, + rows: Row[], +}; + +/** @internal */ +export type VirtualRowsWithCacheFn = PureComputed< + [number, Row[], VirtualRows], VirtualRows +>; + +/** @internal */ +export type PlainRowsFn = PureComputed<[VirtualRows], Row[]>; + +/** @internal */ +export type LoadedRowsStartFn = PureComputed<[VirtualRows], number>; + +/** @internal */ +export type MergeRowsFn = PureComputed< + [Interval, Interval, Row[], Row[], number, number], VirtualRows +>; + +/** @internal */ +export type CalculateRequestedRangeFn = PureComputed<[Interval, Interval, number, number]>; diff --git a/packages/dx-grid-core/src/types/virtual-table.types.ts b/packages/dx-grid-core/src/types/virtual-table.types.ts index c4bb1942a4..b6c64a644c 100644 --- a/packages/dx-grid-core/src/types/virtual-table.types.ts +++ b/packages/dx-grid-core/src/types/virtual-table.types.ts @@ -1,5 +1,6 @@ -import { TableColumn, TableRow } from './table.types'; import { PureComputed } from '@devexpress/dx-core'; +import { Getters } from '@devexpress/dx-react-core'; +import { TableColumn, TableRow, GetCellColSpanFn } from './table.types'; /** @internal */ export type GetColumnWidthFn = PureComputed<[TableColumn, number?], number | null>; @@ -15,10 +16,19 @@ export type CollapsedCell = { column: Pick, colSpan type CollapsedRow = TableRow & { cells: any[], height: number }; /** @internal */ -export type VisibleBoundary = ReadonlyArray; +export type VisibleBoundary = ReadonlyArray; +/** @internal */ +export type RowsVisibleBoundary = { + start: number; + end: number; +}; +/** @internal */ +export type GridRowsBoundaries = Record<'header' | 'body' | 'footer', RowsVisibleBoundary> & { + viewportTop: number; // to anchor a boundary to specific coords +}; /** @internal */ export type GetVisibleBoundaryFn = PureComputed< - [ReadonlyArray, number, number, (item: any) => number | null, number], + [ReadonlyArray, number, number, (item: any) => number | null, number, number?], VisibleBoundary >; @@ -36,7 +46,7 @@ export type GetSpanBoundaryFn = PureComputed< /** @internal */ export type CollapseBoundariesFn = PureComputed< - [number, VisibleBoundary[], ReadonlyArray[]], + [number, VisibleBoundary[], ReadonlyArray[], number], VisibleBoundary[] >; @@ -55,7 +65,7 @@ export type GetCollapsedColumnsFn = PureComputed< /** @internal */ export type GetCollapsedAndStubRowsFn = PureComputed< // tslint:disable-next-line: max-line-length - [TableRow[], VisibleBoundary, VisibleBoundary[], GetRowHeightFn, (r: TableRow) => ReadonlyArray], + [TableRow[], VisibleBoundary, VisibleBoundary[], GetRowHeightFn, (r: TableRow) => ReadonlyArray, number], CollapsedRow[] >; @@ -69,14 +79,71 @@ export type GetCollapsedCellsFn = PureComputed< export type GetCollapsedGridFn = PureComputed< [{ rows: TableRow[], columns: TableColumn[], - top: number, height: number, left: number, width: number, - getColumnWidth: GetColumnWidthFn, getRowHeight: GetRowHeightFn, getColSpan: GetColSpanFn, + rowsVisibleBoundary?: VisibleBoundary, columnsVisibleBoundary: VisibleBoundary[], + getColumnWidth: GetColumnWidthFn, getRowHeight: GetRowHeightFn, + getColSpan: GetColSpanFn, + totalRowCount: number, + offset: number, }], { columns: CollapsedColumn[], rows: CollapsedRow[] } >; +/** @internal */ +export type CollapsedGrid = { columns: CollapsedColumn[], rows: CollapsedRow[] }; +/** @internal */ +export type GetCollapsedGridsFn = PureComputed< + [{ + headerRows: TableRow[], + bodyRows: TableRow[], + footerRows: TableRow[], + columns: TableColumn[], + loadedRowsStart: number, + totalRowCount: number, + getCellColSpan?: GetCellColSpanFn, + viewportLeft: number, + containerWidth: number, + visibleRowBoundaries: GridRowsBoundaries, + getColumnWidth: GetColumnWidthFn, + getRowHeight: GetRowHeightFn, + }], + { + headerGrid: CollapsedGrid, + bodyGrid: CollapsedGrid, + footerGrid: CollapsedGrid, + } +>; + /** @internal */ export type GetColumnWidthGetterFn = PureComputed< [TableColumn[], number, number], GetColumnWidthFn >; + +/** @internal */ +export type RowsVisibleBoundaryFn = PureComputed< + [any, Getters, number, GetRowHeightFn], GridRowsBoundaries +>; + +/** @internal */ +export type GetRenderBoundaryFn = PureComputed<[number, number[], number], number[]>; +/** @internal */ +export type GetSpecificRenderBoundaryFn = PureComputed<[number, number[]], number[]>; + +type PageTriggersMeta = { + topTriggerIndex: number, + topTriggerPosition: number, + bottomTriggerIndex: number, + bottomTriggerPosition: number, +}; +/** @internal */ +export type GridGeometry = { + viewportTop: number; + containerHeight: number; + visibleRowBoundaries: GridRowsBoundaries; + estimatedRowHeight: number; +}; + +/** @internal */ +export type PageTriggersMetaFn = PureComputed< + [GridGeometry, Getters], PageTriggersMeta | null +>; diff --git a/packages/dx-grid-core/src/utils/virtual-table.test.ts b/packages/dx-grid-core/src/utils/virtual-table.test.ts index ed1e8de50c..a5acf50204 100644 --- a/packages/dx-grid-core/src/utils/virtual-table.test.ts +++ b/packages/dx-grid-core/src/utils/virtual-table.test.ts @@ -10,6 +10,7 @@ import { getCollapsedGrid, TABLE_STUB_TYPE, getColumnWidthGetter, + getRenderBoundary, } from './virtual-table'; describe('VirtualTableLayout utils', () => { @@ -29,19 +30,44 @@ describe('VirtualTableLayout utils', () => { .toEqual([2, 4]); }); - it('should work with overscan', () => { + it('should consider rows start offset and default height', () => { const items = [ { size: 40 }, { size: 40 }, { size: 40 }, { size: 40 }, { size: 40 }, + ]; + + expect(getVisibleBoundary(items, 600, 120, item => item.size, 20, 30)) + .toEqual([20, 22]); + }); + + it('should work when rows are not loaded', () => { + const items = [ + { size: 40 }, + { size: 40 }, + { size: 40 }, { size: 40 }, { size: 40 }, ]; - expect(getVisibleBoundary(items, 80, 120, item => item.size, 1)) - .toEqual([1, 5]); + expect(getVisibleBoundary(items, 240, 120, item => item.size, 0, 40)) + .toEqual([6, 6]); + }); + }); + + describe('#getRenderBoundary', () => { + it('should correctly add overscan in simple case', () => { + expect(getRenderBoundary(20, [5, 10], 3)).toEqual([2, 13]); + }); + + it('should correctly add overscan when grid is scrolled to top', () => { + expect(getRenderBoundary(10, [0, 5], 3)).toEqual([0, 8]); + }); + + it('should correctly add overscan when grid is scrolled to bottom', () => { + expect(getRenderBoundary(10, [5, 9], 3)).toEqual([2, 9]); }); }); @@ -271,6 +297,17 @@ describe('VirtualTableLayout utils', () => { [9, 9], // visible ]); }); + + it('should work when visible rows not loaded', () => { + const itemsCount = 100; + const visibleBoundary = [[Infinity, -Infinity]]; + const spanBoundaries = []; + + expect(collapseBoundaries(itemsCount, visibleBoundary, spanBoundaries)) + .toEqual([ + [0, 99], // stub + ]); + }); }); describe('#getCollapsedColumns', () => { @@ -453,10 +490,10 @@ describe('VirtualTableLayout utils', () => { { key: 3, width: 40 }, // visible (overscan) { key: 4, width: 40 }, ], - top: 160, - left: 80, - height: 40, - width: 40, + rowsVisibleBoundary: [1, 7], + columnsVisibleBoundary: [[1, 3]], + totalRowCount: 9, + offset: 0, }; const result = getCollapsedGrid(args); @@ -477,26 +514,26 @@ describe('VirtualTableLayout utils', () => { .toEqual([...Array.from({ length: 5 }).map(() => 1)]); }); - it('should return empty result when there are no columns', () => { + it('should return empty result when there are no rows', () => { const args = { rows: [], columns: [ { key: 0, width: 40 }, ], - top: 0, - left: 0, - height: 80, - width: 80, + rowsVisibleBoundary: [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + columnsVisibleBoundary: [[0, 1]], + totalRowCount: 0, + offset: 0, }; const result = { - columns: [], + columns: args.columns, rows: [], }; expect(getCollapsedGrid(args)) .toEqual(result); }); - it('should return empty result when there are no rows', () => { + it('should return empty result when there are no columns', () => { const args = { rows: [ { key: 0, height: 40 }, @@ -535,10 +572,8 @@ describe('VirtualTableLayout utils', () => { { key: 7, width: 40 }, // stub ┘ { key: 8, width: 40 }, // stub ], - top: 0, - left: 160, - height: 40, - width: 40, + rowsVisibleBoundary: [0, 3], + columnsVisibleBoundary: [[3, 5]], getColSpan: (row, column) => { if (row.key === 0 && column.key === 2) return 2; if (row.key === 0 && column.key === 5) return 3; @@ -546,6 +581,8 @@ describe('VirtualTableLayout utils', () => { if (row.key === 2 && column.key === 0) return 9; return 1; }, + totalRowCount: 5, + offset: 0, }; const result = getCollapsedGrid(args); diff --git a/packages/dx-grid-core/src/utils/virtual-table.ts b/packages/dx-grid-core/src/utils/virtual-table.ts index bf004733d0..f4ef1272aa 100644 --- a/packages/dx-grid-core/src/utils/virtual-table.ts +++ b/packages/dx-grid-core/src/utils/virtual-table.ts @@ -1,12 +1,18 @@ +import { PureComputed } from '@devexpress/dx-core'; import { GetVisibleBoundaryWithFixedFn, VisibleBoundary, GetVisibleBoundaryFn, GetSpanBoundaryFn, CollapseBoundariesFn, GetColumnsSizeFn, GetCollapsedColumnsFn, CollapsedColumn, GetCollapsedAndStubRowsFn, GetCollapsedCellsFn, GetCollapsedGridFn, GetColumnWidthFn, - GetRowHeightFn, + TableColumn, TableRow, CollapsedCell, GetColumnWidthGetterFn, + RowsVisibleBoundary, + GetCollapsedGridsFn, + CollapsedGrid, + GetRenderBoundaryFn, + GetSpecificRenderBoundaryFn, } from '../types'; -import { TABLE_FLEX_TYPE } from '..'; +import { TABLE_FLEX_TYPE, intervalUtil } from '..'; export const TABLE_STUB_TYPE = Symbol('stub'); @@ -20,14 +26,24 @@ export const getVisibleBoundaryWithFixed: GetVisibleBoundaryWithFixedFn = ( }, [visibleBoundary] as [VisibleBoundary]); export const getVisibleBoundary: GetVisibleBoundaryFn = ( - items, viewportStart, viewportSize, getItemSize, overscan, + items, viewportStart, viewportSize, getItemSize, offset = 0, itemSize = 0, ) => { let start: number | null = null; let end: number | null = null; + let index = 0; + let beforePosition = offset * itemSize; + const noVisibleRowsLoaded = itemSize > 0 && + beforePosition + items.length * itemSize < viewportStart || + viewportStart < beforePosition; + + if (noVisibleRowsLoaded) { + beforePosition = viewportStart; + index = items.length; + start = Math.round(viewportStart / itemSize) - offset; + end = start; + } const viewportEnd = viewportStart + viewportSize; - let index = 0; - let beforePosition = 0; while (end === null && index < items.length) { const item = items[index]; const afterPosition = beforePosition + getItemSize(item)!; @@ -51,92 +67,104 @@ export const getVisibleBoundary: GetVisibleBoundaryFn = ( start = start === null ? 0 : start; end = end === null ? 0 : end; - if (overscan) { - start = Math.max(0, start - overscan); - end = Math.min(items.length - 1, end + overscan); - } + return [start + offset, end + offset]; +}; + +export const getRenderBoundary: GetRenderBoundaryFn = (itemsCount, visibleBoundary, overscan) => { + let [start, end] = visibleBoundary; + start = Math.max(0, start - overscan); + end = Math.min(itemsCount - 1, end + overscan); return [start, end]; }; +export const getColumnsVisibleBoundary: PureComputed< + [TableColumn[], number, number, GetColumnWidthFn], VisibleBoundary[] +> = (columns, left, width, getColumnWidth) => ( + getVisibleBoundaryWithFixed( + getVisibleBoundary(columns, left, width, getColumnWidth, 0), + columns, + ) +); +export const getRowsVisibleBoundary: PureComputed< +[TableRow[], number, number, GetColumnWidthFn, number, number], RowsVisibleBoundary +> = (rows, top, height, getRowHeight, offset, rowHeight) => { + const boundaries = getVisibleBoundary(rows, top, height, getRowHeight, offset, rowHeight); + const start = boundaries[0]; + const end = boundaries[1]; + + return { + start, + end, + }; +}; + +export const getColumnsRenderBoundary: GetSpecificRenderBoundaryFn = ( + columnCount, visibleBoundary, +) => getRenderBoundary(columnCount, visibleBoundary, 1); + +export const getRowsRenderBoundary: GetSpecificRenderBoundaryFn = ( + rowsCount, visibleBoundary, +) => getRenderBoundary(rowsCount, visibleBoundary, 3); + export const getSpanBoundary: GetSpanBoundaryFn = ( items, visibleBoundaries, getItemSpan, ) => visibleBoundaries - .map((visibleBoundary) => { - let [start, end] = visibleBoundary; - - for (let index = 0; index <= visibleBoundary[1]; index += 1) { - const span = getItemSpan(items[index]); - if (index < visibleBoundary[0] && index + span > visibleBoundary[0]) { - start = index; - } - if (index + (span - 1) > visibleBoundary[1]) { - end = index + (span - 1); - } - } - return [start, end] as VisibleBoundary; - }); + .map((visibleBoundary) => { + let [start, end] = visibleBoundary; + for (let index = 0; index <= visibleBoundary[1]; index += 1) { + const span = getItemSpan(items[index]); + if (index < visibleBoundary[0] && index + span > visibleBoundary[0]) { + start = index; + } + if (index + (span - 1) > visibleBoundary[1]) { + end = index + (span - 1); + } + } + return [start, end] as VisibleBoundary; + }); export const collapseBoundaries: CollapseBoundariesFn = ( itemsCount, visibleBoundaries, spanBoundaries, ) => { - const boundaries: VisibleBoundary[] = []; - - const visiblePoints = visibleBoundaries.reduce((acc: number[], boundary) => { - for (let point = boundary[0]; point <= boundary[1]; point += 1) { - acc.push(point); - } - return acc; - }, []); - - const spanStartPoints = new Set(); - const spanEndPoints = new Set(); + const breakpoints = new Set([0, itemsCount]); spanBoundaries.forEach(rowBoundaries => rowBoundaries .forEach((boundary) => { - spanStartPoints.add(boundary[0]); - spanEndPoints.add(boundary[1]); + breakpoints.add(boundary[0]); + if (boundary[1] - 1 < itemsCount) { + // next interval starts after span end point + breakpoints.add(boundary[1] + 1); + } })); - let lastPoint: number | undefined; - for (let index = 0; index < itemsCount; index += 1) { - if (visiblePoints.indexOf(index) !== -1) { - if (lastPoint !== undefined) { - boundaries.push([lastPoint, index - 1]); - lastPoint = undefined; + visibleBoundaries + .filter(boundary => boundary.every(bound => 0 <= bound && bound < itemsCount)) + .forEach((boundary) => { + for (let point = boundary[0]; point <= boundary[1]; point += 1) { + breakpoints.add(point); } - boundaries.push([index, index]); - } else if (spanStartPoints.has(index)) { - if (index > 0) { - boundaries.push([ - lastPoint !== undefined ? lastPoint : index, - index - 1, - ]); + if (boundary[1] + 1 < itemsCount) { + // close last visible point + breakpoints.add(boundary[1] + 1); } - lastPoint = index; - } else if (spanEndPoints.has(index)) { - boundaries.push([ - lastPoint !== undefined ? lastPoint : index, - index, - ]); - lastPoint = undefined; - } else if (lastPoint === undefined) { - lastPoint = index; - } - } + }); - if (lastPoint !== undefined) { - boundaries.push([lastPoint, itemsCount - 1]); + const bp = [...breakpoints].sort((a, b) => a - b); + const bounds: any[] = []; + for (let i = 0; i < bp.length - 1; i += 1) { + bounds.push([ + bp[i], + bp[i + 1] - 1, + ]); } - return boundaries; + return bounds; }; const getColumnsSize: GetColumnsSizeFn = (columns, startIndex, endIndex, getColumnSize) => { let size = 0; - let index; - const loopEndIndex = endIndex + 1; - for (index = startIndex; index < loopEndIndex; index += 1) { - size += getColumnSize(columns[index], 0) || 0; + for (let i = startIndex; i <= endIndex; i += 1) { + size += getColumnSize(columns[i], 0) || 0; } return size; }; @@ -168,24 +196,26 @@ export const getCollapsedColumns: GetCollapsedColumnsFn = ( }; export const getCollapsedRows: GetCollapsedAndStubRowsFn = ( - rows, visibleBoundary, boundaries, getRowHeight, getCells, + rows, visibleBoundary, boundaries, getRowHeight, getCells, offset, ) => { const collapsedRows: any[] = []; boundaries.forEach((boundary) => { const isVisible = visibleBoundary[0] <= boundary[0] && boundary[1] <= visibleBoundary[1]; if (isVisible) { - const row = rows[boundary[0]]; + const row = rows[boundary[0] - offset]; collapsedRows.push({ row, cells: getCells(row), }); } else { + const row = {} as any; collapsedRows.push({ row: { key: `${TABLE_STUB_TYPE.toString()}_${boundary[0]}_${boundary[1]}`, type: TABLE_STUB_TYPE, height: getColumnsSize(rows, boundary[0], boundary[1], getRowHeight), }, + cells: getCells(row), }); } }); @@ -232,28 +262,25 @@ export const getCollapsedCells: GetCollapsedCellsFn = ( export const getCollapsedGrid: GetCollapsedGridFn = ({ rows, columns, - top, - height, - left, - width, - getColumnWidth = (column => column.width) as GetColumnWidthFn, - getRowHeight = (row => row.height) as GetRowHeightFn, - getColSpan = (...args: any[]) => 1, + rowsVisibleBoundary, + columnsVisibleBoundary, + getColumnWidth = (column: any) => column.width, + getRowHeight = (row: any) => row.height, + getColSpan = () => 1, + totalRowCount, + offset, }) => { - if (!rows.length || !columns.length) { + if (!columns.length) { return { columns: [], rows: [], }; } - const rowsVisibleBoundary = getVisibleBoundary(rows, top, height, getRowHeight, 3); - const columnsVisibleBoundary = getVisibleBoundaryWithFixed( - getVisibleBoundary(columns, left, width, getColumnWidth, 1), - columns, - ); + + const boundaries = rowsVisibleBoundary || [0, rows.length - 1 || 1]; const rowSpanBoundaries = rows - .slice(rowsVisibleBoundary[0], rowsVisibleBoundary[1]) + .slice(boundaries[0], boundaries[1]) .map(row => getSpanBoundary( columns, columnsVisibleBoundary, @@ -263,9 +290,10 @@ export const getCollapsedGrid: GetCollapsedGridFn = ({ columns.length, columnsVisibleBoundary, rowSpanBoundaries, + 0, ); - const rowBoundaries = collapseBoundaries(rows.length, [rowsVisibleBoundary], []); + const rowBoundaries = collapseBoundaries(totalRowCount!, [boundaries], [], offset); return { columns: getCollapsedColumns( @@ -276,7 +304,7 @@ export const getCollapsedGrid: GetCollapsedGridFn = ({ ), rows: getCollapsedRows( rows, - rowsVisibleBoundary, + boundaries, rowBoundaries, getRowHeight, row => getCollapsedCells( @@ -289,6 +317,7 @@ export const getCollapsedGrid: GetCollapsedGridFn = ({ columnBoundaries, column => getColSpan(row, column), ), + offset, ), }; }; @@ -305,3 +334,88 @@ export const getColumnWidthGetter: GetColumnWidthGetterFn = ( ? null : column.width || autoColWidth); }; + +export const getCollapsedGrids: GetCollapsedGridsFn = ({ + headerRows = [], + bodyRows = [], + footerRows = [], + columns, + loadedRowsStart, + totalRowCount, + getCellColSpan, + viewportLeft, + containerWidth, + visibleRowBoundaries, + getColumnWidth, + getRowHeight, + }, +) => { + const getColSpan = ( + tableRow: any, tableColumn: any, + ) => getCellColSpan!({ tableRow, tableColumn, tableColumns: columns }); + + const visibleColumnBoundaries = [ + getColumnsRenderBoundary( + columns.length, + getColumnsVisibleBoundary( + columns, viewportLeft, containerWidth, getColumnWidth, + )[0], + ), + ]; + const getCollapsedGridBlock: PureComputed< + [any[], any[]?, number?, number?], CollapsedGrid + > = ( + rows, rowsVisibleBoundary, rowCount = rows.length, offset = 0, + ) => getCollapsedGrid({ + rows, + columns, + rowsVisibleBoundary, + columnsVisibleBoundary: visibleColumnBoundaries, + getColumnWidth, + getRowHeight, + getColSpan, + totalRowCount: rowCount, + offset, + }); + + const headerGrid = getCollapsedGridBlock( + headerRows, getRenderRowBounds(visibleRowBoundaries.header, headerRows.length), + ); + const bodyGrid = getCollapsedGridBlock( + bodyRows, + adjustedRenderRowBounds( + visibleRowBoundaries.body, bodyRows.length, loadedRowsStart, + ), + totalRowCount || 1, + loadedRowsStart, + ); + const footerGrid = getCollapsedGridBlock( + footerRows, getRenderRowBounds(visibleRowBoundaries.footer, footerRows.length), + ); + + return { + headerGrid, + bodyGrid, + footerGrid, + }; +}; + +const getRenderRowBounds: PureComputed<[RowsVisibleBoundary, number], number[]> = ( + visibleBounds, rowCount, +) => getRowsRenderBoundary( + rowCount, + [visibleBounds.start, visibleBounds.end], +); + +const adjustedRenderRowBounds: PureComputed<[RowsVisibleBoundary, number, number], number[]> = ( + visibleBounds, rowCount, loadedRowsStart, +) => { + const renderRowBoundaries = getRenderRowBounds( + visibleBounds, loadedRowsStart + rowCount, + ); + const adjustedInterval = intervalUtil.intersect( + { start: renderRowBoundaries[0], end: renderRowBoundaries[1] }, + { start: loadedRowsStart, end: loadedRowsStart + rowCount }, + ); + return [adjustedInterval.start, adjustedInterval.end]; +}; diff --git a/packages/dx-react-core/src/draggable.tsx b/packages/dx-react-core/src/draggable.tsx index 2882380d3b..d8c17e472d 100644 --- a/packages/dx-react-core/src/draggable.tsx +++ b/packages/dx-react-core/src/draggable.tsx @@ -20,7 +20,6 @@ export class Draggable extends React.Component { constructor(props, context) { super(props, context); - const delegate = { onStart: ({ x, y }) => { const { onStart } = this.props; diff --git a/packages/dx-react-grid-bootstrap3/src/plugins/virtual-table.jsx b/packages/dx-react-grid-bootstrap3/src/plugins/virtual-table.jsx index 83dbc1ab34..878e992dc5 100644 --- a/packages/dx-react-grid-bootstrap3/src/plugins/virtual-table.jsx +++ b/packages/dx-react-grid-bootstrap3/src/plugins/virtual-table.jsx @@ -3,6 +3,7 @@ import { makeVirtualTable } from '@devexpress/dx-react-grid'; import { Table } from './table'; import { Table as TableComponent } from '../templates/table'; import { VirtualTableLayout as VirtualLayout } from '../templates/virtual-table-layout'; +import { TableSkeletonCell as SkeletonCell } from '../templates/table-skeleton-cell'; const FixedHeader = props => ; const FixedFooter = props => ; @@ -11,6 +12,7 @@ export const VirtualTable = makeVirtualTable(Table, { VirtualLayout, FixedHeader, FixedFooter, + SkeletonCell, defaultEstimatedRowHeight: 37, defaultHeight: 530, }); diff --git a/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.jsx b/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.jsx new file mode 100644 index 0000000000..08f43102c3 --- /dev/null +++ b/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.jsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; + +export const TableSkeletonCell = ({ + style, + tableRow, + tableColumn, + ...restProps +}) => ( + +); + +TableSkeletonCell.propTypes = { + style: PropTypes.object, + tableRow: PropTypes.object, + tableColumn: PropTypes.object, +}; + +TableSkeletonCell.defaultProps = { + style: null, + tableRow: undefined, + tableColumn: undefined, +}; diff --git a/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.test.jsx b/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.test.jsx new file mode 100644 index 0000000000..e9a95f21e7 --- /dev/null +++ b/packages/dx-react-grid-bootstrap3/src/templates/table-skeleton-cell.test.jsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { TableSkeletonCell } from './table-skeleton-cell'; + +describe('TableSkeletonCell', () => { + it('should pass rest props to the root element', () => { + const tree = shallow(( + + )); + + expect(tree.is('.custom-class')) + .toBeTruthy(); + }); +}); diff --git a/packages/dx-react-grid-bootstrap4/src/plugins/virtual-table.jsx b/packages/dx-react-grid-bootstrap4/src/plugins/virtual-table.jsx index 716c4baf4b..2ebeb5ab2c 100644 --- a/packages/dx-react-grid-bootstrap4/src/plugins/virtual-table.jsx +++ b/packages/dx-react-grid-bootstrap4/src/plugins/virtual-table.jsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { makeVirtualTable } from '@devexpress/dx-react-grid'; import { Table } from './table'; import { Table as TableComponent } from '../templates/table'; +import { TableSkeletonCell as SkeletonCell } from '../templates/table-skeleton-cell'; import { VirtualTableLayout as VirtualLayout } from '../templates/virtual-table-layout'; const FixedHeader = props => ; @@ -11,6 +12,7 @@ export const VirtualTable = makeVirtualTable(Table, { VirtualLayout, FixedHeader, FixedFooter, + SkeletonCell, defaultEstimatedRowHeight: 49, defaultHeight: 530, }); diff --git a/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.jsx b/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.jsx new file mode 100644 index 0000000000..d0b1fec603 --- /dev/null +++ b/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.jsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export const TableSkeletonCell = ({ + className, + tableRow, + tableColumn, + ...restProps +}) => ( + +); + +TableSkeletonCell.propTypes = { + className: PropTypes.string, + tableRow: PropTypes.object, + tableColumn: PropTypes.object, +}; + +TableSkeletonCell.defaultProps = { + className: undefined, + tableRow: undefined, + tableColumn: undefined, +}; diff --git a/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.test.jsx b/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.test.jsx new file mode 100644 index 0000000000..160301abd1 --- /dev/null +++ b/packages/dx-react-grid-bootstrap4/src/templates/table-skeleton-cell.test.jsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { TableSkeletonCell } from './table-skeleton-cell'; + +describe('TableSkeletonCell', () => { + it('should pass custom class to the root element', () => { + const tree = shallow(( + + )); + + expect(tree.is('.dx-g-bs4-skeleton-cell.custom-class')) + .toBeTruthy(); + }); + + it('should pass rest props to the root element', () => { + const tree = shallow(( + + )); + + expect(tree.prop('data')) + .toEqual({ a: 1 }); + }); +}); diff --git a/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/infinite-scrolling.jsxt b/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/infinite-scrolling.jsxt new file mode 100644 index 0000000000..bcaee39c39 --- /dev/null +++ b/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/infinite-scrolling.jsxt @@ -0,0 +1,102 @@ +import * as React from 'react';<%&additionalImports%> +import { + VirtualTableState, + createRemoteRowsCache, +} from '@devexpress/dx-react-grid'; +import { + Grid, + VirtualTable, + TableHeaderRow, +} from '@devexpress/dx-react-grid-<%&themeName%>'; + +const VIRTUAL_PAGE_SIZE = 100; +const MAX_ROWS = 50000; +const URL = 'https://js.devexpress.com/Demos/WidgetsGalleryDataService/api/Sales'; + +const buildQueryString = (skip, take) => ( + `${URL}?requireTotalCount=true&skip=${skip}&take=${take}` +); +const getRowId = row => row.Id; + +export default class Demo extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + columns: [ + { name: 'id', title: 'Id', getCellValue: row => row.Id }, + { name: 'category', title: 'Category', getCellValue: row => row.ProductCategoryName }, + { name: 'store', title: 'Store', getCellValue: row => row.StoreName }, + { name: 'product', title: 'Product', getCellValue: row => row.ProductName }, + { name: 'date', title: 'Date', getCellValue: row => row.DateKey }, + { name: 'amount', title: 'Amount', getCellValue: row => row.SalesAmount }, + ], + rows: [], + tableColumnExtensions: [ + { columnName: 'id', width: 80 }, + { columnName: 'category', width: 220 }, + { columnName: 'store', width: 220 }, + { columnName: 'date', width: 150 }, + { columnName: 'amount', width: 120 }, + ], + skip: 0, + totalCount: MAX_ROWS, + loading: true, + }; + + const cache = createRemoteRowsCache(VIRTUAL_PAGE_SIZE); + const setRows = (skip, count, totalCount) => { + const rows = cache.getRows(skip, count); + this.setState({ + rows, + skip, + totalCount: totalCount < MAX_ROWS ? totalCount : MAX_ROWS, + loading: false, + }); + }; + + this.getRemoteRows = (skip, take) => { + const cached = cache.getRows(skip, take); + if (cached.length) { + setRows(skip, take); + } else { + this.setState({ loading: true }); + const query = buildQueryString(skip, take); + fetch(query, { mode: 'cors' }) + .then(response => response.json()) + .then(({ data, totalCount }) => { + cache.setRows(skip, data); + setRows(skip, take, totalCount); + }) + .catch(() => this.setState({ loading: false })); + } + }; + } + + render() { + const { + rows, columns, skip, totalCount, tableColumnExtensions, loading, + } = this.state; + + return ( + <<%&wrapperTag%><%&wrapperAttributes%>> + + + + + + > + ); + } +} diff --git a/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/remote-data.jsxt b/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/remote-data.jsxt new file mode 100644 index 0000000000..c9c3a0b915 --- /dev/null +++ b/packages/dx-react-grid-demos/src/demo-sources/grid-virtual-scrolling/remote-data.jsxt @@ -0,0 +1,167 @@ +import * as React from 'react';<%&additionalImports%> +import { + VirtualTableState, + DataTypeProvider, + FilteringState, + SortingState, + createRemoteRowsCache, +} from '@devexpress/dx-react-grid'; +import { + Grid, + VirtualTable, + TableHeaderRow, + TableFilterRow, +} from '@devexpress/dx-react-grid-<%&themeName%>'; +import { Loading } from '../../../theme-sources/<%&themeName%>/components/loading'; + +const VIRTUAL_PAGE_SIZE = 100; +const MAX_ROWS = 50000; +const URL = 'https://js.devexpress.com/Demos/WidgetsGalleryDataService/api/Sales'; +const getRowId = row => row.Id; + +const CurrencyFormatter = ({ value }) => ( + +$ + {value} + +); + +const CurrencyTypeProvider = props => ( + +); + +const DateFormatter = ({ value }) => value.replace(/(\d{4})-(\d{2})-(\d{2})(T.*)/, '$3.$2.$1'); + +const DateTypeProvider = props => ( + +); + +export default class Demo extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + columns: [ + { name: 'Id', title: 'Id', getCellValue: row => row.Id }, + { name: 'ProductCategoryName', title: 'Category', getCellValue: row => row.ProductCategoryName }, + { name: 'StoreName', title: 'Store', getCellValue: row => row.StoreName }, + { name: 'ProductName', title: 'Product', getCellValue: row => row.ProductName }, + { name: 'DateKey', title: 'Date', getCellValue: row => row.DateKey }, + { name: 'SalesAmount', title: 'Amount', getCellValue: row => row.SalesAmount }, + ], + rows: [], + tableColumnExtensions: [ + { columnName: 'Id', width: 80 }, + { columnName: 'ProductCategoryName', width: 220 }, + { columnName: 'StoreName', width: 220 }, + { columnName: 'DateKey', width: 150 }, + { columnName: 'SalesAmount', width: 120 }, + ], + skip: 0, + totalCount: MAX_ROWS, + loading: true, + filters: [], + sorting: [], + }; + + const cache = createRemoteRowsCache(VIRTUAL_PAGE_SIZE); + const setRows = (skip, count, totalCount) => { + const rows = cache.getRows(skip, count); + this.setState({ + rows, + skip, + totalCount: totalCount < MAX_ROWS ? totalCount : MAX_ROWS, + loading: false, + }); + }; + + this.getRemoteRows = (skip, take) => { + const cached = cache.getRows(skip, take); + if (cached.length) { + setRows(skip, take); + } else { + this.setState({ loading: true }); + const query = this.buildQueryString(skip, take); + fetch(query, { mode: 'cors' }) + .then(response => response.json()) + .then(({ data, totalCount }) => { + cache.setRows(skip, data); + setRows(skip, take, totalCount); + }) + .catch(() => this.setState({ loading: false })); + } + }; + + this.changeFilters = (filters) => { + cache.invalidate(); + this.setState({ filters, rows: [] }); + }; + + this.changeSorting = (sorting) => { + cache.invalidate(); + this.setState({ sorting, rows: [] }); + }; + } + + buildQueryString(skip, take) { + const { filters, sorting } = this.state; + const filterStr = filters + .map(({ columnName, value, operation }) => ( + `["${columnName}","${operation}","${value}"]` + )).join(',"and",'); + const sortingConfig = sorting + .map(({ columnName, direction }) => ({ + selector: columnName, + desc: direction === 'desc', + })); + const sortingStr = JSON.stringify(sortingConfig); + const filterQuery = filterStr ? `&filter=${escape(filterStr)}` : ''; + const sortQuery = sortingStr ? `&sort=${escape(`${sortingStr}`)}` : ''; + + return `${URL}?requireTotalCount=true&skip=${skip}&take=${take}${filterQuery}${sortQuery}`; + } + + render() { + const { + rows, columns, skip, totalCount, tableColumnExtensions, loading, filters, sorting, + } = this.state; + + return ( + <<%&wrapperTag%><%&wrapperAttributes%>> + + + + + + + + + + + {loading && } + > + ); + } +} diff --git a/packages/dx-react-grid-material-ui/src/plugins/table.jsx b/packages/dx-react-grid-material-ui/src/plugins/table.jsx index c6aacb6bfa..4e24cfc038 100644 --- a/packages/dx-react-grid-material-ui/src/plugins/table.jsx +++ b/packages/dx-react-grid-material-ui/src/plugins/table.jsx @@ -28,6 +28,8 @@ export const Table = withComponents({ StubHeaderCell: StubCell, })(TableBase); +Table.components = TableBase.components; + Table.COLUMN_TYPE = TableBase.COLUMN_TYPE; Table.ROW_TYPE = TableBase.ROW_TYPE; Table.NODATA_ROW_TYPE = TableBase.NODATA_ROW_TYPE; diff --git a/packages/dx-react-grid-material-ui/src/plugins/virtual-table.jsx b/packages/dx-react-grid-material-ui/src/plugins/virtual-table.jsx index c5403efc12..fb9a6521a2 100644 --- a/packages/dx-react-grid-material-ui/src/plugins/virtual-table.jsx +++ b/packages/dx-react-grid-material-ui/src/plugins/virtual-table.jsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { makeVirtualTable } from '@devexpress/dx-react-grid'; import { Table } from './table'; import { Table as TableComponent } from '../templates/table'; +import { TableSkeletonCell as SkeletonCell } from '../templates/table-skeleton-cell'; import { VirtualTableLayout as VirtualLayout } from '../templates/virtual-table-layout'; const FixedHeader = props => ; @@ -11,6 +12,7 @@ export const VirtualTable = makeVirtualTable(Table, { VirtualLayout, FixedHeader, FixedFooter, + SkeletonCell, defaultEstimatedRowHeight: 48, defaultHeight: 530, }); diff --git a/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.jsx b/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.jsx new file mode 100644 index 0000000000..b3bd2faca8 --- /dev/null +++ b/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.jsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { withStyles } from '@material-ui/core/styles'; +import TableCell from '@material-ui/core/TableCell'; + +const styles = theme => ({ + cell: { + padding: theme.spacing.unit, + backgroundImage: 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAACqCAYAAABbAOqQAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA39pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpjYWQ2ODE5MS00ZDMxLWRjNGYtOTU0NC1jNjJkMTIxMjY2M2IiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MjY1RUVFQzAzRDYzMTFFODlFNThCOUJBQjU4Q0EzRDgiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MjY1RUVFQkYzRDYzMTFFODlFNThCOUJBQjU4Q0EzRDgiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjVlMjM1Y2U0LTc5ZWUtNGI0NC05ZjlkLTk2NTZmZGFjNjhhNCIgc3RSZWY6ZG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjk1OTQ2MjBiLTUyMTQtYTM0Yy04Nzc5LTEwMmEyMTY4MTlhOSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvLbJKYAAADrSURBVHja7N3BDYBACABBsQn7L48q0BoMD5SZxAZuc74gF1V1MMfpCARBEEEQRBAEEQRBdovnuxxDq3RD/LIQRBAEQRBBEEQQBBEEQQQBAAAAAAAAABhi8gZVbgxi6kQQBBEEQQRBEEEQRBAEQRBBAAAAAAAAAAAabX2Daux2lqkTQRBEEAQRBEEEQRBBEARBBAEAAAAAAAAAaLR1g2osUyeCIIggCCIIggiCIIIgCIIIAgAAAAAAAADQ6KsbVPnXIKZOBEEQQRBEEAQRBEEEQRAEEYRXoqqcghuCIIIgiCAIIgiCCMIUtwADALYCCr92l++TAAAAAElFTkSuQmCC)', + backgroundRepeat: 'no-repeat repeat', + backgroundOrigin: 'content-box', + }, +}); + +const TableSkeletonCellBase = ({ + classes, + className, + tableRow, + tableColumn, + ...restProps +}) => ( + +); + +TableSkeletonCellBase.propTypes = { + classes: PropTypes.object.isRequired, + className: PropTypes.string, + tableRow: PropTypes.object, + tableColumn: PropTypes.object, +}; + +TableSkeletonCellBase.defaultProps = { + className: undefined, + tableRow: undefined, + tableColumn: undefined, +}; + +export const TableSkeletonCell = withStyles(styles, { name: 'TableSkeletonCell' })(TableSkeletonCellBase); diff --git a/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.test.jsx b/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.test.jsx new file mode 100644 index 0000000000..74167b36dc --- /dev/null +++ b/packages/dx-react-grid-material-ui/src/templates/table-skeleton-cell.test.jsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createShallow, getClasses } from '@material-ui/core/test-utils'; +import { TableSkeletonCell } from './table-skeleton-cell'; + +describe('TableSkeletonCell', () => { + let shallow; + let classes; + beforeAll(() => { + shallow = createShallow({ dive: true }); + classes = getClasses(); + }); + + it('should pass the className prop to the root element', () => { + const tree = shallow(( + + )); + + expect(tree.is(`.${classes.cell}`)) + .toBeTruthy(); + + expect(tree.is('.custom-class')) + .toBeTruthy(); + }); + + it('should pass rest props to the root element', () => { + const tree = shallow(( + + )); + + expect(tree.props().data) + .toMatchObject({ a: 1 }); + }); +}); diff --git a/packages/dx-react-grid/api/dx-react-grid.api.ts b/packages/dx-react-grid/api/dx-react-grid.api.ts index 6a25c5e491..ae4547c270 100644 --- a/packages/dx-react-grid/api/dx-react-grid.api.ts +++ b/packages/dx-react-grid/api/dx-react-grid.api.ts @@ -69,6 +69,16 @@ interface ColumnChooserProps { toggleButtonComponent: React.ComponentType; } +// @public (undocumented) +declare const createRemoteRowsCache: (pageSize: number, capacity?: number) => { + // (undocumented) + getRows: (skip: number, count: number) => any[]; + // (undocumented) + setRows: (skip: number, rows: ReadonlyArray) => void; + // (undocumented) + invalidate: () => void; +}; + // @public declare const CustomGrouping: React.ComponentType; @@ -1281,11 +1291,32 @@ interface VirtualTableProps { noDataCellComponent: React.ComponentType; noDataRowComponent: React.ComponentType; rowComponent: React.ComponentType; + // (undocumented) + skeletonCellComponent: React.ComponentType; stubCellComponent: React.ComponentType; stubHeaderCellComponent: React.ComponentType; stubRowComponent: React.ComponentType; tableComponent: React.ComponentType; } +// @public (undocumented) +declare const VirtualTableState: React.ComponentType; + +// @public (undocumented) +interface VirtualTableStateProps { + // (undocumented) + getRows: (skip: number, take: number) => void; + // (undocumented) + infiniteScrolling: boolean; + // (undocumented) + loading: boolean; + // (undocumented) + pageSize?: number; + // (undocumented) + skip: number; + // (undocumented) + totalRowCount: number; +} + // (No @packageDocumentation comment for this package) diff --git a/packages/dx-react-grid/docs/reference/virtual-table-state.md b/packages/dx-react-grid/docs/reference/virtual-table-state.md new file mode 100644 index 0000000000..6ce0efacf7 --- /dev/null +++ b/packages/dx-react-grid/docs/reference/virtual-table-state.md @@ -0,0 +1,47 @@ +# VirtualTableState Plugin Reference + +A plugin that manages remote data for the virtual table. + +## Import + +Use the following statement to import the plugin: + +```js +import { VirtualTableState } from '@devexpress/dx-react-grid'; +``` + +## User Reference + +### Dependencies + +none + +### Properties + +Name | Type | Default | Description +-----|------|---------|------------ +skip | number | | Specifies the index that the first row of the current chunk has in the entire data set. +totalRowCount | number | | Specifies the total row count. +pageSize? | number | 100 | Specifies the count of rows in the current chunk. +loading | boolean | | Specifies whether data is loading. +infiniteScrolling? | boolean | | Enables a scrolling mode in which rows are loaded in sequence. +getRows | (skip: number, take: number) => void | | Starts to load remote data for grid rows. + +## Plugin Developer Reference + +### Imports + +Name | Plugin | Type | Description +-----|--------|------|------------ +rows | [Getter](../../../dx-react-core/docs/reference/getter.md) | Array<any> | Rows to be rendered. + +### Exports + +Name | Plugin | Type | Description +-----|--------|------|------------ +isDataRemote | [Getter](../../../dx-react-core/docs/reference/getter.md) | boolean | Indicates whether data is loaded from a remote source. +isDataLoading | [Getter](../../../dx-react-core/docs/reference/getter.md) | boolean | Indicates whether data is loading. +isScrollingInfinite | [Getter](../../../dx-react-core/docs/reference/getter.md) | boolean | Indicates whether the infinite scrolling mode is enabled. +skip | [Getter](../../../dx-react-core/docs/reference/getter.md) | number | The index that the first row of the current chunk has in the entire data set. +requestNextPage | [Action](../../../dx-react-core/docs/reference/action.md) | (skip: number, take: number) => void | Starts to load the next data chunk. +clearRowCache | [Action](../../../dx-react-core/docs/reference/action.md) | () => void | Removes loaded rows from the cache. diff --git a/packages/dx-react-grid/package.json b/packages/dx-react-grid/package.json index a85a344f96..9c40fd5880 100644 --- a/packages/dx-react-grid/package.json +++ b/packages/dx-react-grid/package.json @@ -38,7 +38,7 @@ "typings": "dist/dx-react-grid.d.ts", "scripts": { "test": "jest", - "test:watch": "jest --watch", + "test:watch": "jest --watch --runInBand", "test:coverage": "jest --coverage", "build": "rollup -c rollup.config.js", "build:watch": "rollup -c rollup.config.js -w", diff --git a/packages/dx-react-grid/src/components/table-layout.test.tsx b/packages/dx-react-grid/src/components/table-layout.test.tsx index bd40dfa973..c0b3073227 100644 --- a/packages/dx-react-grid/src/components/table-layout.test.tsx +++ b/packages/dx-react-grid/src/components/table-layout.test.tsx @@ -95,6 +95,44 @@ describe('TableLayout', () => { minColumnWidth: 100, }; + it('should not be updated if columns were not changed', () => { + const tree = mount(( + + )); + + tree.setProps({ columns: columns.slice() }); + rafCallback(); + tree.setProps({ columns: columns.slice() }); + tree.update(); + rafCallback(); + + expect(getAnimations).not.toBeCalled(); + }); + + it('should be updated if an active animation is in progress', () => { + filterActiveAnimations.mockImplementation(() => new Map([['col', {}]])); + getAnimations.mockImplementation(() => new Map([['col', {}]])); + evalAnimations.mockImplementation(() => new Map()); + const nextColumns = [columns[1], columns[0]]; + const tree = mount(( + + )); + + tree.setProps({ columns: nextColumns }); + rafCallback(); + getAnimations.mockClear(); + + tree.setProps({ columns: nextColumns.slice() }); + tree.update(); + rafCallback(); + + expect(getAnimations).toBeCalled(); + }); + it('should be updated on the "columns" property change', () => { filterActiveAnimations.mockImplementation(() => new Map()); evalAnimations.mockImplementation(() => new Map()); @@ -141,7 +179,10 @@ describe('TableLayout', () => { }); describe('cache table width', () => { - const nextColumns = columns.slice(0, 2); + const nextColumnsWithSize = width => ([ + columns[0], + { ...columns[1], width }, + ]); const tableDimensions = { scrollWidth: 300, offsetWidth: 200 }; it('should not reset width if scroll width changed', () => { @@ -156,9 +197,9 @@ describe('TableLayout', () => { .mockReturnValue(tableDimensions); filterActiveAnimations.mockImplementation(() => new Map()); - tree.setProps({ columns: nextColumns }); + tree.setProps({ columns: nextColumnsWithSize(200) }); rafCallback(); - tree.setProps({ columns: nextColumns.slice() }); + tree.setProps({ columns: nextColumnsWithSize(100) }); tree.update(); rafCallback(); @@ -179,9 +220,9 @@ describe('TableLayout', () => { .mockReturnValue(tableDimensions); filterActiveAnimations.mockImplementation(() => new Map()); - tree.setProps({ columns: nextColumns }); + tree.setProps({ columns: nextColumnsWithSize(200) }); rafCallback(); - tree.setProps({ columns: nextColumns.slice() }); + tree.setProps({ columns: nextColumnsWithSize(100) }); rafCallback(); expect(getAnimations).toHaveBeenCalledTimes(2); @@ -201,7 +242,7 @@ describe('TableLayout', () => { .mockReturnValue(tableDimensions); filterActiveAnimations.mockImplementation(() => new Map()); - tree.setProps({ columns: nextColumns }); + tree.setProps({ columns: nextColumnsWithSize(200) }); rafCallback(); tree.setProps({ columns: columns.slice(1) }); rafCallback(); @@ -223,7 +264,7 @@ describe('TableLayout', () => { .mockReturnValue(tableDimensions); filterActiveAnimations.mockImplementation(() => new Map()); - tree.setProps({ columns: nextColumns }); + tree.setProps({ columns: nextColumnsWithSize(200) }); rafCallback(); tree.setProps({ columns: columns.slice(0, 1) }); rafCallback(); diff --git a/packages/dx-react-grid/src/components/table-layout.tsx b/packages/dx-react-grid/src/components/table-layout.tsx index 4c47105282..a97f62cb11 100644 --- a/packages/dx-react-grid/src/components/table-layout.tsx +++ b/packages/dx-react-grid/src/components/table-layout.tsx @@ -8,11 +8,12 @@ import { TABLE_FLEX_TYPE, ColumnAnimationMap, } from '@devexpress/dx-grid-core'; +import { shallowEqual } from '@devexpress/dx-core'; import { TableLayoutCoreProps, TableLayoutCoreState } from '../types'; class TableLayoutBase extends React.PureComponent { animations: ColumnAnimationMap; - savedScrolldWidth: { [key: number]: number }; + savedScrollWidth: { [key: number]: number }; savedOffsetWidth = -1; tableRef: React.RefObject; raf = -1; @@ -25,15 +26,30 @@ class TableLayoutBase extends React.PureComponent column.width === undefined).length === 0; if (isFixedWidth) { + // presumably a flex column added here instead of in a getter in the Table plugin + // to make sure that all manipulations on taleColumns have already done earlier result = [...result, { key: TABLE_FLEX_TYPE.toString(), type: TABLE_FLEX_TYPE }]; } diff --git a/packages/dx-react-grid/src/components/table-layout/__snapshots__/virtual-table-layout.test.tsx.snap b/packages/dx-react-grid/src/components/table-layout/__snapshots__/virtual-table-layout.test.tsx.snap index e4a4de1bec..fc557e3804 100644 --- a/packages/dx-react-grid/src/components/table-layout/__snapshots__/virtual-table-layout.test.tsx.snap +++ b/packages/dx-react-grid/src/components/table-layout/__snapshots__/virtual-table-layout.test.tsx.snap @@ -5,866 +5,756 @@ exports[`VirtualTableLayout should render correct layout 1`] = ` onScroll={[Function]} style={ Object { - "height": "100px", + "height": 100, } } > - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - + }, + ], + } + } + minColumnWidth={120} + name="footer" + rowComponent={[Function]} + rowRefsHandler={[Function]} + tableComponent={[Function]} + tableRef={ + Object { + "current": null, + } + } + /> `; diff --git a/packages/dx-react-grid/src/components/table-layout/static-table-layout.tsx b/packages/dx-react-grid/src/components/table-layout/static-table-layout.tsx index 4a39960e3e..b51309bf8f 100644 --- a/packages/dx-react-grid/src/components/table-layout/static-table-layout.tsx +++ b/packages/dx-react-grid/src/components/table-layout/static-table-layout.tsx @@ -32,6 +32,13 @@ export class StaticTableLayout extends React.PureComponent )} {!!footerRows.length && ( )}
diff --git a/packages/dx-react-grid/src/components/table-layout/virtual-row-layout.tsx b/packages/dx-react-grid/src/components/table-layout/virtual-row-layout.tsx index b8501c788b..d14d2c2c36 100644 --- a/packages/dx-react-grid/src/components/table-layout/virtual-row-layout.tsx +++ b/packages/dx-react-grid/src/components/table-layout/virtual-row-layout.tsx @@ -6,6 +6,7 @@ export class VirtualRowLayout extends React.Component { shouldComponentUpdate(nextProps) { const { cells: prevCells, row: prevRow } = this.props; const { cells: nextCells, row: nextRow } = nextProps; + if (prevRow !== nextRow || prevCells.length !== nextCells.length) { return true; } @@ -17,6 +18,7 @@ export class VirtualRowLayout extends React.Component { return propsAreNotEqual; } + render() { const { row, cells, rowComponent: Row, cellComponent: Cell } = this.props; return ( diff --git a/packages/dx-react-grid/src/components/table-layout/virtual-table-layout-block.tsx b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout-block.tsx new file mode 100644 index 0000000000..0c4f7a4747 --- /dev/null +++ b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout-block.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { RefHolder } from '@devexpress/dx-react-core'; +import { ColumnGroup } from './column-group'; +import { VirtualTableLayoutBlockProps } from '../../types'; +import { VirtualRowLayout } from './virtual-row-layout'; + +// tslint:disable-next-line: max-line-length +export class VirtualTableLayoutBlock extends React.PureComponent { + static defaultProps = { + blockRefsHandler: () => {}, + rowRefsHandler: () => {}, + tableRef: React.createRef(), + }; + + render() { + const { + name, + tableRef, + collapsedGrid, + minWidth, + blockRefsHandler, + rowRefsHandler, + tableComponent: Table, + bodyComponent: Body, + cellComponent, + rowComponent, + marginBottom, + } = this.props; + + return ( + blockRefsHandler(name, ref)} + > + + + + {collapsedGrid.rows.map((visibleRow) => { + const { row, cells = [] } = visibleRow; + + return ( + rowRefsHandler(row, ref)} + > + + + ); + })} + +
+
+ ); + } + +} diff --git a/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.test.tsx b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.test.tsx index bf3d052c4b..e36cdd4de8 100644 --- a/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.test.tsx +++ b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.test.tsx @@ -4,8 +4,7 @@ import { shallow, mount } from 'enzyme'; import { isEdgeBrowser } from '@devexpress/dx-core'; import { Sizer } from '@devexpress/dx-react-core'; import { - getCollapsedGrid, - getColumnWidthGetter, + getCollapsedGrids, TABLE_FLEX_TYPE, } from '@devexpress/dx-grid-core'; import { setupConsole } from '@devexpress/dx-testing'; @@ -22,7 +21,7 @@ jest.mock('@devexpress/dx-core', () => { }); jest.mock('@devexpress/dx-grid-core', () => { const actual = require.requireActual('@devexpress/dx-grid-core'); - jest.spyOn(actual, 'getCollapsedGrid'); + jest.spyOn(actual, 'getCollapsedGrids'); jest.spyOn(actual, 'getColumnWidthGetter'); return actual; }); @@ -81,10 +80,10 @@ const defaultProps = { { key: 8 }, { key: 9 }, ], + loadedRowsStart: 0, + totalRowCount: 9, containerComponent: props =>
, - // eslint-disable-next-line react/prop-types headTableComponent: ({ tableRef, ...props }) => , - // eslint-disable-next-line react/prop-types tableComponent: ({ tableRef, ...props }) =>
, headComponent: props => , bodyComponent: props => , @@ -150,11 +149,11 @@ describe('VirtualTableLayout', () => { ]; const rows = [{ key: 0 }]; - getCollapsedGrid + getCollapsedGrids .mockImplementationOnce((args) => { - const result = require.requireActual('@devexpress/dx-grid-core').getCollapsedGrid(args); + const result = require.requireActual('@devexpress/dx-grid-core').getCollapsedGrids(args); - expect(result.columns.find(col => col.key === 'col_flex').width) + expect(result.bodyGrid.columns.find(col => col.key === 'col_flex').width) .toBe(null); return result; @@ -174,34 +173,34 @@ describe('VirtualTableLayout', () => { const tree = mount(( )); expect(tree.find(defaultProps.containerComponent).props().style) - .toMatchObject({ height: `${defaultProps.height}px` }); + .toMatchObject({ height: defaultProps.height }); - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 3][0]) - .toMatchObject({ - top: 0, - left: 0, - height: defaultProps.estimatedRowHeight, - width: 400, - }); - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 2][0]) - .toMatchObject({ - top: 0, - left: 0, - height: 120 - (defaultProps.estimatedRowHeight * 2), - width: 400, - }); - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 1][0]) + expect(getCollapsedGrids).toBeCalledTimes(2); + expect(getCollapsedGrids.mock.calls[getCollapsedGrids.mock.calls.length - 1][0]) .toMatchObject({ - top: 0, - left: 0, - height: defaultProps.estimatedRowHeight, - width: 400, + viewportLeft: 0, + containerWidth: 400, + visibleRowBoundaries: { + header: { + start: 0, + end: 0, + }, + body: { + start: 0, + end: 0, + }, + footer: { + start: 0, + end: 0, + }, + viewportTop: 0, + }, }); }); @@ -227,22 +226,25 @@ describe('VirtualTableLayout', () => { )); simulateScroll(tree, { scrollTop: 100, scrollLeft: 50 }); - const calls = getCollapsedGrid.mock.calls; - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 3][0]) - .toMatchObject({ - top: 0, - left: 50, - }); - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 2][0]) - .toMatchObject({ - top: 100, - left: 50, - }); - expect(getCollapsedGrid.mock.calls[getCollapsedGrid.mock.calls.length - 1][0]) + expect(getCollapsedGrids.mock.calls[getCollapsedGrids.mock.calls.length - 1][0]) .toMatchObject({ - top: 0, - left: 50, + viewportLeft: 50, + visibleRowBoundaries: { + header: { + start: 0, + end: 0, + }, + body: { + start: 2, + end: 4, + }, + footer: { + start: 0, + end: 0, + }, + viewportTop: 100, + }, }); }); @@ -254,14 +256,14 @@ describe('VirtualTableLayout', () => { headerRows={defaultProps.bodyRows.slice(0, 1)} /> )); - const initialCallsCount = getCollapsedGrid.mock.calls.length; + const initialCallsCount = getCollapsedGrids.mock.calls.length; simulateScroll(tree, scrollArgs); if (shouldRerender) { - expect(getCollapsedGrid.mock.calls.length).toBeGreaterThan(initialCallsCount); + expect(getCollapsedGrids.mock.calls.length).toBeGreaterThan(initialCallsCount); } else { - expect(getCollapsedGrid.mock.calls.length).toBe(initialCallsCount); + expect(getCollapsedGrids.mock.calls.length).toBe(initialCallsCount); } }; @@ -316,7 +318,7 @@ describe('VirtualTableLayout', () => { { key: 2, height: 10 }, ]; - getCollapsedGrid + getCollapsedGrids .mockImplementationOnce((args) => { const { getRowHeight } = args; expect(getRowHeight(rows[0])) @@ -324,7 +326,7 @@ describe('VirtualTableLayout', () => { expect(getRowHeight(rows[1])) .toEqual(10); - return require.requireActual('@devexpress/dx-grid-core').getCollapsedGrid(args); + return require.requireActual('@devexpress/dx-grid-core').getCollapsedGrids(args); }); mount(( @@ -354,7 +356,7 @@ describe('VirtualTableLayout', () => { /> )); - const { getRowHeight } = getCollapsedGrid.mock.calls[0][0]; + const { getRowHeight } = getCollapsedGrids.mock.calls[0][0]; expect(getRowHeight(rows[0])) .toEqual(50); expect(getRowHeight(rows[1])) @@ -381,78 +383,9 @@ describe('VirtualTableLayout', () => { )); tree.setProps({ bodyRows: [rows[0]] }); - const { getRowHeight } = getCollapsedGrid.mock.calls[0][0]; + const { getRowHeight } = getCollapsedGrids.mock.calls[0][0]; expect(getRowHeight(rows[1])) .toEqual(defaultProps.estimatedRowHeight); }); - - it('should clear row height when headerRows updated', () => { - const rows = [ - { key: 11 }, - { key: 12 }, - ]; - - findDOMNode.mockImplementation(() => ({ - getBoundingClientRect: () => ({ - height: 50, - }), - })); - - const tree = mount(( - - )); - tree.setProps({ headerRows: [rows[0]] }); - - const { getRowHeight } = getCollapsedGrid.mock.calls[0][0]; - expect(getRowHeight(rows[1])) - .toEqual(defaultProps.estimatedRowHeight); - }); - - it('should clear row height when footerRows updated', () => { - const rows = [ - { key: 11 }, - { key: 12 }, - ]; - - findDOMNode.mockImplementation(() => ({ - getBoundingClientRect: () => ({ - height: 50, - }), - })); - - const tree = mount(( - - )); - tree.setProps({ footerRows: [rows[0]] }); - - const calls = getCollapsedGrid.mock.calls; - const { getRowHeight } = getCollapsedGrid.mock.calls[0][0]; - expect(getRowHeight(rows[1])) - .toEqual(defaultProps.estimatedRowHeight); - }); - }); - - it('should use getColumnWidthGetter', () => { - const getColumnWidth = () => 0; - getColumnWidthGetter.mockImplementationOnce(() => getColumnWidth); - - mount(( - - )); - - expect(getCollapsedGrid.mock.calls[0][0]) - .toMatchObject({ - getColumnWidth, - }); }); }); diff --git a/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.tsx b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.tsx index 04fc3dd57f..6252142a7e 100644 --- a/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.tsx +++ b/packages/dx-react-grid/src/components/table-layout/virtual-table-layout.tsx @@ -1,17 +1,13 @@ import * as React from 'react'; -import { findDOMNode } from 'react-dom'; -import { isEdgeBrowser, memoize, MemoizedFunction } from '@devexpress/dx-core'; -import { Sizer, RefHolder } from '@devexpress/dx-react-core'; +import { Sizer } from '@devexpress/dx-react-core'; +import { MemoizedFunction, memoize, isEdgeBrowser } from '@devexpress/dx-core'; import { - getCollapsedGrid, - TABLE_STUB_TYPE, - getColumnWidthGetter, - GetColumnWidthFn, - TableColumn, + TableColumn, GetColumnWidthFn, getCollapsedGrids, + getColumnWidthGetter, TABLE_STUB_TYPE, getVisibleRowsBounds, GridRowsBoundaries, } from '@devexpress/dx-grid-core'; -import { ColumnGroup } from './column-group'; -import { VirtualRowLayout } from './virtual-row-layout'; -import { VirtualTableLayoutProps, VirtualTableLayoutState } from '../../types'; +import { VirtualTableLayoutState, VirtualTableLayoutProps } from '../../types'; +import { findDOMNode } from 'react-dom'; +import { VirtualTableLayoutBlock } from './virtual-table-layout-block'; const AUTO_HEIGHT = 'auto'; @@ -22,6 +18,7 @@ const defaultProps = { headTableComponent: () => null, footerComponent: () => null, footerTableComponent: () => null, + ensureNextVirtualPage: () => void 0, }; type PropsType = VirtualTableLayoutProps & typeof defaultProps; @@ -29,23 +26,24 @@ type PropsType = VirtualTableLayoutProps & typeof defaultProps; // tslint:disable-next-line: max-line-length export class VirtualTableLayout extends React.PureComponent { static defaultProps = defaultProps; - isEdgeBrowser = false; - rowRefs: Map; - blockRefs: Map; getColumnWidthGetter: MemoizedFunction<[TableColumn[], number, number], GetColumnWidthFn>; + rowRefs = new Map(); + blockRefs = new Map(); + isEdgeBrowser = false; constructor(props) { super(props); this.state = { - bodyHeight: 0, - headerHeight: 0, - footerHeight: 0, rowHeights: new Map(), viewportTop: 0, viewportLeft: 0, - width: 800, - height: 600, + containerWidth: 800, + containerHeight: 600, + height: 0, + headerHeight: 0, + bodyHeight: 0, + footerHeight: 0, }; const headerHeight = props.headerRows @@ -59,13 +57,6 @@ export class VirtualTableLayout extends React.PureComponent ( getColumnWidthGetter(tableColumns, tableWidth, minColumnWidth) @@ -77,12 +68,12 @@ export class VirtualTableLayout extends React.PureComponent { const { rowHeights } = this.state; const { estimatedRowHeight } = this.props; - const storedHeight = rowHeights.get(row.key); - if (storedHeight !== undefined) return storedHeight; - if (row.height) return row.height; + if (row) { + const storedHeight = rowHeights.get(row.key); + if (storedHeight !== undefined) return storedHeight; + if (row.height) return row.height; + } return estimatedRowHeight; } + registerRowRef = (row, ref) => { + if (ref === null) { + this.rowRefs.delete(row); + } else { + this.rowRefs.set(row, ref); + } + } + + registerBlockRef = (name, ref) => { + if (ref === null) { + this.blockRefs.delete(name); + } else { + this.blockRefs.set(name, ref); + } + } + storeRowHeights() { const rowsWithChangedHeights = Array.from(this.rowRefs.entries()) - // eslint-disable-next-line react/no-find-dom-node .map(([row, ref]) => [row, findDOMNode(ref)]) .filter(([, node]) => !!node) .map(([row, node]) => [row, node.getBoundingClientRect().height]) @@ -130,7 +138,7 @@ export class VirtualTableLayout extends React.PureComponent (this.blockRefs.get(blockName) ? (findDOMNode(this.blockRefs.get(blockName)) as HTMLElement).getBoundingClientRect().height : 0 @@ -156,20 +164,31 @@ export class VirtualTableLayout extends React.PureComponent { + const node = e.target; + + if (this.shouldSkipScrollEvent(e)) { + return; } + + const { estimatedRowHeight } = this.props; + const { containerHeight } = this.state; + const { scrollTop: viewportTop, scrollLeft: viewportLeft } = node; + ensureNextVirtualPage({ + estimatedRowHeight, + visibleRowBoundaries, + viewportTop, + containerHeight, + }); + + this.setState({ + viewportTop, + viewportLeft, + }); } - registerBlockRef(name, ref) { - if (ref === null) { - this.blockRefs.delete(name); - } else { - this.blockRefs.set(name, ref); - } + handleContainerSizeChange = ({ width, height }) => { + this.setState({ containerWidth: width, containerHeight: height }); } shouldSkipScrollEvent(e) { @@ -193,77 +212,48 @@ export class VirtualTableLayout extends React.PureComponent this.registerBlockRef(name, ref)} - > -
- - - {collapsedGrid.rows.map((visibleRow) => { - const { row, cells = [] } = visibleRow; - return ( - this.registerRowRef(row, ref)} - > - - - ); - })} - -
- + return getVisibleRowsBounds( + this.state, { loadedRowsStart, bodyRows, headerRows, footerRows }, + estimatedRowHeight, this.getRowHeight, ); } - render() { + getCollapsedGrids(visibleRowBoundaries: GridRowsBoundaries) { + const { viewportLeft, containerWidth } = this.state; const { + headerRows, bodyRows, footerRows, + columns, loadedRowsStart, totalRowCount, + getCellColSpan, minColumnWidth, + } = this.props; + const getColumnWidth = this.getColumnWidthGetter(columns, containerWidth, minColumnWidth!); + + return getCollapsedGrids({ headerRows, bodyRows, footerRows, columns, - minColumnWidth, - height: propHeight, + loadedRowsStart, + totalRowCount, + getCellColSpan, + viewportLeft, + containerWidth, + visibleRowBoundaries, + getColumnWidth, + getRowHeight: this.getRowHeight, + }); + } + + render() { + const { containerComponent: Container, headTableComponent: HeadTable, footerTableComponent: FootTable, @@ -271,76 +261,75 @@ export class VirtualTableLayout extends React.PureComponent getCellColSpan!({ tableRow, tableColumn, tableColumns: columns }); - - /* tslint:disable object-shorthand-properties-first */ - const collapsedHeaderGrid = getCollapsedGrid({ - rows: headerRows, - columns, - top: 0, - left: viewportLeft, - width, - height: headerHeight, - getColumnWidth, - getRowHeight: this.getRowHeight, - getColSpan, - }); - const collapsedBodyGrid = getCollapsedGrid({ - rows: bodyRows, - columns, - top: viewportTop, - left: viewportLeft, - width, - height: height - headerHeight - footerHeight, - getColumnWidth, - getRowHeight: this.getRowHeight, - getColSpan, - }); - const collapsedFooterGrid = getCollapsedGrid({ - rows: footerRows, - columns, - top: 0, - left: viewportLeft, - width, - height: footerHeight, - getColumnWidth, - getRowHeight: this.getRowHeight, - getColSpan, - }); - /* tslint:enable object-shorthand-properties-first */ + const visibleRowBoundaries = this.getVisibleBoundaries(); + const collapsedGrids = this.getCollapsedGrids(visibleRowBoundaries); + const commonProps = { + cellComponent, + rowComponent, + minColumnWidth, + blockRefsHandler: this.registerBlockRef, + rowRefsHandler: this.registerRowRef, + }; - /* tslint:disable max-line-length */ return ( this.updateViewport(e, visibleRowBoundaries, ensureNextVirtualPage) + } > - {!!headerRows.length && this.renderRowsBlock('header', collapsedHeaderGrid, HeadTable, Head)} - {this.renderRowsBlock('body', collapsedBodyGrid, Table, Body, tableRef, Math.max(0, height - headerHeight - bodyHeight - footerHeight))} - {!!footerRows.length && this.renderRowsBlock('footer', collapsedFooterGrid, FootTable, Footer)} + { + (!!headerRows.length) && ( + + ) + } + + { + (!!footerRows.length) && ( + + ) + } ); - /* tslint:enable max-line-length */ } } diff --git a/packages/dx-react-grid/src/index.ts b/packages/dx-react-grid/src/index.ts index 8890af5432..463b66989e 100644 --- a/packages/dx-react-grid/src/index.ts +++ b/packages/dx-react-grid/src/index.ts @@ -42,12 +42,15 @@ export * from './plugins/summary-state'; export * from './plugins/integrated-summary'; export * from './plugins/custom-summary'; export * from './plugins/table-summary-row'; +export * from './plugins/virtual-table/virtual-table-state'; export * from './components/table-layout'; export * from './components/table-layout/virtual-table-layout'; export * from './components/table-layout/static-table-layout'; export * from './components/group-panel-layout'; -export * from './utils/virtual-table'; +export * from './plugins/virtual-table/virtual-table'; export * from './types/tables/virtual-table.types'; + +export * from './utils/remote-rows-cache'; diff --git a/packages/dx-react-grid/src/plugins/grid-core.tsx b/packages/dx-react-grid/src/plugins/grid-core.tsx index f69b8db6e3..caaa3f0cd8 100644 --- a/packages/dx-react-grid/src/plugins/grid-core.tsx +++ b/packages/dx-react-grid/src/plugins/grid-core.tsx @@ -17,6 +17,8 @@ export class GridCore extends React.PureComponent { return ( + + diff --git a/packages/dx-react-grid/src/plugins/table-header-row.tsx b/packages/dx-react-grid/src/plugins/table-header-row.tsx index 8f9dc9e28b..46581812a6 100644 --- a/packages/dx-react-grid/src/plugins/table-header-row.tsx +++ b/packages/dx-react-grid/src/plugins/table-header-row.tsx @@ -15,7 +15,7 @@ import { TableHeaderRowProps, TableCellProps, TableRowProps } from '../types'; const tableHeaderRowsComputed = ( { tableHeaderRows }: Getters, -) => tableRowsWithHeading(tableHeaderRows); +) => tableRowsWithHeading(tableHeaderRows || []); class TableHeaderRowBase extends React.PureComponent { static ROW_TYPE = TABLE_HEADING_TYPE; diff --git a/packages/dx-react-grid/src/plugins/table.test.tsx b/packages/dx-react-grid/src/plugins/table.test.tsx index 677dd16043..f30a769ac0 100644 --- a/packages/dx-react-grid/src/plugins/table.test.tsx +++ b/packages/dx-react-grid/src/plugins/table.test.tsx @@ -31,6 +31,7 @@ const defaultDeps = { rows: [{ field: 1 }], getRowId: () => {}, getCellValue: () => {}, + isDataLoading: false, }, template: { body: undefined, @@ -88,7 +89,11 @@ describe('Table', () => { )); expect(tableRowsWithDataRows) - .toBeCalledWith(defaultDeps.getter.rows, defaultDeps.getter.getRowId); + .toBeCalledWith( + defaultDeps.getter.rows, + defaultDeps.getter.getRowId, + defaultDeps.getter.isDataLoading, + ); expect(getComputedState(tree).tableBodyRows) .toBe('tableRowsWithDataRows'); }); diff --git a/packages/dx-react-grid/src/plugins/table.tsx b/packages/dx-react-grid/src/plugins/table.tsx index 98dc60dd4e..851136a168 100644 --- a/packages/dx-react-grid/src/plugins/table.tsx +++ b/packages/dx-react-grid/src/plugins/table.tsx @@ -21,14 +21,14 @@ import { TABLE_NODATA_TYPE, GridColumnExtension, } from '@devexpress/dx-grid-core'; -import { TableProps, Table as TableNS } from '../types'; +import { TableProps, Table as TableNS, TableLayoutProps } from '../types'; const RowPlaceholder = props => ; const CellPlaceholder = props => ; const tableHeaderRows = []; -const tableBodyRowsComputed = ({ rows, getRowId }: Getters) => ( - tableRowsWithDataRows(rows, getRowId) +const tableBodyRowsComputed = ({ rows, getRowId, isDataLoading }: Getters) => ( + tableRowsWithDataRows(rows, getRowId, isDataLoading) ); const tableFooterRows = []; @@ -102,6 +102,7 @@ class TableBase extends React.PureComponent { + +