Skip to content

Commit

Permalink
feat(core): add rowHighlightCssClass & highlightRow() to SlickGrid (
Browse files Browse the repository at this point in the history
#1272)

- remove previous `highlightRowByMetadata()` method in GridService, we can avoid using ItemMetaData altogether by adding a simple `highlightRow()` method directly in the SlickGrid core lib and also add `rowHighlightCssClass` grid option which will be animated by default but user could also disable the animation and/or even disable the fade out (remain highlighted)
- also fix potential mem leak found on some `setTimeout` not having any `clearTimeout` assigned which could cause potential mem leaks
  • Loading branch information
ghiscoding authored Dec 20, 2023
1 parent 14791e7 commit 31c38ad
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 249 deletions.
37 changes: 37 additions & 0 deletions packages/common/src/core/__tests__/slickGrid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Column, FormatterResultWithHtml, FormatterResultWithText, GridOption }
import { SlickDataView } from '../slickDataview';
import { SlickGrid } from '../slickGrid';

jest.useFakeTimers();

describe('SlickGrid core file', () => {
let container: HTMLElement;
let grid: SlickGrid;
Expand Down Expand Up @@ -190,4 +192,39 @@ describe('SlickGrid core file', () => {
expect(cellNodeElm.outerHTML).toBe('<div class="some-class" title="some tooltip">some content</div>');
});
});

describe('highlightRow() method', () => {
const columns = [
{ id: 'firstName', field: 'firstName', name: 'First Name' },
{ id: 'lastName', field: 'lastName', name: 'Last Name' },
{ id: 'age', field: 'age', name: 'Age' },
] as Column[];
const options = { enableCellNavigation: true, devMode: { ownerNodeIndex: 0 } } as GridOption;
const dv = new SlickDataView({});

it('should call the method and expect the highlight to happen for a certain duration', () => {
const mockItems = [{ id: 0, firstName: 'John', lastName: 'Doe', age: 30 }, { id: 0, firstName: 'Jane', lastName: 'Doe', age: 28 }];

grid = new SlickGrid<any, Column>(container, dv, columns, options);
dv.addItems(mockItems);
grid.init();
grid.render();

grid.highlightRow(0, 10);
expect(grid).toBeTruthy();
expect(grid.getDataLength()).toBe(2);

let slickRowElms = container.querySelectorAll<HTMLDivElement>('.slick-row');
expect(slickRowElms.length).toBe(2);
expect(slickRowElms[0].classList.contains('highlight-animate')).toBeTruthy(); // only 1st row is highlighted
expect(slickRowElms[1].classList.contains('highlight-animate')).toBeFalsy();

jest.runAllTimers(); // fast-forward timer

slickRowElms = container.querySelectorAll<HTMLDivElement>('.slick-row');
expect(slickRowElms.length).toBe(2);
expect(slickRowElms[0].classList.contains('highlight-animate')).toBeFalsy();
expect(slickRowElms[1].classList.contains('highlight-animate')).toBeFalsy();
});
});
});
53 changes: 45 additions & 8 deletions packages/common/src/core/slickGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
formatterFactory: null,
editorFactory: null,
cellFlashingCssClass: 'flashing',
rowHighlightCssClass: 'highlight-animate',
selectedCellCssClass: 'selected',
multiSelect: true,
enableTextSelectionOnCells: false,
Expand Down Expand Up @@ -266,6 +267,11 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
hidden: false
} as Partial<C>;

protected _columnResizeTimer?: NodeJS.Timeout;
protected _executionBlockTimer?: NodeJS.Timeout;
protected _flashCellTimer?: NodeJS.Timeout;
protected _highlightRowTimer?: NodeJS.Timeout;

// scroller
protected th!: number; // virtual height
protected h!: number; // real scrollable height
Expand Down Expand Up @@ -374,7 +380,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
// async call handles
protected h_editorLoader: any = null;
protected h_render = null;
protected h_postrender: any = null;
protected h_postrender?: NodeJS.Timeout;
protected h_postrenderCleanup: any = null;
protected postProcessedRows: any = {};
protected postProcessToRow: number = null as any;
Expand Down Expand Up @@ -2214,7 +2220,8 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
this.updateCanvasWidth(true);
this.render();
this.trigger(this.onColumnsResized, { triggeredByColumn });
setTimeout(() => { this.columnResizeDragging = false; }, 300);
clearTimeout(this._columnResizeTimer);
this._columnResizeTimer = setTimeout(() => { this.columnResizeDragging = false; }, 300);
}
})
);
Expand Down Expand Up @@ -2462,6 +2469,15 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
this.stylesheet = null;
}

/** Clear all highlight timers that might have been left opened */
protected clearAllTimers() {
clearTimeout(this._columnResizeTimer);
clearTimeout(this._executionBlockTimer);
clearTimeout(this._flashCellTimer);
clearTimeout(this._highlightRowTimer);
clearTimeout(this.h_editorLoader);
}

/**
* Destroy (dispose) of SlickGrid
* @param {boolean} shouldDestroyAllElements - do we want to destroy (nullify) all DOM elements as well? This help in avoiding mem leaks
Expand Down Expand Up @@ -2546,6 +2562,7 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e

emptyElement(this._container);
this._container.classList.remove(this.uid);
this.clearAllTimers();

if (shouldDestroyAllElements) {
this.destroyAllElements();
Expand Down Expand Up @@ -4524,7 +4541,8 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e

const blockAndExecute = () => {
blocked = true;
setTimeout(unblock, minPeriod_ms);
clearTimeout(this._executionBlockTimer);
this._executionBlockTimer = setTimeout(unblock, minPeriod_ms);
action.call(this);
};

Expand Down Expand Up @@ -4703,17 +4721,16 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
* Flashes the cell twice by toggling the CSS class 4 times.
* @param {Number} row A row index.
* @param {Number} cell A column index.
* @param {Number} [speed] (optional) - The milliseconds delay between the toggling calls. Defaults to 100 ms.
* @param {Number} [speed] (optional) - The milliseconds delay between the toggling calls. Defaults to 250 ms.
*/
flashCell(row: number, cell: number, speed?: number) {
speed = speed || 250;

flashCell(row: number, cell: number, speed = 250) {
const toggleCellClass = (cellNode: HTMLElement, times: number) => {
if (times < 1) {
return;
}

setTimeout(() => {
clearTimeout(this._flashCellTimer);
this._flashCellTimer = setTimeout(() => {
if (times % 2 === 0) {
cellNode.classList.add(this._options.cellFlashingCssClass || '');
} else {
Expand All @@ -4731,6 +4748,26 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
}
}

/**
* Highlight a row for a certain duration (ms) of time.
* If the duration is set to null/undefined, then the row will remain highlighted indifinitely.
* @param {Number} row - grid row number
* @param {Number} duration - duration (ms)
*/
highlightRow(row: number, duration?: number) {
const rowCache = this.rowsCache[row];

if (Array.isArray(rowCache?.rowNode) && this._options.rowHighlightCssClass) {
rowCache.rowNode.forEach(node => node.classList.add(this._options.rowHighlightCssClass || ''));
if (typeof duration === 'number') {
clearTimeout(this._highlightRowTimer);
this._highlightRowTimer = setTimeout(() => {
rowCache.rowNode?.forEach(node => node.classList.remove(this._options.rowHighlightCssClass || ''));
}, duration);
}
}
}

// Interactivity

protected handleMouseWheel(e: MouseEvent, _delta: number, deltaX: number, deltaY: number) {
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/interfaces/gridOption.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,13 @@ export interface GridOption<C extends Column = Column> {
/** Grid row height in pixels (only type the number). Row of cell values. */
rowHeight?: number;

/**
* Defaults to "highlight-animate", a CSS class name used to simulate row highlight with an optional duration (e.g. after insert).
* The default class is "highlight-animate" but you could also use "highlight" if you don't plan on using duration neither animation.
* Note: when having a duration, make sure that it's always lower than the duration defined in the CSS/SASS variable `$slick-row-highlight-fade-animation`
*/
rowHighlightCssClass?: string;

/** Row Move Manager Plugin options & events */
rowMoveManager?: RowMoveManager;

Expand Down
130 changes: 7 additions & 123 deletions packages/common/src/services/__tests__/grid.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { GridOption, CellArgs, Column, OnEventArgs } from '../../interfaces/inde
import { SlickRowSelectionModel } from '../../extensions/slickRowSelectionModel';
import { type SlickDataView, SlickEvent, type SlickGrid } from '../../core/index';

jest.useFakeTimers();
jest.mock('flatpickr', () => { });

const mockRowSelectionModel = {
Expand Down Expand Up @@ -79,6 +78,7 @@ const gridStub = {
getSelectionModel: jest.fn(),
setSelectionModel: jest.fn(),
getSelectedRows: jest.fn(),
highlightRow: jest.fn(),
navigateBottom: jest.fn(),
navigateTop: jest.fn(),
render: jest.fn(),
Expand Down Expand Up @@ -126,12 +126,17 @@ describe('Grid Service', () => {
it('should dispose of the service', () => {
const disposeSpy = jest.spyOn(mockRowSelectionModel, 'dispose');

service.highlightRow(0, 10, 15);
service.highlightRow(0, 10);
service.dispose();

expect(disposeSpy).toHaveBeenCalled();
});

it('should be able to highlight first row at zero index', () => {
service.highlightRow(0, 10);
expect(gridStub.highlightRow).toHaveBeenCalled();
});

describe('getAllColumnDefinitions method', () => {
it('should call "allColumns" GETTER ', () => {
const mockColumns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }];
Expand Down Expand Up @@ -1406,105 +1411,6 @@ describe('Grid Service', () => {
});
});

describe('getItemRowMetadataToHighlight method', () => {
const options = { groupItemMetadataProvider: { getGroupRowMetadata: jest.fn(), getTotalsRowMetadata: jest.fn() } };
const columnDefinitions = [
{ id: 'field1', width: 100, __group: {}, __groupTotals: {} },
{ id: 'field2', width: 150, rowClass: 'red' },
{ id: 'field3', field: 'field3', cssClasses: 'highlight', _dirty: true }
];

// this mock is a typical callback function returned by SlickGrid internally, without anything changed to it's logic
const mockItemMetadataFn = (i: number) => {
const columnDef = columnDefinitions[i];
if (columnDef === undefined) {
return null;
}
if (columnDef.__group) { // overrides for grouping rows
return options.groupItemMetadataProvider.getGroupRowMetadata(columnDef);
}
if (columnDef.__groupTotals) { // overrides for totals rows
return options.groupItemMetadataProvider.getTotalsRowMetadata(columnDef);
}
return null;
};

it('should return a callback function when method is called', () => {
const callback = service.getItemRowMetadataToHighlight(mockItemMetadataFn);
expect(typeof callback === 'function').toBe(true);
});

it('should return an Item Metadata object with empty "cssClasses" property after executing the callback function', () => {
const rowNumber = 0;
const dataviewSpy = jest.spyOn(dataviewStub, 'getItem').mockReturnValue(columnDefinitions[rowNumber]);

const callback = service.getItemRowMetadataToHighlight(mockItemMetadataFn);
const output = callback(rowNumber); // execute callback with a row number

expect(dataviewSpy).toHaveBeenCalled();
expect(typeof callback === 'function').toBe(true);
expect(output).toEqual({ cssClasses: '' });
});

it('should return an Item Metadata object with a "dirty" string in the "cssClasses" property after executing the callback function', () => {
const rowNumber = 2;
const dataviewSpy = jest.spyOn(dataviewStub, 'getItem').mockReturnValue(columnDefinitions[rowNumber]);

const callback = service.getItemRowMetadataToHighlight(mockItemMetadataFn);
const output = callback(rowNumber); // execute callback with a row number

expect(dataviewSpy).toHaveBeenCalled();
expect(typeof callback === 'function').toBe(true);
expect(output).toEqual({ cssClasses: ' dirty' });
});

it('should return an Item Metadata object with filled "cssClasses" property when callback provided already returns a "cssClasses" property', () => {
const rowNumber = 2;
const dataviewSpy = jest.spyOn(dataviewStub, 'getItem').mockReturnValue(columnDefinitions[rowNumber]);

const callback = service.getItemRowMetadataToHighlight(() => {
return { cssClasses: 'highlight' };
});
const output = callback(rowNumber); // execute callback with a row number

expect(dataviewSpy).toHaveBeenCalled();
expect(typeof callback === 'function').toBe(true);
expect(output).toEqual({ cssClasses: 'highlight dirty' });
});

it(`should return an Item Metadata object with filled "cssClasses" property including a row number in the string
when the column definition has a "rowClass" property and when callback provided already returns a "cssClasses" property`, () => {
const rowNumber = 1;
const dataviewSpy = jest.spyOn(dataviewStub, 'getItem').mockReturnValue(columnDefinitions[rowNumber]);

const callback = service.getItemRowMetadataToHighlight(mockItemMetadataFn);
const output = callback(rowNumber); // execute callback with a row number

expect(dataviewSpy).toHaveBeenCalled();
expect(typeof callback === 'function').toBe(true);
expect(output).toEqual({ cssClasses: ' red row1' });
});
});

describe('highlightRowByMetadata method', () => {
it('should hightlight a row with a fading start & end delay', () => {
const mockColumn = { id: 'field2', field: 'field2', width: 150, rowClass: 'red' } as Column;
const getItemSpy = jest.spyOn(dataviewStub, 'getItem').mockReturnValue(mockColumn);
const getIndexSpy = jest.spyOn(dataviewStub, 'getIdxById').mockReturnValue(0);
const updateSpy = jest.spyOn(dataviewStub, 'updateItem');
const renderSpy = jest.spyOn(service, 'renderGrid');

service.highlightRowByMetadata(2, 1, 1);
jest.runAllTimers(); // fast-forward timer

expect(getItemSpy).toHaveBeenCalledWith(2);
expect(updateSpy).toHaveBeenCalledTimes(3);
expect(updateSpy).toHaveBeenCalledWith(mockColumn.id, mockColumn);
expect(renderSpy).toHaveBeenCalled();
expect(getIndexSpy).toHaveBeenCalled();
});
});

describe('getDataItemByRowIndex method', () => {
afterEach(() => {
gridStub.getDataItem = jest.fn(); // put it back as a valid mock for later tests
Expand Down Expand Up @@ -1820,26 +1726,4 @@ describe('Grid Service', () => {
expect(sortSpy).toHaveBeenCalled();
});
});

describe('highlightRow method', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should be able to highlight first row at zero index', () => {
const mockRowMetadata = (rowNumber) => ({ cssClasses: `row-${rowNumber}` });
const mockItem = { id: 0, firstName: 'John', lastName: 'Doe' };
jest.spyOn(service, 'getItemRowMetadataToHighlight').mockReturnValue(mockRowMetadata);
jest.spyOn(dataviewStub, 'getItem').mockReturnValue(mockItem);
jest.spyOn(dataviewStub, 'getIdxById').mockReturnValue(0);
const updateSpy = jest.spyOn(dataviewStub, 'updateItem');
const renderSpy = jest.spyOn(service, 'renderGrid');

service.highlightRow(0, 10, 15);
jest.runAllTimers(); // fast-forward timer

expect(updateSpy).toHaveBeenCalledWith(0, mockItem);
expect(renderSpy).toHaveBeenCalledTimes(3);
});
});
});
Loading

0 comments on commit 31c38ad

Please sign in to comment.