diff --git a/docs/grid-functionalities/grouping-aggregators.md b/docs/grid-functionalities/grouping-aggregators.md index 60179aff6..2215180ef 100644 --- a/docs/grid-functionalities/grouping-aggregators.md +++ b/docs/grid-functionalities/grouping-aggregators.md @@ -2,6 +2,7 @@ - [Demo](#demo) - [Description](#description) - [Setup](#setup) +- [Draggable Dropzone Location](#draggable-dropzone-location) - [Aggregators](#aggregators) - [SortComparers](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/sortComparers/sortComparers.index.ts) - [GroupTotalsFormatter](#group-totals-formatter) @@ -28,6 +29,43 @@ The important thing to understand while working with `SlickGrid` is that Groupin 2. You need to add a `groupTotalsFormatter` on the column definition you want it to be calculated - this is very similar to a Formatter, except that they are designed to show aggregate results, e.g:: `Total: 142.50$` +### Draggable Dropzone Location + +The Draggable Grouping can be located in either the Top-Header or the Pre-Header as described below. + +#### Pre-Heaader +Draggable Grouping can be located in either the Pre-Header of the Top-Header, however when it is located in the Pre-Header then the Header Grouping will not be available (because both of them would conflict with each other). Note that prior to the version 5.1 of Slickgrid-Universal, the Pre-Header was the default and only available option. + +```ts +this.gridOptions = { + createPreHeaderPanel: true, + showPreHeaderPanel: true, + preHeaderPanelHeight: 26, + draggableGrouping: { + // ... any draggable plugin option + }, +} +``` + +#### Top-Heaader +##### requires v5.1 and higher +This is the preferred section since the Top-Header is on top of all headers (including pre-header) and it will always be the full grid width. Using the Top-Header also frees up the Pre-Header section for the potential use of Header Grouping. + +When using Draggable Grouping and Header Grouping together, you need to enable both top-header and pre-header. +```ts +this.gridOptions = { + // we'll use top-header for the Draggable Grouping + createTopHeaderPanel: true, + showTopHeaderPanel: true, + topHeaderPanelHeight: 35, + + // pre-header will include our Header Grouping (i.e. "Common Factor") + createPreHeaderPanel: true, + showPreHeaderPanel: true, + preHeaderPanelHeight: 26, +} +``` + ### Aggregators The `Aggregators` is basically the accumulator, the logic that will do the sum (or any other aggregate we defined). We simply need to instantiate the `Aggregator` by passing the column definition `field` that will be used to accumulate. For example, if we have a column definition of Cost and we want to calculate it's sum, we can call the `Aggregator` as follow ```ts diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example03.ts b/examples/vite-demo-vanilla-bundle/src/examples/example03.ts index 693b12312..7caf7a1df 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example03.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example03.ts @@ -77,7 +77,8 @@ export default class Example03 { initializeGrid() { this.columnDefinitions = [ { - id: 'title', name: 'Title', field: 'title', sortable: true, type: FieldType.string, + id: 'title', name: 'Title', field: 'title', columnGroup: 'Common Factor', + sortable: true, type: FieldType.string, editor: { model: Editors.longText, required: true, @@ -95,7 +96,8 @@ export default class Example03 { } }, { - id: 'duration', name: 'Duration', field: 'duration', sortable: true, filterable: true, + id: 'duration', name: 'Duration', field: 'duration', columnGroup: 'Common Factor', + sortable: true, filterable: true, editor: { model: Editors.float, // required: true, @@ -118,7 +120,45 @@ export default class Example03 { } }, { - id: 'cost', name: 'Cost', field: 'cost', + id: 'start', name: 'Start', field: 'start', sortable: true, columnGroup: 'Period', + // formatter: Formatters.dateIso, + type: FieldType.date, outputType: FieldType.dateIso, + filterable: true, filter: { model: Filters.compoundDate }, + formatter: Formatters.dateIso, + editor: { model: Editors.date }, + grouping: { + getter: 'start', + formatter: (g) => `Start: ${g.value} (${g.count} items)`, + aggregators: [ + new Aggregators.Sum('cost') + ], + aggregateCollapsed: false, + collapsed: false + } + }, + { + id: 'finish', name: 'Finish', field: 'finish', columnGroup: 'Period', + sortable: true, + editor: { + model: Editors.date, + editorOptions: { range: { min: 'today' } } as VanillaCalendarOption + }, + // formatter: Formatters.dateIso, + type: FieldType.date, outputType: FieldType.dateIso, + formatter: Formatters.dateIso, + filterable: true, filter: { model: Filters.dateRange }, + grouping: { + getter: 'finish', + formatter: (g) => `Finish: ${g.value} (${g.count} items)`, + aggregators: [ + new Aggregators.Sum('cost') + ], + aggregateCollapsed: false, + collapsed: false + } + }, + { + id: 'cost', name: 'Cost', field: 'cost', columnGroup: 'Analysis', width: 90, sortable: true, filterable: true, @@ -138,7 +178,8 @@ export default class Example03 { } }, { - id: 'percentComplete', name: '% Complete', field: 'percentComplete', type: FieldType.number, + id: 'percentComplete', name: '% Complete', field: 'percentComplete', columnGroup: 'Analysis', + type: FieldType.number, editor: { model: Editors.slider, minValue: 0, @@ -160,44 +201,7 @@ export default class Example03 { params: { groupFormatterPrefix: 'Avg: ' }, }, { - id: 'start', name: 'Start', field: 'start', sortable: true, - // formatter: Formatters.dateIso, - type: FieldType.date, outputType: FieldType.dateIso, - filterable: true, filter: { model: Filters.compoundDate }, - formatter: Formatters.dateIso, - editor: { model: Editors.date }, - grouping: { - getter: 'start', - formatter: (g) => `Start: ${g.value} (${g.count} items)`, - aggregators: [ - new Aggregators.Sum('cost') - ], - aggregateCollapsed: false, - collapsed: false - } - }, - { - id: 'finish', name: 'Finish', field: 'finish', sortable: true, - editor: { - model: Editors.date, - editorOptions: { range: { min: 'today' } } as VanillaCalendarOption - }, - // formatter: Formatters.dateIso, - type: FieldType.date, outputType: FieldType.dateIso, - formatter: Formatters.dateIso, - filterable: true, filter: { model: Filters.dateRange }, - grouping: { - getter: 'finish', - formatter: (g) => `Finish: ${g.value} (${g.count} items)`, - aggregators: [ - new Aggregators.Sum('cost') - ], - aggregateCollapsed: false, - collapsed: false - } - }, - { - id: 'effortDriven', name: 'Effort-Driven', field: 'effortDriven', + id: 'effortDriven', name: 'Effort-Driven', field: 'effortDriven', columnGroup: 'Analysis', width: 80, minWidth: 20, maxWidth: 100, cssClass: 'cell-effort-driven', sortable: true, @@ -312,9 +316,18 @@ export default class Example03 { selectActiveRow: false }, showCustomFooter: true, + + // pre-header will include our Header Grouping (i.e. "Common Factor") + // Draggable Grouping could be located in either the Pre-Header OR the new Top-Header createPreHeaderPanel: true, showPreHeaderPanel: true, - preHeaderPanelHeight: 35, + preHeaderPanelHeight: 26, + + // when Top-Header is created, it will be used by the Draggable Grouping (otherwise the Pre-Header will be used) + createTopHeaderPanel: true, + showTopHeaderPanel: true, + topHeaderPanelHeight: 35, + rowHeight: 33, headerRowHeight: 35, enableDraggableGrouping: true, @@ -425,7 +438,7 @@ export default class Example03 { groupByDuration() { this.clearGrouping(); if (this.draggableGroupingPlugin?.setDroppedGroups) { - this.showPreHeader(); + this.showTopHeader(); this.draggableGroupingPlugin.setDroppedGroups('duration'); this.sgb?.slickGrid?.invalidate(); // invalidate all rows and re-render } @@ -445,14 +458,14 @@ export default class Example03 { groupByDurationEffortDriven() { this.clearGrouping(); if (this.draggableGroupingPlugin?.setDroppedGroups) { - this.showPreHeader(); + this.showTopHeader(); this.draggableGroupingPlugin.setDroppedGroups(['duration', 'effortDriven']); this.sgb?.slickGrid?.invalidate(); // invalidate all rows and re-render } } - showPreHeader() { - this.sgb?.slickGrid?.setPreHeaderPanelVisibility(true); + showTopHeader() { + this.sgb?.slickGrid?.setTopHeaderPanelVisibility(true); } toggleDarkMode() { @@ -469,7 +482,7 @@ export default class Example03 { toggleDraggableGroupingRow() { this.clearGroupsAndSelects(); - this.sgb?.slickGrid?.setPreHeaderPanelVisibility(!this.sgb?.slickGrid?.getOptions().showPreHeaderPanel); + this.sgb?.slickGrid?.setTopHeaderPanelVisibility(!this.sgb?.slickGrid?.getOptions().showTopHeaderPanel); } onGroupChanged(change: { caller?: string; groupColumns: Grouping[]; }) { diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 7b59a9293..a10225b07 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -18,7 +18,6 @@ const pubSubServiceStub = { } as BasePubSubService; const DEFAULT_COLUMN_HEIGHT = 25; -const DEFAULT_COLUMN_MIN_WIDTH = 30; const DEFAULT_COLUMN_WIDTH = 80; const DEFAULT_GRID_HEIGHT = 600; const DEFAULT_GRID_WIDTH = 800; @@ -454,6 +453,57 @@ describe('SlickGrid core file', () => { }); }); + describe('Top-Header Panel', () => { + it('should create a topheader panel when enabled', () => { + const paneHeight = 25; + const topHeaderPanelHeight = 30; + const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; + const gridOptions = { ...defaultOptions, enableCellNavigation: true, topHeaderPanelHeight, showTopHeaderPanel: true, frozenColumn: 0, createTopHeaderPanel: true } as GridOption; + grid = new SlickGrid(container, [], columns, gridOptions); + grid.init(); + const topheaderElm = container.querySelector('.slick-topheader-panel'); + const topheaderElms = container.querySelectorAll('.slick-topheader-panel'); + + expect(grid).toBeTruthy(); + expect(topheaderElm).toBeTruthy(); + expect(topheaderElm?.querySelectorAll('div').length).toBe(3); + expect(topheaderElms[0].style.display).not.toBe('none'); + expect(grid.getTopHeaderPanel()).toBeTruthy(); + expect(grid.getTopHeaderPanel()).toEqual(grid.getTopHeaderPanel()); + + const paneHeaderLeftElms = container.querySelectorAll('.slick-pane-header'); + jest.spyOn(paneHeaderLeftElms[0], 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0, height: paneHeight } as DOMRect); + jest.spyOn(paneHeaderLeftElms[1], 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0, height: paneHeight } as DOMRect); + + // calling resize should add top offset of pane + topHeader + grid.resizeCanvas(); + + const paneTopLeftElm = container.querySelector('.slick-pane-top.slick-pane-left') as HTMLDivElement; + + expect(paneTopLeftElm.style.top).toBe(`${paneHeight + topHeaderPanelHeight}px`); + }); + + it('should hide column headers div when "showTopHeaderPanel" is disabled', () => { + const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; + const gridOptions = { ...defaultOptions, enableCellNavigation: true, topHeaderPanelHeight: 30, showTopHeaderPanel: false, createTopHeaderPanel: true } as GridOption; + grid = new SlickGrid(container, [], columns, gridOptions); + grid.init(); + let topheaderElms = container.querySelectorAll('.slick-topheader-panel'); + + expect(grid).toBeTruthy(); + expect(topheaderElms).toBeTruthy(); + expect(topheaderElms[0].style.display).toBe('none'); + + grid.setTopHeaderPanelVisibility(true); + topheaderElms = container.querySelectorAll('.slick-topheader-panel'); + expect(topheaderElms[0].style.display).not.toBe('none'); + + grid.setTopHeaderPanelVisibility(false); + topheaderElms = container.querySelectorAll('.slick-topheader-panel'); + expect(topheaderElms[0].style.display).toBe('none'); + }); + }); + describe('Headers', () => { it('should show column headers div by default', () => { const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; @@ -3304,6 +3354,39 @@ describe('SlickGrid core file', () => { expect(viewportElm.scrollLeft).toBe(0); }); + it('should scroll all elements shown when triggered by mousewheel and topHeader is enabled', () => { + const dv = new SlickDataView(); + dv.setItems(data); + grid = new SlickGrid(container, dv, columns, { + ...defaultOptions, enableMouseWheelScrollHandler: true, + createTopHeaderPanel: true, + }); + grid.setOptions({ enableMouseWheelScrollHandler: false }); + grid.setOptions({ enableMouseWheelScrollHandler: true }); + grid.scrollCellIntoView(1, 2, true); + + const mouseEvent = new Event('mousewheel'); + const mousePreventSpy = jest.spyOn(mouseEvent, 'preventDefault'); + const onViewportChangedSpy = jest.spyOn(grid.onViewportChanged, 'notify'); + let viewportTopLeftElm = container.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; + Object.defineProperty(viewportTopLeftElm, 'scrollHeight', { writable: true, value: DEFAULT_GRID_HEIGHT }); + Object.defineProperty(viewportTopLeftElm, 'scrollWidth', { writable: true, value: DEFAULT_GRID_WIDTH }); + Object.defineProperty(viewportTopLeftElm, 'clientHeight', { writable: true, value: 125 }); + Object.defineProperty(viewportTopLeftElm, 'clientWidth', { writable: true, value: 75 }); + + let viewportLeftElm = container.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; + let topHeaderElm = container.querySelector('.slick-topheader-panel') as HTMLDivElement; + Object.defineProperty(viewportLeftElm, 'scrollLeft', { writable: true, value: 88 }); + viewportLeftElm.dispatchEvent(mouseEvent); + + expect(topHeaderElm.scrollLeft).toBe(88); + expect(viewportLeftElm.scrollLeft).toBe(88); + expect(viewportLeftElm.scrollTop).toBe(25); + expect(viewportTopLeftElm.scrollTop).toBe(25); + expect(onViewportChangedSpy).toHaveBeenCalled(); + expect(mousePreventSpy).toHaveBeenCalled(); + }); + it('should scroll all elements shown when triggered by mousewheel and preHeader/footer are enabled and without any Frozen rows/columns', () => { const dv = new SlickDataView(); dv.setItems(data); @@ -5216,7 +5299,6 @@ describe('SlickGrid core file', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, createPreHeaderPanel: true, preHeaderPanelHeight: 44, showPreHeaderPanel: true, enableCellNavigation: true }); const preheaderElm = container.querySelector('.slick-preheader-panel') as HTMLDivElement; - const preheaderElms = container.querySelectorAll('.slick-preheader-panel'); Object.defineProperty(preheaderElm, 'scrollLeft', { writable: true, value: 25 }); preheaderElm.dispatchEvent(new CustomEvent('scroll')); @@ -5230,6 +5312,23 @@ describe('SlickGrid core file', () => { viewportTopLeft.dispatchEvent(selectStartEvent); }); + it('should update viewport top/left scrollLeft when scrolling in topHeader DOM element', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, createTopHeaderPanel: true, topHeaderPanelHeight: 44, showTopHeaderPanel: true, enableCellNavigation: true }); + const topheaderElm = container.querySelector('.slick-topheader-panel') as HTMLDivElement; + Object.defineProperty(topheaderElm, 'scrollLeft', { writable: true, value: 25 }); + + topheaderElm.dispatchEvent(new CustomEvent('scroll')); + + const viewportTopLeft = container.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; + expect(viewportTopLeft.scrollLeft).toBe(25); + + // when enableTextSelectionOnCells isn't enabled and trigger IE related code + const selectStartEvent = new CustomEvent('selectstart'); + Object.defineProperty(selectStartEvent, 'target', { writable: true, value: document.createElement('TextArea') }); + viewportTopLeft.dispatchEvent(selectStartEvent); + }); + it('should NOT trigger onHeaderRowMouseEnter notify when hovering a header when "slick-headerrow-column" class is not found', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age' }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true }); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 08f387c3c..6f44d47ac 100644 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -229,9 +229,13 @@ export class SlickGrid = Column, O e showFooterRow: false, footerRowHeight: 25, createPreHeaderPanel: false, + createTopHeaderPanel: false, showPreHeaderPanel: false, + showTopHeaderPanel: false, preHeaderPanelHeight: 25, preHeaderPanelWidth: 'auto', // mostly useful for Draggable Grouping dropzone to take full width + topHeaderPanelHeight: 25, + topHeaderPanelWidth: 'auto', // mostly useful for Draggable Grouping dropzone to take full width showTopPanel: false, topPanelHeight: 25, formatterFactory: null, @@ -327,6 +331,9 @@ export class SlickGrid = Column, O e protected _preHeaderPanelR!: HTMLDivElement; protected _preHeaderPanelScrollerR!: HTMLDivElement; protected _preHeaderPanelSpacerR!: HTMLDivElement; + protected _topHeaderPanel!: HTMLDivElement; + protected _topHeaderPanelScroller!: HTMLDivElement; + protected _topHeaderPanelSpacer!: HTMLDivElement; protected _topPanelScrollers!: HTMLDivElement[]; protected _topPanels!: HTMLDivElement[]; protected _viewport!: HTMLDivElement[]; @@ -650,6 +657,17 @@ export class SlickGrid = Column, O e this._focusSink = createDomElement('div', { tabIndex: 0, style: { position: 'fixed', width: '0px', height: '0px', top: '0px', left: '0px', outline: '0px' } }, this._container); + if (this._options.createTopHeaderPanel) { + this._topHeaderPanelScroller = createDomElement('div', { className: 'slick-topheader-panel slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this._container); + this._topHeaderPanelScroller.appendChild(document.createElement('div')); + this._topHeaderPanel = createDomElement('div', null, this._topHeaderPanelScroller); + this._topHeaderPanelSpacer = createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._topHeaderPanelScroller); + + if (!this._options.showTopHeaderPanel) { + Utils.hide(this._topHeaderPanelScroller); + } + } + // Containers used for scrolling frozen columns and rows this._paneHeaderL = createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, this._container); this._paneHeaderR = createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, this._container); @@ -763,6 +781,11 @@ export class SlickGrid = Column, O e // Default the active canvas to the top left this._activeCanvasNode = this._canvasTopL; + // top-header + if (this._topHeaderPanelSpacer) { + Utils.width(this._topHeaderPanelSpacer, this.getCanvasWidth() + this.scrollbarDimensions.width); + } + // pre-header if (this._preHeaderPanelSpacer) { Utils.width(this._preHeaderPanelSpacer, this.getCanvasWidth() + this.scrollbarDimensions.width); @@ -884,6 +907,10 @@ export class SlickGrid = Column, O e }); } + if (this._options.createTopHeaderPanel) { + this._bindingEventService.bind(this._topHeaderPanelScroller, 'scroll', this.handleTopHeaderPanelScroll.bind(this) as EventListener); + } + if (this._options.createPreHeaderPanel) { this._bindingEventService.bind(this._preHeaderPanelScroller, 'scroll', this.handlePreHeaderPanelScroll.bind(this) as EventListener); } @@ -1163,6 +1190,10 @@ export class SlickGrid = Column, O e const oldCanvasWidthR = this.canvasWidthR; this.canvasWidth = this.getCanvasWidth(); + if (this._options.createTopHeaderPanel) { + Utils.width(this._topHeaderPanel, this._options.topHeaderPanelWidth ?? this.canvasWidth); + } + const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR; if (widthChanged || this.hasFrozenColumns() || this.hasFrozenRows) { @@ -1433,6 +1464,11 @@ export class SlickGrid = Column, O e return this._preHeaderPanelR; } + /** Get the Top-Header Panel DOM node element */ + getTopHeaderPanel() { + return this._topHeaderPanel; + } + /** * Get Header Row Column DOM element by its column Id or index * @param {Number|String} columnIdOrIdx - column Id or index @@ -2353,6 +2389,7 @@ export class SlickGrid = Column, O e `.${this.uid} .slick-header-column { left: 1000px; }`, `.${this.uid} .slick-top-panel { height: ${this._options.topPanelHeight}px; }`, `.${this.uid} .slick-preheader-panel { height: ${this._options.preHeaderPanelHeight}px; }`, + `.${this.uid} .slick-topheader-panel { height: ${this._options.topHeaderPanelHeight}px; }`, `.${this.uid} .slick-headerrow-columns { height: ${this._options.headerRowHeight}px; }`, `.${this.uid} .slick-footerrow-columns { height: ${this._options.footerRowHeight}px; }`, `.${this.uid} .slick-cell { height: ${rowHeight}px; }`, @@ -2529,6 +2566,10 @@ export class SlickGrid = Column, O e this._bindingEventService.unbindByEventName(this._preHeaderPanelScroller, 'scroll'); } + if (this._topHeaderPanelScroller) { + this._bindingEventService.unbindByEventName(this._topHeaderPanelScroller, 'scroll'); + } + this._bindingEventService.unbindByEventName(this._focusSink, 'keydown'); this._bindingEventService.unbindByEventName(this._focusSink2, 'keydown'); @@ -3130,7 +3171,7 @@ export class SlickGrid = Column, O e return !Array.isArray(this.data); } - protected togglePanelVisibility(option: 'showTopPanel' | 'showHeaderRow' | 'showColumnHeader' | 'showFooterRow' | 'showPreHeaderPanel', container: HTMLElement | HTMLElement[], visible?: boolean) { + protected togglePanelVisibility(option: 'showTopPanel' | 'showHeaderRow' | 'showColumnHeader' | 'showFooterRow' | 'showPreHeaderPanel' | 'showTopHeaderPanel', container: HTMLElement | HTMLElement[], visible?: boolean) { if (this._options[option] !== visible) { this._options[option] = visible as boolean; if (visible) { @@ -3182,6 +3223,14 @@ export class SlickGrid = Column, O e this.togglePanelVisibility('showPreHeaderPanel', [this._preHeaderPanelScroller, this._preHeaderPanelScrollerR], visible); } + /** + * Set the Top-Header Visibility + * @param {Boolean} [visible] - optionally set if top-header panel is visible or not + */ + setTopHeaderPanelVisibility(visible?: boolean) { + this.togglePanelVisibility('showTopHeaderPanel', this._topHeaderPanelScroller, visible); + } + /** Get Grid Canvas Node DOM Element */ getContainerNode() { return this._container; @@ -3683,6 +3732,7 @@ export class SlickGrid = Column, O e } else { const columnNamesH = (this._options.showColumnHeader) ? Utils.toFloat(Utils.height(this._headerScroller[0]) as number) + this.getVBoxDelta(this._headerScroller[0]) : 0; const preHeaderH = (this._options.createPreHeaderPanel && this._options.showPreHeaderPanel) ? this._options.preHeaderPanelHeight! + this.getVBoxDelta(this._preHeaderPanelScroller) : 0; + const topHeaderH = (this._options.createTopHeaderPanel && this._options.showTopHeaderPanel) ? this._options.topHeaderPanelHeight! + this.getVBoxDelta(this._topHeaderPanelScroller) : 0; const style = getComputedStyle(this._container); this.viewportH = Utils.toFloat(style.height) @@ -3692,7 +3742,8 @@ export class SlickGrid = Column, O e - this.topPanelH - this.headerRowH - this.footerRowH - - preHeaderH; + - preHeaderH + - topHeaderH; } this.numVisibleRows = Math.ceil(this.viewportH / this._options.rowHeight!); @@ -3747,7 +3798,13 @@ export class SlickGrid = Column, O e this._paneTopL.style.position = 'relative'; } - Utils.setStyleSize(this._paneTopL, 'top', Utils.height(this._paneHeaderL) || (this._options.showHeaderRow ? this._options.headerRowHeight! : 0) + (this._options.showPreHeaderPanel ? this._options.preHeaderPanelHeight! : 0)); + let topHeightOffset = Utils.height(this._paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (this._options.showTopHeaderPanel ? this._options.topHeaderPanelHeight! : 0); + } else { + topHeightOffset = (this._options.showHeaderRow ? this._options.headerRowHeight! : 0) + (this._options.showPreHeaderPanel ? this._options.preHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this._paneTopL, 'top', topHeightOffset || topHeightOffset); Utils.height(this._paneTopL, this.paneTopH); const paneBottomTop = this._paneTopL.offsetTop + this.paneTopH; @@ -3757,7 +3814,11 @@ export class SlickGrid = Column, O e } if (this.hasFrozenColumns()) { - Utils.setStyleSize(this._paneTopR, 'top', Utils.height(this._paneHeaderL) as number); + let topHeightOffset = Utils.height(this._paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (this._options.showTopHeaderPanel ? this._options.topHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this._paneTopR, 'top', topHeightOffset as number); Utils.height(this._paneTopR, this.paneTopH); Utils.height(this._viewportTopR, this.viewportTopH); @@ -4330,6 +4391,10 @@ export class SlickGrid = Column, O e this.handleElementScroll(this._preHeaderPanelScroller); } + protected handleTopHeaderPanelScroll() { + this.handleElementScroll(this._topHeaderPanelScroller); + } + protected handleElementScroll(element: HTMLElement) { const scrollLeft = element.scrollLeft; if (scrollLeft !== this._viewportScrollContainerX.scrollLeft) { @@ -4380,6 +4445,9 @@ export class SlickGrid = Column, O e this._preHeaderPanelScroller.scrollLeft = this.scrollLeft; } } + if (this._options.createTopHeaderPanel) { + this._topHeaderPanelScroller.scrollLeft = this.scrollLeft; + } if (this.hasFrozenColumns()) { if (this.hasFrozenRows) { diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index e1b30c2ca..15a347e2a 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -79,6 +79,7 @@ const gridStub = { getHeaderColumn: jest.fn(), getOptions: jest.fn(), getPreHeaderPanel: jest.fn(), + getTopHeaderPanel: jest.fn(), getData: () => dataViewStub, getEditorLock: () => getEditorLockMock, getUID: () => GRID_UID, diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 68753d155..a00408dd7 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -25,7 +25,7 @@ import { type SlickDataView, SlickEvent, SlickEventData, SlickEventHandler, type * github.com/muthukumarse/Slickgrid * * NOTES: - * This plugin provides the Draggable Grouping feature + * This plugin provides the Draggable Grouping feature which could be located in either the Top-Header or the Pre-Header * * A plugin to add drop-down menus to column headers. * To specify a custom button in a column header, extend the column definition like so: @@ -131,7 +131,7 @@ export class SlickDraggableGrouping { if (grid) { this._gridUid = grid.getUID(); this._gridColumns = grid.getColumns(); - this._dropzoneElm = grid.getPreHeaderPanel(); + this._dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); this._dropzoneElm.classList.add('slick-dropzone'); // add PubSub instance to all SlickEvent @@ -216,7 +216,7 @@ export class SlickDraggableGrouping { this._eventHandler.unsubscribeAll(); this.pubSubService.unsubscribeAll(this._subscriptions); this._bindingEventService.unbindAll(); - emptyElement(this.gridContainer.querySelector(`.${this.gridUid} .slick-preheader-panel`)); + emptyElement(this.gridContainer.querySelector(`.${this.gridUid} .slick-preheader-panel,.${this.gridUid} .slick-topheader-panel`)); } clearDroppedGroups() { @@ -277,7 +277,7 @@ export class SlickDraggableGrouping { */ setupColumnReorder(grid: SlickGrid, headers: any, _headerColumnWidthDiff: any, setColumns: (columns: Column[]) => void, setupColumnResize: () => void, _columns: Column[], getColumnIndex: (columnId: string) => number, _uid: string, trigger: (slickEvent: SlickEvent, data?: any) => void) { this.destroySortableInstances(); - const dropzoneElm = grid.getPreHeaderPanel(); + const dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); const groupTogglerElm = dropzoneElm.querySelector('.slick-group-toggle-all'); diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 18aa90ca5..ac428eab2 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -208,12 +208,15 @@ export interface GridOption { /** Context menu options (mouse right+click) */ contextMenu?: ContextMenu; - /** Defaults to false, which leads to create the footer row of the grid */ + /** Defaults to false, which leads to creating the footer row of the grid */ createFooterRow?: boolean; - /** Default to false, which leads to create an extra pre-header panel (on top of column header) for column grouping purposes */ + /** Default to false, which leads to creating an extra pre-header panel (on top of column header) for column grouping purposes */ createPreHeaderPanel?: boolean; + /** Default to false, which leads to creating an extra top-header panel (on top of column header & pre-header) for column grouping purposes */ + createTopHeaderPanel?: boolean; + /** Custom Footer Options */ customFooterOptions?: CustomFooterOption; @@ -659,6 +662,12 @@ export interface GridOption { /** Defaults to "auto", extra pre-header panel (on top of column header) width, it could be a number (pixels) or a string ("100%" or "auto") */ preHeaderPanelWidth?: number | string; + /** Extra top-header panel height (on top of column header & pre-header) */ + topHeaderPanelHeight?: number; + + /** Defaults to "auto", extra top-header panel (on top of column header & pre-header) width, it could be a number (pixels) or a string ("100%" or "auto") */ + topHeaderPanelWidth?: number | string; + /** Do we want to preserve copied selection on paste? */ preserveCopiedSelectionOnPaste?: boolean; @@ -756,6 +765,9 @@ export interface GridOption { /** Do we want to show the extra pre-header panel (on top of column header) for column grouping purposes */ showPreHeaderPanel?: boolean; + /** Do we want to show the extra top-header panel (on top of column header & pre-header) for column grouping purposes */ + showTopHeaderPanel?: boolean; + /** Do we want to show top panel row? */ showTopPanel?: boolean; diff --git a/packages/common/src/services/groupingAndColspan.service.ts b/packages/common/src/services/groupingAndColspan.service.ts index 37bcdf50d..0cd9c277b 100644 --- a/packages/common/src/services/groupingAndColspan.service.ts +++ b/packages/common/src/services/groupingAndColspan.service.ts @@ -100,18 +100,19 @@ export class GroupingAndColspanService { /** Create or Render the Pre-Header Row Grouping Titles */ renderPreHeaderRowGroupingTitles() { - if (this._gridOptions && this._gridOptions.frozenColumn !== undefined && this._gridOptions.frozenColumn >= 0) { + const colsCount = this._columnDefinitions.length; + + if (this._gridOptions?.frozenColumn !== undefined && this._gridOptions.frozenColumn >= 0) { + const frozenCol = this._gridOptions.frozenColumn; + // Add column groups to left panel - let preHeaderPanelElm = this._grid.getPreHeaderPanelLeft(); - this.renderHeaderGroups(preHeaderPanelElm, 0, this._gridOptions.frozenColumn + 1); + this.renderHeaderGroups(this._grid.getPreHeaderPanelLeft(), 0, frozenCol + 1); // Add column groups to right panel - preHeaderPanelElm = this._grid.getPreHeaderPanelRight(); - this.renderHeaderGroups(preHeaderPanelElm, this._gridOptions?.frozenColumn + 1, this._columnDefinitions.length); + this.renderHeaderGroups(this._grid.getPreHeaderPanelRight(), frozenCol + 1, colsCount); } else { // regular grid (not a frozen grid) - const preHeaderPanelElm = this._grid.getPreHeaderPanel(); - this.renderHeaderGroups(preHeaderPanelElm, 0, this._columnDefinitions.length); + this.renderHeaderGroups(this._grid.getPreHeaderPanel(), 0, colsCount); } } diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index 13fc63c2e..35f5d5f9c 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -697,7 +697,8 @@ } /** Header Grouping **/ - .slick-preheader-panel.slick-state-default { + .slick-preheader-panel.slick-state-default, + .slick-topheader-panel.slick-state-default { border-bottom: var(--slick-preheader-border-bottom, $slick-preheader-border-bottom); .slick-header-columns { @@ -730,7 +731,8 @@ border-right: var(--slick-frozen-header-row-border-right, $slick-frozen-header-row-border-right); } .slick-pane-left { - .slick-preheader-panel .slick-header-column.frozen:last-child { + .slick-preheader-panel .slick-header-column.frozen:last-child, + .slick-topheader-panel .slick-header-column.frozen:last-child { border-right: var(--slick-frozen-preheader-row-border-right, $slick-frozen-preheader-row-border-right); } } diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 37ecaf66e..57d7bf6f3 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -893,7 +893,8 @@ input.search-filter { // Draggable Grouping Plugin // ---------------------------------------------- -.slick-preheader-panel { +.slick-preheader-panel, +.slick-topheader-panel { .slick-dropzone, .slick-dropzone-hover { display: flex; align-items: center; diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index 0b551a5b1..e520e17c0 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -1006,15 +1006,6 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () expect(spy).toHaveBeenCalledWith(mockGrid, container); }); - it('should not initialize groupingAndColspanService when "createPreHeaderPanel" grid option is enabled and "enableDraggableGrouping" is also enabled', () => { - const spy = jest.spyOn(groupingAndColspanServiceStub, 'init'); - - component.gridOptions = { createPreHeaderPanel: true, enableDraggableGrouping: true } as unknown as GridOption; - component.initialization(divContainer, slickEventHandler); - - expect(spy).not.toHaveBeenCalled(); - }); - it('should call "translateColumnHeaders" from ExtensionService when "enableTranslate" is set', () => { const spy = jest.spyOn(extensionServiceStub, 'translateColumnHeaders'); 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 3cf8e80ee..7a3af613c 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -761,7 +761,7 @@ export class SlickVanillaGridBundle { this._eventPubSubService.subscribe('onLanguageChange', (args: { language: string; }) => { if (gridOptions.enableTranslate) { this.extensionService.translateAllExtensions(args.language); - if (gridOptions.createPreHeaderPanel && !gridOptions.enableDraggableGrouping) { + if ((gridOptions.createPreHeaderPanel && gridOptions.createTopHeaderPanel) || (gridOptions.createPreHeaderPanel && !gridOptions.enableDraggableGrouping)) { this.groupingService.translateGroupingAndColSpan(); } } @@ -1378,7 +1378,7 @@ export class SlickVanillaGridBundle { this._registeredResources.push(this.gridService, this.gridStateService); // when using Grouping/DraggableGrouping/Colspan register its Service - if (this.gridOptions.createPreHeaderPanel && !this.gridOptions.enableDraggableGrouping) { + if ((this.gridOptions.createPreHeaderPanel && this.gridOptions.createTopHeaderPanel) || (this.gridOptions.createPreHeaderPanel && !this.gridOptions.enableDraggableGrouping)) { this._registeredResources.push(this.groupingService); } diff --git a/packages/vanilla-force-bundle/src/__tests__/vanilla-force-bundle.spec.ts b/packages/vanilla-force-bundle/src/__tests__/vanilla-force-bundle.spec.ts index 5bca1f2d6..e815aa561 100644 --- a/packages/vanilla-force-bundle/src/__tests__/vanilla-force-bundle.spec.ts +++ b/packages/vanilla-force-bundle/src/__tests__/vanilla-force-bundle.spec.ts @@ -469,15 +469,6 @@ describe('Vanilla-Force-Grid-Bundle Component instantiated via Constructor', () expect(spy).toHaveBeenCalledWith(mockGrid, container); }); - it('should not initialize groupingAndColspanService when "createPreHeaderPanel" grid option is enabled and "enableDraggableGrouping" is also enabled', () => { - const spy = jest.spyOn(groupingAndColspanServiceStub, 'init'); - - component.gridOptions = { createPreHeaderPanel: true, enableDraggableGrouping: true } as unknown as GridOption; - component.initialization(divContainer, slickEventHandler); - - expect(spy).not.toHaveBeenCalled(); - }); - it('should call "translateColumnHeaders" from ExtensionService when "enableTranslate" is set', () => { const spy = jest.spyOn(extensionServiceStub, 'translateColumnHeaders'); diff --git a/packages/vanilla-force-bundle/src/vanilla-force-bundle.ts b/packages/vanilla-force-bundle/src/vanilla-force-bundle.ts index 39b425773..7ff179e08 100644 --- a/packages/vanilla-force-bundle/src/vanilla-force-bundle.ts +++ b/packages/vanilla-force-bundle/src/vanilla-force-bundle.ts @@ -126,7 +126,7 @@ export class VanillaForceGridBundle extends SlickVanillaGridBundle { this._registeredResources.push(this.gridService, this.gridStateService); // when using Grouping/DraggableGrouping/Colspan register its Service - if (this.gridOptions.createPreHeaderPanel && !this.gridOptions.enableDraggableGrouping) { + if ((this.gridOptions.createPreHeaderPanel && this.gridOptions.createTopHeaderPanel) || (this.gridOptions.createPreHeaderPanel && !this.gridOptions.enableDraggableGrouping)) { this._registeredResources.push(this.groupingService); } diff --git a/test/cypress/e2e/example02.cy.ts b/test/cypress/e2e/example02.cy.ts index 0009efa56..a0d3b29fa 100644 --- a/test/cypress/e2e/example02.cy.ts +++ b/test/cypress/e2e/example02.cy.ts @@ -201,7 +201,6 @@ describe('Example 02 - Grouping & Aggregators', () => { cy.get('.item-count') .should('contain', 5000); - cy.get('.search-filter.filter-title') .clear() .type('Ta*33'); diff --git a/test/cypress/e2e/example03.cy.ts b/test/cypress/e2e/example03.cy.ts index 7a0cdddb1..8f3c7f87f 100644 --- a/test/cypress/e2e/example03.cy.ts +++ b/test/cypress/e2e/example03.cy.ts @@ -1,5 +1,6 @@ describe('Example 03 - Draggable Grouping', () => { - const fullTitles = ['', 'Title', 'Duration', 'Cost', '% Complete', 'Start', 'Finish', 'Effort-Driven', 'Action']; + const preHeaders = ['', 'Common Factor', 'Period', 'Analysis', '']; + const fullTitles = ['', 'Title', 'Duration', 'Start', 'Finish', 'Cost', '% Complete', 'Effort-Driven', 'Action']; const GRID_ROW_HEIGHT = 33; it('should display Example title', () => { @@ -8,13 +9,26 @@ describe('Example 03 - Draggable Grouping', () => { cy.get('h3 span.subtitle').should('contain', '(with Salesforce Theme)'); }); - it('should have exact column titles on 1st grid', () => { + it('should have exact column (pre-header) grouping titles in grid', () => { cy.get('.grid3') - .find('.slick-header-columns') + .find('.slick-preheader-panel .slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(preHeaders[index])); + }); + + it('should have exact column titles in grid', () => { + cy.get('.grid3') + .find('.slick-header:not(.slick-preheader-panel) .slick-header-columns') .children() .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); + it('should have a draggable dropzone on top of the grid in the top-header section', () => { + cy.get('.grid3') + .find('.slick-topheader-panel .slick-dropzone:visible') + .contains('Drop a column header here to group by the column'); + }); + it('should open the Cell Menu on 2nd and 3rd row and change the Effort-Driven to "True" and expect the cell to be updated and have checkmark to be enabled', () => { cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'Task 1'); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(8)`).find('.checkmark-icon').should('have.length', 0); @@ -51,7 +65,7 @@ describe('Example 03 - Draggable Grouping', () => { }); it('should collapse all rows and make sure Duration group is sorted in descending order', () => { - cy.get('.slick-preheader-panel .slick-group-toggle-all').click(); + cy.get('.slick-topheader-panel .slick-group-toggle-all').click(); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`).should('have.length', 1); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 100'); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 99'); @@ -64,7 +78,7 @@ describe('Example 03 - Draggable Grouping', () => { }); it('should collapse all rows again and make sure Duration group is sorted in descending order', () => { - cy.get('.slick-preheader-panel .slick-group-toggle-all').click(); + cy.get('.slick-topheader-panel .slick-group-toggle-all').click(); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`).should('have.length', 1); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 0'); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 1'); @@ -125,8 +139,8 @@ describe('Example 03 - Draggable Grouping', () => { cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(2)`).should('contain', '0'); }); - it('should use the preheader Toggle All button and expect all groups to now be collapsed', () => { - cy.get('.slick-preheader-panel .slick-group-toggle-all').click(); + it('should use the topheader Toggle All button and expect all groups to now be collapsed', () => { + cy.get('.slick-topheader-panel .slick-group-toggle-all').click(); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`).should('have.length', 1); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Effort-Driven: False'); @@ -179,8 +193,8 @@ describe('Example 03 - Draggable Grouping', () => { .should('exist'); }); - it('should use the preheader Toggle All button and expect all groups to now be expanded', () => { - cy.get('.slick-preheader-panel .slick-group-toggle-all').click(); + it('should use the topheader Toggle All button and expect all groups to now be expanded', () => { + cy.get('.slick-topheader-panel .slick-group-toggle-all').click(); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Effort-Driven: False'); @@ -191,8 +205,8 @@ describe('Example 03 - Draggable Grouping', () => { .should('have.css', 'marginLeft').and('eq', `15px`); }); - it('should use the preheader Toggle All button again and expect all groups to now be collapsed', () => { - cy.get('.slick-preheader-panel .slick-group-toggle-all').click(); + it('should use the topheader Toggle All button again and expect all groups to now be collapsed', () => { + cy.get('.slick-topheader-panel .slick-group-toggle-all').click(); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`).should('have.length', 1); cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Effort-Driven: False');