Skip to content

Commit

Permalink
feat(core): add optional Top-Header for Drag Grouping & Header Groupi…
Browse files Browse the repository at this point in the history
…ng (#1556)

* feat(core): add optional Top-Header (over pre-header)
- prior to this PR, we could not use Draggable Grouping & Header Grouping because both features were using the pre-header and it would have conflict, now to avoid this I am adding an extra Top-Header that will show over all header and pre-header.
  • Loading branch information
ghiscoding committed Jun 5, 2024
1 parent 69125c2 commit 7d4a769
Show file tree
Hide file tree
Showing 16 changed files with 333 additions and 103 deletions.
38 changes: 38 additions & 0 deletions docs/grid-functionalities/grouping-aggregators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
109 changes: 61 additions & 48 deletions examples/vite-demo-vanilla-bundle/src/examples/example03.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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} <span class="text-color-primary">(${g.count} items)</span>`,
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} <span class="text-color-primary">(${g.count} items)</span>`,
aggregators: [
new Aggregators.Sum('cost')
],
aggregateCollapsed: false,
collapsed: false
}
},
{
id: 'cost', name: 'Cost', field: 'cost', columnGroup: 'Analysis',
width: 90,
sortable: true,
filterable: true,
Expand All @@ -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,
Expand All @@ -160,44 +201,7 @@ export default class Example03 {
params: { groupFormatterPrefix: '<i>Avg</i>: ' },
},
{
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} <span class="text-color-primary">(${g.count} items)</span>`,
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} <span class="text-color-primary">(${g.count} items)</span>`,
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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() {
Expand All @@ -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[]; }) {
Expand Down
103 changes: 101 additions & 2 deletions packages/common/src/core/__tests__/slickGrid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<any, Column>(container, [], columns, gridOptions);
grid.init();
const topheaderElm = container.querySelector('.slick-topheader-panel');
const topheaderElms = container.querySelectorAll<HTMLDivElement>('.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<HTMLDivElement>('.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<any, Column>(container, [], columns, gridOptions);
grid.init();
let topheaderElms = container.querySelectorAll<HTMLDivElement>('.slick-topheader-panel');

expect(grid).toBeTruthy();
expect(topheaderElms).toBeTruthy();
expect(topheaderElms[0].style.display).toBe('none');

grid.setTopHeaderPanelVisibility(true);
topheaderElms = container.querySelectorAll<HTMLDivElement>('.slick-topheader-panel');
expect(topheaderElms[0].style.display).not.toBe('none');

grid.setTopHeaderPanelVisibility(false);
topheaderElms = container.querySelectorAll<HTMLDivElement>('.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[];
Expand Down Expand Up @@ -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<any, Column>(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);
Expand Down Expand Up @@ -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<any, Column>(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<HTMLDivElement>('.slick-preheader-panel');
Object.defineProperty(preheaderElm, 'scrollLeft', { writable: true, value: 25 });

preheaderElm.dispatchEvent(new CustomEvent('scroll'));
Expand All @@ -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<any, Column>(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<any, Column>(container, items, columns, { ...defaultOptions, showHeaderRow: true, enableCellNavigation: true });
Expand Down
Loading

0 comments on commit 7d4a769

Please sign in to comment.