diff --git a/examples/webpack-demo-vanilla-bundle/assets/i18n/en.json b/examples/webpack-demo-vanilla-bundle/assets/i18n/en.json index 746dda353..4dc685044 100644 --- a/examples/webpack-demo-vanilla-bundle/assets/i18n/en.json +++ b/examples/webpack-demo-vanilla-bundle/assets/i18n/en.json @@ -13,6 +13,7 @@ "COMMANDS": "Commands", "CONTAINS": "Contains", "COPY": "Copy", + "EMPTY_DATA_WARNING_MESSAGE": "No data to display.", "ENDS_WITH": "Ends With", "EQUALS": "Equals", "EQUAL_TO": "Equal to", diff --git a/examples/webpack-demo-vanilla-bundle/assets/i18n/fr.json b/examples/webpack-demo-vanilla-bundle/assets/i18n/fr.json index e7553c0dd..38ba9676f 100644 --- a/examples/webpack-demo-vanilla-bundle/assets/i18n/fr.json +++ b/examples/webpack-demo-vanilla-bundle/assets/i18n/fr.json @@ -13,6 +13,7 @@ "COMMANDS": "Commandes", "CONTAINS": "Contient", "COPY": "Copier", + "EMPTY_DATA_WARNING_MESSAGE": "Aucune donnée à afficher.", "ENDS_WITH": "Se termine par", "EQUALS": "Égale", "EQUAL_TO": "Égal à", diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example10.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example10.ts index 9a54d4660..9cad6d3f0 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example10.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example10.ts @@ -208,7 +208,7 @@ export class Example10 { * @param query * @return Promise */ - getCustomerApiCall(query: string): Promise { + getCustomerApiCall(_query: string): Promise { // in your case, you will call your WebAPI function (wich needs to return a Promise) // for the demo purpose, we will call a mock WebAPI function const mockedResult = { diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example11.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example11.ts index a3369a516..24a257fa2 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example11.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example11.ts @@ -572,7 +572,7 @@ export class Example11 { pushNewFilterToSelectPreFilter(predefinedFilters: FilterPreset | FilterPreset[], isOptionSelected = false) { if (isOptionSelected) { - this.resetPredefinedFilterSelection(this.predefinedPresets); + this.predefinedPresets.forEach(preFilter => preFilter.isSelected = false); // reset selection } const presetFilters: FilterPreset[] = Array.isArray(predefinedFilters) ? predefinedFilters : [predefinedFilters]; const filterSelect = document.querySelector('.selected-filter'); @@ -611,6 +611,7 @@ export class Example11 { event.stopPropagation(); return; } + this.predefinedPresets.forEach(preFilter => preFilter.isSelected = false); // reset selection const currentFilters = this.sgb.filterService.getCurrentLocalFilters(); const filterName = await prompt('Please provide a name for the new Filter.'); @@ -665,7 +666,7 @@ export class Example11 { } usePredefinedFilter(filterValue: string) { - this.resetPredefinedFilterSelection(this.predefinedPresets); + this.predefinedPresets.forEach(preFilter => preFilter.isSelected = false); // reset selection const selectedFilter = this.predefinedPresets.find(preset => preset.value === filterValue); if (selectedFilter) { selectedFilter.isSelected = true; @@ -681,10 +682,6 @@ export class Example11 { this.currentSelectedFilterPreset = selectedFilter; } - resetPredefinedFilterSelection(predefinedFilters: FilterPreset[]) { - predefinedFilters.forEach(preFilter => preFilter.isSelected = false); - } - mockProducts() { return [ { diff --git a/packages/common/src/editors/dateEditor.ts b/packages/common/src/editors/dateEditor.ts index 0d2f80c9c..349258245 100644 --- a/packages/common/src/editors/dateEditor.ts +++ b/packages/common/src/editors/dateEditor.ts @@ -168,7 +168,6 @@ export class DateEditor implements Editor { if (this.flatInstance.element) { setTimeout(() => destroyObjectDomElementProps(this.flatInstance)); } - this.flatInstance = null; } if (this._$editorInputElm?.remove) { this._$editorInputElm.remove(); @@ -178,6 +177,7 @@ export class DateEditor implements Editor { this._$inputWithData.remove(); this._$inputWithData = null; } + this._$input.remove(); } disable(isDisabled = true) { diff --git a/packages/common/src/editors/sliderEditor.ts b/packages/common/src/editors/sliderEditor.ts index 3c9c51f25..3eccff18d 100644 --- a/packages/common/src/editors/sliderEditor.ts +++ b/packages/common/src/editors/sliderEditor.ts @@ -104,7 +104,7 @@ export class SliderEditor implements Editor { // if user chose to display the slider number on the right side, then update it every time it changes // we need to use both "input" and "change" event to be all cross-browser if (!this.editorParams.hideSliderNumber) { - this._$editorElm.on('input change', (event: JQueryEventObject & { target: HTMLInputElement }) => { + this._$editorElm.on('input change', (event: JQuery.Event & { target: HTMLInputElement }) => { const value = event && event.target && event.target.value || ''; if (value && document) { const elements = document.getElementsByClassName(this._elementRangeOutputId || ''); diff --git a/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts b/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts index 4c0ac5175..1845e460a 100644 --- a/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts +++ b/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts @@ -111,11 +111,14 @@ describe('checkboxSelectorExtension', () => { it('should dispose of the addon', () => { const instance = extension.create(columnsMock, gridOptionsMock); - const destroySpy = jest.spyOn(instance, 'destroy'); + const selectionModel = extension.register(); + const addonDestroySpy = jest.spyOn(instance, 'destroy'); + const smDestroySpy = jest.spyOn(selectionModel, 'destroy'); extension.dispose(); - expect(destroySpy).toHaveBeenCalled(); + expect(addonDestroySpy).toHaveBeenCalled(); + expect(smDestroySpy).toHaveBeenCalled(); }); it('should provide addon options and expect them to be called in the addon constructor', () => { diff --git a/packages/common/src/extensions/cellExternalCopyManagerExtension.ts b/packages/common/src/extensions/cellExternalCopyManagerExtension.ts index d0ae035be..e7c297d15 100644 --- a/packages/common/src/extensions/cellExternalCopyManagerExtension.ts +++ b/packages/common/src/extensions/cellExternalCopyManagerExtension.ts @@ -24,6 +24,7 @@ declare const Slick: SlickNamespace; export class CellExternalCopyManagerExtension implements Extension { private _addon: SlickCellExternalCopyManager | null; private _addonOptions: ExcelCopyBufferOption; + private _cellSelectionModel: SlickCellSelectionModel; private _eventHandler: SlickEventHandler; private _commandQueue: EditCommand[]; private _undoRedoBuffer: EditUndoRedoBuffer; @@ -55,6 +56,10 @@ export class CellExternalCopyManagerExtension implements Extension { if (this._addon && this._addon.destroy) { this._addon.destroy(); } + if (this._cellSelectionModel?.destroy) { + this._cellSelectionModel.destroy(); + } + document.removeEventListener('keydown', this.hookUndoShortcutKey.bind(this)); } /** Get the instance of the SlickGrid addon (control or plugin). */ @@ -72,7 +77,8 @@ export class CellExternalCopyManagerExtension implements Extension { this.hookUndoShortcutKey(); this._addonOptions = { ...this.getDefaultOptions(), ...this.sharedService.gridOptions.excelCopyBufferOptions } as ExcelCopyBufferOption; - this.sharedService.slickGrid.setSelectionModel(new Slick.CellSelectionModel() as SlickCellSelectionModel); + this._cellSelectionModel = new Slick.CellSelectionModel() as SlickCellSelectionModel; + this.sharedService.slickGrid.setSelectionModel(this._cellSelectionModel); this._addon = new Slick.CellExternalCopyManager(this._addonOptions); if (this._addon) { this.sharedService.slickGrid.registerPlugin(this._addon); diff --git a/packages/common/src/extensions/checkboxSelectorExtension.ts b/packages/common/src/extensions/checkboxSelectorExtension.ts index 0e67e6650..a8ffc1fec 100644 --- a/packages/common/src/extensions/checkboxSelectorExtension.ts +++ b/packages/common/src/extensions/checkboxSelectorExtension.ts @@ -8,6 +8,7 @@ declare const Slick: SlickNamespace; export class CheckboxSelectorExtension implements Extension { private _addon: SlickCheckboxSelectColumn | null; + private _rowSelectionPlugin: SlickRowSelectionModel; constructor(private extensionUtility: ExtensionUtility, private sharedService: SharedService) { } @@ -16,6 +17,9 @@ export class CheckboxSelectorExtension implements Extension { this._addon.destroy(); this._addon = null; } + if (this._rowSelectionPlugin?.destroy) { + this._rowSelectionPlugin.destroy(); + } } /** @@ -73,7 +77,7 @@ export class CheckboxSelectorExtension implements Extension { if (this.sharedService.gridOptions.preselectedRows && rowSelectionPlugin && this.sharedService.slickGrid.getSelectionModel()) { setTimeout(() => this._addon?.selectRows(this.sharedService.gridOptions.preselectedRows || [])); } - + this._rowSelectionPlugin = rowSelectionPlugin; return rowSelectionPlugin; } return null; diff --git a/packages/common/src/extensions/rowMoveManagerExtension.ts b/packages/common/src/extensions/rowMoveManagerExtension.ts index 47f856de8..35f6a6a9b 100644 --- a/packages/common/src/extensions/rowMoveManagerExtension.ts +++ b/packages/common/src/extensions/rowMoveManagerExtension.ts @@ -18,6 +18,7 @@ declare const Slick: SlickNamespace; export class RowMoveManagerExtension implements Extension { private _addon: SlickRowMoveManager | null; private _eventHandler: SlickEventHandler; + private _rowSelectionPlugin: SlickRowSelectionModel; constructor(private extensionUtility: ExtensionUtility, private sharedService: SharedService) { this._eventHandler = new Slick.EventHandler(); @@ -35,6 +36,9 @@ export class RowMoveManagerExtension implements Extension { this._addon.destroy(); this._addon = null; } + if (this._rowSelectionPlugin?.destroy) { + this._rowSelectionPlugin.destroy(); + } } /** @@ -101,7 +105,7 @@ export class RowMoveManagerExtension implements Extension { rowSelectionPlugin = new Slick.RowSelectionModel(this.sharedService.gridOptions.rowSelectionOptions); this.sharedService.slickGrid.setSelectionModel(rowSelectionPlugin); } - + this._rowSelectionPlugin = rowSelectionPlugin; this.sharedService.slickGrid.registerPlugin(this._addon); // hook all events diff --git a/packages/common/src/filters/compoundDateFilter.ts b/packages/common/src/filters/compoundDateFilter.ts index 12846b9ea..74aa3b3e4 100644 --- a/packages/common/src/filters/compoundDateFilter.ts +++ b/packages/common/src/filters/compoundDateFilter.ts @@ -126,14 +126,13 @@ export class CompoundDateFilter implements Filter { if (this.flatInstance.element) { destroyObjectDomElementProps(this.flatInstance); } - this.flatInstance = null; } if (this.$filterElm) { this.$filterElm.off('keyup').remove(); this.$filterElm = null; } if (this.$selectOperatorElm) { - this.$selectOperatorElm.off('change').remove() + this.$selectOperatorElm.off('change').remove(); } } diff --git a/packages/common/src/formatters/__tests__/fakeHyperlinkFormatter.spec.ts b/packages/common/src/formatters/__tests__/fakeHyperlinkFormatter.spec.ts new file mode 100644 index 000000000..092a46b8b --- /dev/null +++ b/packages/common/src/formatters/__tests__/fakeHyperlinkFormatter.spec.ts @@ -0,0 +1,15 @@ +import { fakeHyperlinkFormatter } from '../fakeHyperlinkFormatter'; + +describe('the Edit Icon Formatter', () => { + it('should return a span with the "fake-hyperlink" class when a value is provided', () => { + const value = 'Custom Value'; + const result = fakeHyperlinkFormatter(0, 0, value); + expect(result).toBe('Custom Value'); + }); + + it('should return an empty string formatter when no value is provided', () => { + const value = null; + const result = fakeHyperlinkFormatter(0, 0, value); + expect(result).toBe(''); + }); +}); diff --git a/packages/common/src/formatters/fakeHyperlinkFormatter.ts b/packages/common/src/formatters/fakeHyperlinkFormatter.ts new file mode 100644 index 000000000..9266635fe --- /dev/null +++ b/packages/common/src/formatters/fakeHyperlinkFormatter.ts @@ -0,0 +1,5 @@ +import { Formatter } from './../interfaces/index'; + +export const fakeHyperlinkFormatter: Formatter = (_row: number, _cell: number, value: string) => { + return value ? `${value}` : ''; +}; diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index 9730475c3..bd5c58543 100644 --- a/packages/common/src/global-grid-options.ts +++ b/packages/common/src/global-grid-options.ts @@ -96,6 +96,14 @@ export const GlobalGridOptions: GridOption = { defaultSlickgridEventPrefix: '', editable: false, editorTypingDebounce: 450, + enableEmptyDataWarningMessage: true, + emptyDataWarning: { + class: 'slick-empty-data-warning', + message: 'No data to display.', + messageKey: 'EMPTY_DATA_WARNING_MESSAGE', + marginTop: 100, + marginLeft: 10 + }, enableAutoResize: true, enableAutoSizeColumns: true, enableCellNavigation: false, diff --git a/packages/common/src/interfaces/emptyWarning.interface.ts b/packages/common/src/interfaces/emptyWarning.interface.ts new file mode 100644 index 000000000..caff74e55 --- /dev/null +++ b/packages/common/src/interfaces/emptyWarning.interface.ts @@ -0,0 +1,16 @@ +export interface EmptyWarning { + /** Empty data warning message, defaults to "No data to display." */ + message: string; + + /** Empty data warning message translation key, defaults to "EMPTY_DATA_WARNING_MESSAGE" */ + messageKey?: string; + + /** DOM Element class name, defaults to "empty-data-warning" */ + class?: string; + + /** Top margin position, number in pixel, of where the warning message will be displayed, default calculation is (header title row + filter row + 5px) */ + marginTop?: number; + + /** Left margin position, number in pixel, of where the warning message will be displayed, defaults to 10px */ + marginLeft?: number; +} diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index b1f33d9e9..c14167b89 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -11,6 +11,7 @@ import { DataViewOption, DraggableGrouping, EditCommand, + EmptyWarning, ExcelCopyBufferOption, ExcelExportOption, ExportOption, @@ -170,6 +171,15 @@ export interface GridOption { /** Default to 450ms and only applies to Composite Editor, how long to wait until we start validating the editor changes on Editor that support it (integer, float, text, longText). */ editorTypingDebounce?: number; + emptyDataWarning?: EmptyWarning; + + /** + * Defaults to true, will display a warning message positioned inside the grid when there's no data returned. + * When using local (in-memory) dataset, it will show the message when there's no filtered data returned. + * When using backend Pagination it will display the message as soon as the total row count is 0. + */ + enableEmptyDataWarningMessage?: boolean; + /** Do we want to emulate paging when we are scrolling? */ emulatePagingWhenScrolling?: boolean; diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index ae0eb9ceb..97a3efbd9 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -43,6 +43,7 @@ export * from './editorValidationResult.interface'; export * from './editorValidator.interface'; export * from './editUndoRedoBuffer.interface'; export * from './elementPosition.interface'; +export * from './emptyWarning.interface'; export * from './excelCellFormat.interface'; export * from './excelCopyBufferOption.interface'; export * from './excelExportOption.interface'; diff --git a/packages/common/src/interfaces/slickCellRangeSelector.interface.ts b/packages/common/src/interfaces/slickCellRangeSelector.interface.ts index 557fc99cd..6c4153e38 100644 --- a/packages/common/src/interfaces/slickCellRangeSelector.interface.ts +++ b/packages/common/src/interfaces/slickCellRangeSelector.interface.ts @@ -1,7 +1,6 @@ -import { SlickCellRangeDecorator, SlickGrid } from './index'; +import { SlickCellRangeDecorator, SlickGrid, SlickRange } from './index'; import { CellRange } from './cellRange.interface'; import { SlickEvent } from './slickEvent.interface'; -import { SlickEventData } from './slickEventData.interface'; export interface SlickCellRangeSelector { pluginName: 'CellRangeSelector' @@ -28,5 +27,5 @@ export interface SlickCellRangeSelector { onBeforeCellRangeSelected: SlickEvent<{ cell: { row: number; cell: number; } }>; /** Triggered after a cell range selection happened */ - onCellRangeSelected: SlickEventData; + onCellRangeSelected: SlickEvent<{ range: SlickRange }>; } diff --git a/packages/common/src/services/__tests__/grid.service.spec.ts b/packages/common/src/services/__tests__/grid.service.spec.ts index 0a0d4abdc..55585eb4c 100644 --- a/packages/common/src/services/__tests__/grid.service.spec.ts +++ b/packages/common/src/services/__tests__/grid.service.spec.ts @@ -7,14 +7,15 @@ jest.useFakeTimers(); declare const Slick: SlickNamespace; -const mockSelectionModel = jest.fn().mockImplementation(() => ({ +const mockSelectionModel = { init: jest.fn(), destroy: jest.fn() -})); +}; +const mockSelectionModelImplementation = jest.fn().mockImplementation(() => mockSelectionModel); jest.mock('flatpickr', () => { }); -jest.mock('slickgrid/plugins/slick.rowselectionmodel', () => mockSelectionModel); -Slick.RowSelectionModel = mockSelectionModel; +jest.mock('slickgrid/plugins/slick.rowselectionmodel', () => mockSelectionModelImplementation); +Slick.RowSelectionModel = mockSelectionModelImplementation; const extensionServiceStub = { getAllColumns: jest.fn(), @@ -99,6 +100,15 @@ describe('Grid Service', () => { expect(service).toBeTruthy(); }); + it('should dispose of the service', () => { + const destroySpy = jest.spyOn(mockSelectionModel, 'destroy'); + + service.highlightRow(0, 10, 15); + service.dispose(); + + expect(destroySpy).toHaveBeenCalled(); + }); + describe('getAllColumnDefinitions method', () => { it('should call "allColumns" GETTER ', () => { const mockColumns = [{ id: 'field1', field: 'field1', width: 100 }, { id: 'field2', field: 'field2', width: 100 }]; @@ -1358,7 +1368,7 @@ describe('Grid Service', () => { const extensionSpy = jest.spyOn(extensionServiceStub, 'getAllColumns').mockReturnValue(mockColumns); // const gridStateSpy = jest.spyOn(gridStateServiceStub, 'resetColumns'); - service.resetGrid(mockColumns); + service.resetGrid(); expect(extensionSpy).toHaveBeenCalled(); // expect(gridStateSpy).toHaveBeenCalledWith(mockColumns); diff --git a/packages/common/src/services/extension.service.ts b/packages/common/src/services/extension.service.ts index c1468209d..064eb335c 100644 --- a/packages/common/src/services/extension.service.ts +++ b/packages/common/src/services/extension.service.ts @@ -59,7 +59,7 @@ export class ExtensionService { for (const extensionName of Object.keys(this._extensionList)) { if (this._extensionList.hasOwnProperty(extensionName)) { const extension = this._extensionList[extensionName] as ExtensionModel; - if (extension && extension.class && extension.class.dispose) { + if (extension?.class?.dispose) { extension.class.dispose(); } } diff --git a/packages/common/src/services/grid.service.ts b/packages/common/src/services/grid.service.ts index b31d1123d..9b4cc92ea 100644 --- a/packages/common/src/services/grid.service.ts +++ b/packages/common/src/services/grid.service.ts @@ -9,6 +9,7 @@ import { OnEventArgs, SlickGrid, SlickNamespace, + SlickRowSelectionModel, } from '../interfaces/index'; import { ExtensionService } from './extension.service'; import { FilterService } from './filter.service'; @@ -27,6 +28,7 @@ const GridServiceUpdateOptionDefaults: GridServiceUpdateOption = { highlightRow: export class GridService { private _grid: SlickGrid; + private _rowSelectionPlugin: SlickRowSelectionModel; constructor( private extensionService: ExtensionService, @@ -47,6 +49,12 @@ export class GridService { return (this._grid?.getOptions) ? this._grid.getOptions() : {}; } + dispose() { + if (this._rowSelectionPlugin?.destroy) { + this._rowSelectionPlugin.destroy(); + } + } + init(grid: SlickGrid): void { this._grid = grid; } @@ -215,8 +223,8 @@ export class GridService { highlightRow(rowNumber: number | number[], fadeDelay = 1500, fadeOutDelay = 300) { // create a SelectionModel if there's not one yet if (!this._grid.getSelectionModel() && Slick && Slick.RowSelectionModel) { - const rowSelectionPlugin = new Slick.RowSelectionModel(this._gridOptions.rowSelectionOptions); - this._grid.setSelectionModel(rowSelectionPlugin); + this._rowSelectionPlugin = new Slick.RowSelectionModel(this._gridOptions.rowSelectionOptions); + this._grid.setSelectionModel(this._rowSelectionPlugin); } if (Array.isArray(rowNumber)) { diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 914371bf3..f1ba8ecff 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -861,3 +861,11 @@ $footer-right-float: right !default; $footer-right-text-align: right !default; $footer-right-text-color: $footer-text-color !default; $footer-right-width: 50% !default; + +/** Empty Data Warning element */ +$empty-data-warning-color: $cell-text-color !default; +$empty-data-warning-font-family: $font-family !default; +$empty-data-warning-font-size: calc(#{$font-size-base} + 2px) !default; +$empty-data-warning-font-style: italic !default; +$empty-data-warning-line-height: 18px !default; +$empty-data-warning-z-index: 9999 !default; diff --git a/packages/common/src/styles/slick-pagination.scss b/packages/common/src/styles/slick-component.scss similarity index 70% rename from packages/common/src/styles/slick-pagination.scss rename to packages/common/src/styles/slick-component.scss index 67a9f6fe4..c72adba4f 100644 --- a/packages/common/src/styles/slick-pagination.scss +++ b/packages/common/src/styles/slick-component.scss @@ -1,7 +1,64 @@ /* pagination/pagination variables */ @import './variables'; -/* Pagination styling */ +// ---------------------------------------------- +// Slick Footer Component +// ---------------------------------------------- + +.slick-custom-footer { + color: $footer-text-color; + padding: $footer-padding; + background-color: $footer-bg-color; + font-size: $footer-font-size; + font-style: $footer-font-style; + font-weight: $footer-font-weight; + height: $footer-height; + + .left-footer { + color: $footer-left-text-color; + font-style: $footer-left-font-style; + font-weight: $footer-left-font-weight; + text-align: $footer-left-text-align; + padding: $footer-left-padding; + width: $footer-left-width; + float: $footer-left-float; + } + + .right-footer.metrics { + color: $footer-right-text-color; + text-align: $footer-right-text-align; + font-style: $footer-right-font-style; + font-weight: $footer-right-font-weight; + text-align: $footer-right-text-align; + padding: $footer-right-padding; + width: $footer-right-width; + float: $footer-right-float; + .separator { + margin: $footer-right-separator-margin; + } + } +} + + +// ---------------------------------------------- +// Slick Empty Data Warning Component +// ---------------------------------------------- + +.slick-empty-data-warning { + position: absolute; + color: $empty-data-warning-color; + font-family: $empty-data-warning-font-family; + font-size: $empty-data-warning-font-size; + font-style: $empty-data-warning-font-style; + line-height: $empty-data-warning-line-height; + z-index: $empty-data-warning-z-index; +} + + +// ---------------------------------------------- +// Slick Pagination Component +// ---------------------------------------------- + .slick-pagination { border-top: $pagination-border-top; border-right: $pagination-border-right; diff --git a/packages/common/src/styles/slick-footer.scss b/packages/common/src/styles/slick-footer.scss deleted file mode 100644 index 1524dbab4..000000000 --- a/packages/common/src/styles/slick-footer.scss +++ /dev/null @@ -1,35 +0,0 @@ -@import './variables'; - -.slick-custom-footer { - color: $footer-text-color; - padding: $footer-padding; - background-color: $footer-bg-color; - font-size: $footer-font-size; - font-style: $footer-font-style; - font-weight: $footer-font-weight; - height: $footer-height; - - .left-footer { - color: $footer-left-text-color; - font-style: $footer-left-font-style; - font-weight: $footer-left-font-weight; - text-align: $footer-left-text-align; - padding: $footer-left-padding; - width: $footer-left-width; - float: $footer-left-float; - } - - .right-footer.metrics { - color: $footer-right-text-color; - text-align: $footer-right-text-align; - font-style: $footer-right-font-style; - font-weight: $footer-right-font-weight; - text-align: $footer-right-text-align; - padding: $footer-right-padding; - width: $footer-right-width; - float: $footer-right-float; - .separator { - margin: $footer-right-separator-margin; - } - } -} diff --git a/packages/common/src/styles/slickgrid-theme-bootstrap.scss b/packages/common/src/styles/slickgrid-theme-bootstrap.scss index 390a17d37..dbbb7c704 100644 --- a/packages/common/src/styles/slickgrid-theme-bootstrap.scss +++ b/packages/common/src/styles/slickgrid-theme-bootstrap.scss @@ -10,8 +10,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-material.bare.scss b/packages/common/src/styles/slickgrid-theme-material.bare.scss index 6f496823b..7bac9e74f 100644 --- a/packages/common/src/styles/slickgrid-theme-material.bare.scss +++ b/packages/common/src/styles/slickgrid-theme-material.bare.scss @@ -16,8 +16,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-material.lite.scss b/packages/common/src/styles/slickgrid-theme-material.lite.scss index e657ab237..0a9343de1 100644 --- a/packages/common/src/styles/slickgrid-theme-material.lite.scss +++ b/packages/common/src/styles/slickgrid-theme-material.lite.scss @@ -18,8 +18,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-material.scss b/packages/common/src/styles/slickgrid-theme-material.scss index 421408854..c2094c07a 100644 --- a/packages/common/src/styles/slickgrid-theme-material.scss +++ b/packages/common/src/styles/slickgrid-theme-material.scss @@ -20,8 +20,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; - @import './slick-pagination'; - @import './slick-footer'; + @import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-salesforce.bare.scss b/packages/common/src/styles/slickgrid-theme-salesforce.bare.scss index 0563a1930..c849839d5 100644 --- a/packages/common/src/styles/slickgrid-theme-salesforce.bare.scss +++ b/packages/common/src/styles/slickgrid-theme-salesforce.bare.scss @@ -16,8 +16,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-salesforce.lite.scss b/packages/common/src/styles/slickgrid-theme-salesforce.lite.scss index 5add2f649..741653a95 100644 --- a/packages/common/src/styles/slickgrid-theme-salesforce.lite.scss +++ b/packages/common/src/styles/slickgrid-theme-salesforce.lite.scss @@ -17,8 +17,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/common/src/styles/slickgrid-theme-salesforce.scss b/packages/common/src/styles/slickgrid-theme-salesforce.scss index 7eda514f1..1dcf30460 100644 --- a/packages/common/src/styles/slickgrid-theme-salesforce.scss +++ b/packages/common/src/styles/slickgrid-theme-salesforce.scss @@ -21,8 +21,7 @@ @import './slick-controls'; @import './slick-editors'; @import './slick-plugins'; -@import './slick-pagination'; -@import './slick-footer'; +@import './slick-component'; @import './slickgrid-examples'; @import './slick-bootstrap'; @import './ui-autocomplete'; diff --git a/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip b/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip index 8d8db6d0e..580343d61 100644 Binary files a/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip and b/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip differ diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-empty-warning.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-empty-warning.spec.ts new file mode 100644 index 000000000..fde11be89 --- /dev/null +++ b/packages/vanilla-bundle/src/components/__tests__/slick-empty-warning.spec.ts @@ -0,0 +1,101 @@ +import { GridOption, SlickGrid } from '@slickgrid-universal/common'; +import { SlickEmptyWarningComponent } from '../slick-empty-warning.component'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const GRID_UID = 'slickgrid_123456'; + +const mockGridOptions = { + enableTranslate: false, + showCustomFooter: true, +} as GridOption; + +const gridStub = { + getGridPosition: () => mockGridOptions, + getOptions: () => mockGridOptions, + getUID: () => GRID_UID, + registerPlugin: jest.fn(), +} as unknown as SlickGrid; + +describe('Slick-Empty-Warning Component', () => { + let component: SlickEmptyWarningComponent; + let div: HTMLDivElement; + let translateService: TranslateServiceStub; + + beforeEach(() => { + div = document.createElement('div'); + div.className = GRID_UID; + document.body.appendChild(div); + translateService = new TranslateServiceStub(); + + mockGridOptions.emptyDataWarning = { + message: 'No data to display.', + messageKey: 'EMPTY_DATA_WARNING_MESSAGE' + }; + }); + + describe('Integration Tests', () => { + afterEach(() => { + // clear all the spyOn mocks to not influence next test + jest.clearAllMocks(); + component.dispose(); + }); + + it('should expect the Slick-Empty-Warning to be created and NOT be rendered when passing False as 2nd argument and component was never rendered', () => { + component = new SlickEmptyWarningComponent(gridStub); + component.showEmptyDataMessage(false); + + const componentElm = document.querySelector('div.slickgrid_123456.slick-empty-data-warning') as HTMLSelectElement; + + expect(component).toBeTruthy(); + expect(component.constructor).toBeDefined(); + expect(componentElm).toBeFalsy(); + }); + + it('should expect the Slick-Empty-Warning to be created and rendered and passing true as 2nd argument', () => { + component = new SlickEmptyWarningComponent(gridStub); + component.showEmptyDataMessage(true); + + const componentElm = document.querySelector('div.slickgrid_123456.slick-empty-data-warning') as HTMLSelectElement; + + expect(component).toBeTruthy(); + expect(component.constructor).toBeDefined(); + expect(componentElm).toBeTruthy(); + expect(componentElm.style.display).toBe('block'); + expect(componentElm.textContent).toBe('No data to display.'); + }); + + it('should expect the Slick-Empty-Warning to change some options and display a different message when provided as an option', () => { + const mockGridPosition = { top: 500, left: 42, bottom: 34, right: 15, height: 800, width: 450, visible: true }; + const mockOptions = { message: 'No Record found.', class: 'custom-class', marginTop: 22, marginLeft: 11 }; + jest.spyOn(gridStub, 'getGridPosition').mockReturnValue(mockGridPosition); + component = new SlickEmptyWarningComponent(gridStub); + component.showEmptyDataMessage(true, mockOptions); + + const componentElm = document.querySelector('div.slickgrid_123456.custom-class') as HTMLSelectElement; + + expect(component).toBeTruthy(); + expect(component.constructor).toBeDefined(); + expect(componentElm).toBeTruthy(); + expect(componentElm.style.display).toBe('block'); + expect(componentElm.style.top).toBe(`${mockGridPosition.top + 22}px`); // 500 + 22 + expect(componentElm.style.left).toBe(`${mockGridPosition.left + 11}px`); // 42 + 11 + expect(componentElm.classList.contains('custom-class')).toBeTruthy(); + expect(componentElm.textContent).toBe('No Record found.'); + }); + + it('should expect the Slick-Empty-Warning message to be translated to French when providing a Translater Service and "messageKey" property', () => { + mockGridOptions.enableTranslate = true; + translateService.use('fr'); + + component = new SlickEmptyWarningComponent(gridStub, translateService); + component.showEmptyDataMessage(true); + const componentElm = document.querySelector('div.slickgrid_123456.slick-empty-data-warning') as HTMLSelectElement; + + expect(component).toBeTruthy(); + expect(component.constructor).toBeDefined(); + expect(componentElm).toBeTruthy(); + expect(componentElm.style.display).toBe('block'); + expect(componentElm.textContent).toBe('Aucune donnée à afficher.'); + }); + }); +}); diff --git a/packages/vanilla-bundle/src/components/slick-empty-warning.component.ts b/packages/vanilla-bundle/src/components/slick-empty-warning.component.ts new file mode 100644 index 000000000..fc0a98ac6 --- /dev/null +++ b/packages/vanilla-bundle/src/components/slick-empty-warning.component.ts @@ -0,0 +1,76 @@ +import { EmptyWarning, getHtmlElementOffset, GridOption, sanitizeTextByAvailableSanitizer, SlickGrid, TranslaterService } from '@slickgrid-universal/common'; + +export class SlickEmptyWarningComponent { + private _warningElement: HTMLDivElement | null; + + /** Getter for the Grid Options pulled through the Grid Object */ + get gridOptions(): GridOption { + return (this.grid && this.grid.getOptions) ? this.grid.getOptions() : {}; + } + + constructor(private grid: SlickGrid, private translaterService?: TranslaterService) { } + + dispose() { + this._warningElement?.remove(); + this._warningElement = null; + } + + /** + * Display a warning of empty data when the filtered dataset is empty + * NOTE: to make this code reusable, you could (should) move this code into a utility service + * @param isShowing - are we showing the message? + * @param options - any styling options you'd like to pass like the text color + */ + showEmptyDataMessage(isShowing = true, options?: EmptyWarning): boolean { + const gridUid = this.grid.getUID(); + const defaultMessage = 'No data to display.'; + const mergedOptions: EmptyWarning = { message: defaultMessage, ...this.gridOptions.emptyDataWarning, ...options }; + const emptyDataClassName = mergedOptions?.class ?? 'slick-empty-data-warning'; + const finalClassNames = [gridUid, emptyDataClassName]; + this._warningElement = document.querySelector(`.${finalClassNames.join('.')}`); + + // calculate margins + const gridHeaderFilterRowHeight = this.gridOptions?.headerRowHeight ?? 30; // filter row height + const headerRowCount = 2; // header title row is calculated by SASS and defined as (17px * headerRowCount + paddingTopBottom) + const headerRowPaddingTopBottom = 10; // 5px (2x for both top/bottom), this is different in each SASS Theme + const headerRowHeight = 17 * headerRowCount + headerRowPaddingTopBottom; + let warningMessage = mergedOptions.message; + if (this.gridOptions.enableTranslate && this.translaterService && mergedOptions?.messageKey) { + warningMessage = this.translaterService.translate(mergedOptions.messageKey); + } + const preHeaderRowHeight = this.gridOptions.showPreHeaderPanel && this.gridOptions.preHeaderPanelHeight || 0; + const marginTop = (mergedOptions.marginTop ?? (headerRowHeight + gridHeaderFilterRowHeight + 5)) + preHeaderRowHeight; + const marginLeft = mergedOptions.marginLeft ?? 10; + + if (!this._warningElement && !isShowing) { + return isShowing; + } + + if (!this._warningElement) { + const sanitizedOptions = this.gridOptions && this.gridOptions.sanitizeHtmlOptions || {}; + const sanitizedText = sanitizeTextByAvailableSanitizer(this.gridOptions, warningMessage, sanitizedOptions); + + this._warningElement = document.createElement('div'); + this._warningElement.className = finalClassNames.join(' '); + this._warningElement.innerHTML = sanitizedText; + document.body.appendChild(this._warningElement); + } + + // if we did find the Slick-Empty-Warning element then we'll display/hide at the grid position with some margin offsets (we need to position under the headerRow and filterRow) + if (this._warningElement) { + if (isShowing) { + const gridPosition = this.grid.getGridPosition(); + const gridOffset = getHtmlElementOffset(document.querySelector(`.${this.grid.getUID()}`) as HTMLDivElement); + + // SF seems to have problem with getGridPosition() so we can use getHtmlElementOffset when that happens + const gridPosTop = !isNaN(gridPosition.top) ? gridPosition.top : (gridOffset?.top ?? 0); + const gridPosLeft = !isNaN(gridPosition.left) ? gridPosition.left : (gridOffset?.left ?? 0); + this._warningElement.style.top = `${gridPosTop + marginTop}px`; + this._warningElement.style.left = `${gridPosLeft + marginLeft}px`; + } + this._warningElement.style.display = isShowing ? 'block' : 'none'; + } + + return isShowing; + } +} diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts index 8733c663e..1fb1f2052 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -74,6 +74,7 @@ import { EventPubSubService } from '../services/eventPubSub.service'; import { FileExportService } from '../services/fileExport.service'; import { ResizerService } from '../services/resizer.service'; import { SalesforceGlobalGridOptions } from '../salesforce-global-grid-options'; +import { SlickEmptyWarningComponent } from './slick-empty-warning.component'; import { SlickFooterComponent } from './slick-footer.component'; import { SlickPaginationComponent } from './slick-pagination.component'; import { SlickerGridInstance } from '../interfaces/slickerGridInstance.interface'; @@ -91,6 +92,7 @@ export class SlickVanillaGridBundle { private _gridParentContainerElm: HTMLElement; private _hideHeaderRowAfterPageLoad = false; private _isDatasetInitialized = false; + private _isDatasetProvided = false; private _isGridInitialized = false; private _isLocalGrid = true; private _isPaginationInitialized = false; @@ -133,6 +135,7 @@ export class SlickVanillaGridBundle { treeDataService: TreeDataService; slickCompositeEditor: SlickCompositeEditorComponent | undefined; + slickEmptyWarning: SlickEmptyWarningComponent | undefined; slickFooter: SlickFooterComponent | undefined; slickPagination: SlickPaginationComponent | undefined; gridClass: string; @@ -185,6 +188,7 @@ export class SlickVanillaGridBundle { this.dataView.setItems([], this._gridOptions.datasetIdPropertyName); this.sortService.processTreeDataInitialSort(); } + this._isDatasetProvided = true; } get gridOptions(): GridOption { @@ -355,7 +359,10 @@ export class SlickVanillaGridBundle { this.initialization(this._gridContainerElm, eventHandler); if (!hierarchicalDataset && !this.gridOptions.backendServiceApi) { this.dataset = dataset || []; + this._isDatasetProvided = true; } + + this.slickEmptyWarning = new SlickEmptyWarningComponent(this.slickGrid, this.translaterService); } emptyGridContainerElm() { @@ -373,6 +380,7 @@ export class SlickVanillaGridBundle { this.extensionService?.dispose(); this.filterService?.dispose(); this.gridEventService?.dispose(); + this.gridService?.dispose(); this.gridStateService?.dispose(); this.groupingService?.dispose(); this.paginationService?.dispose(); @@ -381,6 +389,7 @@ export class SlickVanillaGridBundle { this.treeDataService?.dispose(); // dispose the Components + this.slickEmptyWarning?.dispose(); this.slickCompositeEditor?.dispose(); this.slickFooter?.dispose(); this.slickPagination?.dispose(); @@ -673,6 +682,7 @@ export class SlickVanillaGridBundle { if (processResult && processResult.data && processResult.data[datasetName]) { this._dataset = processResult.data[datasetName].hasOwnProperty('nodes') ? (processResult as any).data[datasetName].nodes : (processResult as any).data[datasetName]; const totalCount = processResult.data[datasetName].hasOwnProperty('totalCount') ? (processResult as any).data[datasetName].totalCount : (processResult as any).data[datasetName].length; + this._isDatasetProvided = true; this.refreshGridData(this._dataset, totalCount || 0); } }; @@ -789,6 +799,11 @@ export class SlickVanillaGridBundle { if (this.slickFooter) { this.slickFooter.metrics = this.metrics; } + + // when using local (in-memory) dataset, we'll display a warning message when filtered data is empty + if (this._isLocalGrid && this._gridOptions.enableEmptyDataWarningMessage) { + this.displayEmptyDataWarning(args.current === 0); + } }); // when filtering data with local dataset, we need to update each row else it will not always show correctly in the UI @@ -942,6 +957,11 @@ export class SlickVanillaGridBundle { this.loadLocalGridPagination(dataset); } + if (this._gridOptions.enableEmptyDataWarningMessage && Array.isArray(dataset) && this._isDatasetProvided) { + const finalTotalCount = totalCount || dataset.length; + this.displayEmptyDataWarning(finalTotalCount < 1); + } + if (Array.isArray(dataset) && this.slickGrid && this.dataView && typeof this.dataView.setItems === 'function') { this.dataView.setItems(dataset, this._gridOptions.datasetIdPropertyName); if (!this._gridOptions.backendServiceApi) { @@ -1041,6 +1061,14 @@ export class SlickVanillaGridBundle { return paginationOptions; } + // -- + // private functions + // ------------------ + + private displayEmptyDataWarning(showWarning = true) { + this.slickEmptyWarning?.showEmptyDataMessage(showWarning); + } + /** Initialize the Pagination Service once */ private initializePaginationService(paginationOptions: Pagination) { if (this.gridOptions) { diff --git a/packages/vanilla-bundle/src/salesforce-global-grid-options.ts b/packages/vanilla-bundle/src/salesforce-global-grid-options.ts index ff5e51946..07d7a3a10 100644 --- a/packages/vanilla-bundle/src/salesforce-global-grid-options.ts +++ b/packages/vanilla-bundle/src/salesforce-global-grid-options.ts @@ -1,7 +1,7 @@ import { GridOption, EventNamingStyle } from '@slickgrid-universal/common'; /** Global Grid Options Defaults for Salesforce */ -export const SalesforceGlobalGridOptions: GridOption = { +export const SalesforceGlobalGridOptions = { autoEdit: true, // true single click (false for double-click) autoCommitEdit: true, compositeEditorOptions: { @@ -12,6 +12,12 @@ export const SalesforceGlobalGridOptions: GridOption = { }, datasetIdPropertyName: 'Id', defaultFilterPlaceholder: '', + emptyDataWarning: { + class: 'slick-empty-data-warning', + message: ` No data to display.`, + marginTop: 90, + marginLeft: 10 + }, enableAutoTooltip: true, enableDeepCopyDatasetOnPageLoad: true, enableExport: true, @@ -45,4 +51,4 @@ export const SalesforceGlobalGridOptions: GridOption = { rowHeight: 33, eventNamingStyle: EventNamingStyle.lowerCaseWithoutOnPrefix, useSalesforceDefaultGridOptions: true, -}; +} as GridOption; diff --git a/test/cypress/integration/example07.spec.js b/test/cypress/integration/example07.spec.js index e78aa355d..cd3b69826 100644 --- a/test/cypress/integration/example07.spec.js +++ b/test/cypress/integration/example07.spec.js @@ -127,7 +127,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { // change Finish date cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-05').click(); - cy.get('.flatpickr-day:visible:nth(25)').click('bottom', { force: true }); + cy.get('.flatpickr-calendar:visible .flatpickr-day').contains('22').click('bottom', { force: true }); cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-22'); cy.get('.slick-viewport.slick-viewport-top.slick-viewport-left') @@ -192,7 +192,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { // change Finish date cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-05').click(); - cy.get('.flatpickr-day:visible:nth(25)').click('bottom', { force: true }); + cy.get('.flatpickr-calendar:visible .flatpickr-day').contains('22').click('bottom', { force: true }); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-22'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('contain', 'Task 0000'); diff --git a/test/translateServiceStub.ts b/test/translateServiceStub.ts index a575daeef..171b0f974 100644 --- a/test/translateServiceStub.ts +++ b/test/translateServiceStub.ts @@ -28,6 +28,7 @@ export class TranslateServiceStub implements TranslaterService { case 'CONTAINS': output = this._locale === 'en' ? 'Contains' : 'Contient'; break; case 'COPY': output = this._locale === 'en' ? 'Copy' : 'Copier'; break; case 'DURATION': output = this._locale === 'en' ? 'Duration' : 'Durée'; break; + case 'EMPTY_DATA_WARNING_MESSAGE': output = this._locale === 'en' ? 'No data to display.' : 'Aucune donnée à afficher.'; break; case 'ENDS_WITH': output = this._locale === 'en' ? 'Ends With' : 'Se termine par'; break; case 'EQUALS': output = this._locale === 'en' ? 'Equals' : 'Égale'; break; case 'EQUAL_TO': output = this._locale === 'en' ? 'Equal to' : 'Égal à'; break;