Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add rowHighlightCssClass & highlightRow() to SlickGrid #1272

Merged
merged 1 commit into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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