diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts index d8a64ca5d..44fe4d407 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example03.ts @@ -305,8 +305,11 @@ export class Example3 { // frozenColumn: 2, draggableGrouping: { dropPlaceHolderText: 'Drop a column header here to group by the column', + // hideGroupSortIcons: true, // groupIconCssClass: 'fa fa-outdent', deleteIconCssClass: 'mdi mdi-close color-danger', + sortAscIconCssClass: 'mdi mdi-arrow-up', + sortDescIconCssClass: 'mdi mdi-arrow-down', onGroupChanged: (_e, args) => this.onGroupChanged(args), onExtensionRegistered: (extension) => this.draggableGroupingPlugin = extension, // groupIconCssClass: 'mdi mdi-drag-vertical', diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index ae9ba60fd..3e517180c 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -36,12 +36,14 @@ import { Column, DraggableGroupingOption, GridOption, SlickGrid, SlickNamespace import { BackendUtilityService, createDomElement, } from '../../services'; import { SharedService } from '../../services/shared.service'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; +import { SortDirectionNumber } from '../../enums'; declare const Slick: SlickNamespace; const GRID_UID = 'slickgrid12345'; let addonOptions: DraggableGroupingOption = { dropPlaceHolderText: 'Drop a column header here to group by the column', + hideGroupSortIcons: false, hideToggleAllButton: false, toggleAllButtonText: '', toggleAllPlaceholderText: 'Toggle all Groups', @@ -78,6 +80,7 @@ const gridStub = { getData: () => dataViewStub, getEditorLock: () => getEditorLockMock, getUID: () => GRID_UID, + invalidate: jest.fn(), registerPlugin: jest.fn(), updateColumnHeader: jest.fn(), onColumnsReordered: new Slick.Event(), @@ -330,7 +333,7 @@ describe('Draggable Grouping Plugin', () => { let placeholderElm = preHeaderDiv.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; let dropGroupingElm = preHeaderDiv.querySelector('.slick-dropped-grouping') as HTMLDivElement; expect(placeholderElm.style.display).toBe('none'); - expect(dropGroupingElm.style.display).toBe('inline-block'); + expect(dropGroupingElm.style.display).toBe('flex'); }); it('should execute the "onEnd" callback of Sortable and expect sortable to be cancelled', () => { @@ -443,6 +446,7 @@ describe('Draggable Grouping Plugin', () => { }); afterEach(() => { + plugin.dispose(); jest.clearAllMocks(); }); @@ -456,6 +460,7 @@ describe('Draggable Grouping Plugin', () => { formatter: mockColumns[2].grouping!.formatter, getter: 'age', collapsed: false, + sortAsc: true, }], }); @@ -525,6 +530,143 @@ describe('Draggable Grouping Plugin', () => { expect(toggleAllIconElm.classList.contains('collapsed')).toBeFalsy(); }); }); + + describe('setupColumnDropbox sort icon and click events', () => { + let groupChangedSpy: any; + let mockHeaderColumnDiv1: HTMLDivElement; + let mockHeaderColumnDiv2: HTMLDivElement; + + beforeEach(() => { + groupChangedSpy = jest.spyOn(plugin.onGroupChanged, 'notify'); + mockHeaderColumnDiv1 = document.createElement('div'); + mockHeaderColumnDiv1.className = 'slick-dropped-grouping'; + mockHeaderColumnDiv1.id = 'age'; + mockHeaderColumnDiv1.dataset.id = 'age'; + mockColumns[2].grouping!.collapsed = false; + + mockHeaderColumnDiv2 = document.createElement('div'); + mockHeaderColumnDiv2.className = 'slick-dropped-grouping'; + mockHeaderColumnDiv2.id = 'medals'; + mockHeaderColumnDiv2.dataset.id = 'medals'; + dropzoneElm.appendChild(mockHeaderColumnDiv1); + dropzoneElm.appendChild(mockHeaderColumnDiv2); + + mockHeaderColumnDiv1.appendChild(mockDivPaneContainer1); + mockHeaderColumnDiv2.appendChild(mockDivPaneContainer1); + }); + + afterEach(() => { + plugin.dispose(); + jest.clearAllMocks(); + }); + + it('should ', () => { + + }); + + it('should not expect any sort icons displayed when "hideGroupSortIcons" is set to True', () => { + plugin.init(gridStub, { ...addonOptions, hideGroupSortIcons: true }); + const fn = plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + jest.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + + fn.sortableLeftInstance!.options.onStart!({} as any); + plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + + expect(plugin.addonOptions.hideGroupSortIcons).toBe(true) + let groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; + let groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; + + expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); + expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); + expect(fn.sortableLeftInstance.destroy).toBeTruthy(); + expect(groupBySortElm).toBeFalsy(); + expect(groupBySortAscIconElm).toBeFalsy(); + }); + + it('should toggle ascending/descending order when original sort is ascending then user clicked the sorting icon twice', () => { + plugin.init(gridStub, { ...addonOptions }); + const fn = plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + jest.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + const invalidateSpy = jest.spyOn(gridStub, 'invalidate'); + + fn.sortableLeftInstance!.options.onStart!({} as any); + plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + + let groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; + let groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; + + expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); + expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); + expect(fn.sortableLeftInstance.destroy).toBeTruthy(); + expect(groupBySortElm).toBeTruthy(); + expect(groupBySortAscIconElm).toBeTruthy(); + + groupBySortAscIconElm.dispatchEvent(new Event('click')); + const toggleAllElm = document.querySelector('.slick-group-toggle-all') as HTMLDivElement; + const toggleAllIconElm = toggleAllElm.querySelector('.slick-group-toggle-all-icon') as HTMLDivElement; + groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; + let groupBySortDescIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-desc-icon') as HTMLDivElement; + + expect(setGroupingSpy).toHaveBeenCalledWith(expect.toBeArray()); + expect(toggleAllIconElm.classList.contains('expanded')).toBeTruthy(); + expect(groupBySortAscIconElm).toBeFalsy(); + expect(groupBySortDescIconElm).toBeTruthy(); + + groupBySortDescIconElm.dispatchEvent(new Event('click')); + groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; + groupBySortDescIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-desc-icon') as HTMLDivElement; + + expect(setGroupingSpy).toHaveBeenCalledWith(expect.toBeArray()); + expect(toggleAllIconElm.classList.contains('expanded')).toBeTruthy(); + expect(groupBySortAscIconElm).toBeTruthy(); + expect(groupBySortDescIconElm).toBeFalsy(); + expect(invalidateSpy).toHaveBeenCalledTimes(2); + expect(groupChangedSpy).toHaveBeenCalledWith({ caller: 'sort-group', groupColumns: expect.toBeArray(), }); + }); + + it('should toggle ascending/descending order with different icons when original sort is ascending then user clicked the sorting icon twice', () => { + plugin.init(gridStub, { ...addonOptions, sortAscIconCssClass: 'mdi mdi-arrow-up', sortDescIconCssClass: 'mdi mdi-arrow-down' }); + const fn = plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + jest.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + const invalidateSpy = jest.spyOn(gridStub, 'invalidate'); + + fn.sortableLeftInstance!.options.onStart!({} as any); + plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + + let groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; + let groupBySortAscIconElm = preHeaderDiv.querySelector('.mdi-arrow-up') as HTMLDivElement; + + expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); + expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); + expect(fn.sortableLeftInstance.destroy).toBeTruthy(); + expect(groupBySortElm).toBeTruthy(); + expect(groupBySortAscIconElm).toBeTruthy(); + + groupBySortAscIconElm.dispatchEvent(new Event('click')); + groupBySortAscIconElm = preHeaderDiv.querySelector('.mdi-arrow-up') as HTMLDivElement; + let groupBySortDescIconElm = preHeaderDiv.querySelector('.mdi-arrow-down') as HTMLDivElement; + + expect(setGroupingSpy).toHaveBeenCalledWith(expect.toBeArray()); + expect(groupBySortAscIconElm).toBeFalsy(); + expect(groupBySortDescIconElm).toBeTruthy(); + + groupBySortDescIconElm.dispatchEvent(new Event('click')); + groupBySortAscIconElm = preHeaderDiv.querySelector('.mdi-arrow-up') as HTMLDivElement; + groupBySortDescIconElm = preHeaderDiv.querySelector('.mdi-arrow-down') as HTMLDivElement; + + expect(setGroupingSpy).toHaveBeenCalledWith(expect.toBeArray()); + expect(groupBySortAscIconElm).toBeTruthy(); + expect(groupBySortDescIconElm).toBeFalsy(); + expect(invalidateSpy).toHaveBeenCalledTimes(2); + expect(groupChangedSpy).toHaveBeenCalledWith({ caller: 'sort-group', groupColumns: expect.toBeArray(), }); + + const sortResult1 = mockColumns[2].grouping!.comparer!({ value: 'John', count: 0 }, { value: 'Jane', count: 1 }); + expect(sortResult1).toBe(SortDirectionNumber.asc); + + const sortResult2 = mockColumns[2].grouping!.comparer!({ value: 'Jane', count: 1 }, { value: 'John', count: 0 }); + expect(sortResult2).toBe(SortDirectionNumber.desc); + }); + }); }); describe('with Frozen Grid', () => { diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 7aa8be291..702baf4f7 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -5,6 +5,7 @@ import * as Sortable_ from 'sortablejs'; const Sortable = ((Sortable_ as any)?.['default'] ?? Sortable_); // patch for rollup import { ExtensionUtility } from '../extensions/extensionUtility'; +import { SortDirectionNumber } from '../enums'; import { Column, DOMMouseOrTouchEvent, @@ -22,6 +23,7 @@ import { import { BindingEventService } from '../services/bindingEvent.service'; import { SharedService } from '../services/shared.service'; import { createDomElement, emptyElement } from '../services/domUtilities'; +import { sortByFieldType } from '../sortComparers'; // using external SlickGrid JS libraries declare const Slick: SlickNamespace; @@ -70,6 +72,7 @@ export class SlickDraggableGrouping { protected _subscriptions: EventSubscription[] = []; protected _defaults = { dropPlaceHolderText: 'Drop a column header here to group by the column', + hideGroupSortIcons: false, hideToggleAllButton: false, toggleAllButtonText: '', toggleAllPlaceholderText: 'Toggle all Groups', @@ -130,9 +133,6 @@ export class SlickDraggableGrouping { get gridUid(): string { return this._gridUid || (this.grid?.getUID() ?? ''); } - get gridUidSelector(): string { - return this.gridUid ? `#${this.gridUid}` : ''; - } /** Initialize plugin. */ init(grid: SlickGrid, groupingOptions?: DraggableGrouping) { @@ -228,7 +228,7 @@ export class SlickDraggableGrouping { this._eventHandler.unsubscribeAll(); this.pubSubService.unsubscribeAll(this._subscriptions); this._bindEventService.unbindAll(); - emptyElement(document.querySelector('.slick-preheader-panel')); + emptyElement(document.querySelector(`.${this.gridUid} .slick-preheader-panel`)); this._grid = undefined as any; } @@ -237,7 +237,7 @@ export class SlickDraggableGrouping { this.updateGroupBy('clear-all'); const allDroppedGroupingElms = this._dropzoneElm.querySelectorAll('.slick-dropped-grouping'); for (const groupElm of Array.from(allDroppedGroupingElms)) { - const groupRemoveBtnElm = document.querySelector('.slick-groupby-remove'); + const groupRemoveBtnElm = this._dropzoneElm.querySelector('.slick-groupby-remove'); groupRemoveBtnElm?.remove(); groupElm?.remove(); } @@ -283,7 +283,7 @@ export class SlickDraggableGrouping { let reorderedColumns = grid.getColumns(); const dropzoneElm = grid.getPreHeaderPanel(); const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); - const groupTogglerElm = document.querySelector('.slick-group-toggle-all'); + const groupTogglerElm = dropzoneElm.querySelector('.slick-group-toggle-all'); const sortableOptions = { animation: 50, @@ -324,7 +324,7 @@ export class SlickDraggableGrouping { } const droppedGroupingElm = dropzoneElm.querySelector('.slick-dropped-grouping'); if (droppedGroupingElm) { - droppedGroupingElm.style.display = 'inline-block'; + droppedGroupingElm.style.display = 'flex'; } if (groupTogglerElm) { groupTogglerElm.style.display = 'inline-block'; @@ -382,16 +382,54 @@ export class SlickDraggableGrouping { this.updateGroupBy('add-group'); } - protected addGroupByRemoveClickHandler(id: string | number, headerColumnElm: HTMLDivElement, entry: any) { - const groupRemoveElm = document.querySelector(`${this.gridUidSelector}_${id}_entry > .slick-groupby-remove`); - if (groupRemoveElm) { - this._bindEventService.bind(groupRemoveElm, 'click', () => { - const boundedElms = this._bindEventService.boundedEvents.filter(boundedEvent => boundedEvent.element === groupRemoveElm); - for (const boundedEvent of boundedElms) { - this._bindEventService.unbind(boundedEvent.element, 'click', boundedEvent.listener); + protected addGroupByRemoveClickHandler(id: string | number, groupRemoveIconElm: HTMLDivElement, headerColumnElm: HTMLDivElement, entry: any) { + this._bindEventService.bind(groupRemoveIconElm, 'click', () => { + const boundedElms = this._bindEventService.boundedEvents.filter(boundedEvent => boundedEvent.element === groupRemoveIconElm); + for (const boundedEvent of boundedElms) { + this._bindEventService.unbind(boundedEvent.element, 'click', boundedEvent.listener); + } + this.removeGroupBy(id, headerColumnElm, entry); + }); + } + + protected addGroupSortClickHandler(col: Column, groupSortContainerElm: HTMLDivElement) { + const { grouping, type } = col; + this._bindEventService.bind(groupSortContainerElm, 'click', () => { + // group sorting requires all group to be opened, make sure that the Toggle All is also expanded + this.toggleGroupAll(col, false); + + if (grouping) { + const nextSortDirection = grouping.sortAsc ? SortDirectionNumber.desc : SortDirectionNumber.asc; + grouping.comparer = (a, b) => sortByFieldType(type || 'text', a.value, b.value, nextSortDirection, col, this.gridOptions); + this.getGroupBySortIcon(groupSortContainerElm, !grouping.sortAsc); + this.updateGroupBy('sort-group'); + grouping.sortAsc = !grouping.sortAsc; + this.grid.invalidate(); + } + }); + } + + protected getGroupBySortIcon(groupSortContainerElm: HTMLDivElement, sortAsc = true) { + if (sortAsc) { + // ascending icon + if (this._addonOptions.sortAscIconCssClass) { + groupSortContainerElm.classList.remove(...this._addonOptions.sortDescIconCssClass?.split(' ') ?? ''); + groupSortContainerElm.classList.add(...this._addonOptions.sortAscIconCssClass.split(' ')); + } else { + groupSortContainerElm.classList.add('slick-groupby-sort-asc-icon'); + groupSortContainerElm.classList.remove('slick-groupby-sort-desc-icon'); + } + } else { + // descending icon + if (this._addonOptions.sortDescIconCssClass) { + groupSortContainerElm.classList.remove(...this._addonOptions.sortAscIconCssClass?.split(' ') ?? ''); + groupSortContainerElm.classList.add(...this._addonOptions.sortDescIconCssClass.split(' ')); + } else { + if (!this._addonOptions.sortDescIconCssClass) { + groupSortContainerElm.classList.add('slick-groupby-sort-desc-icon'); + groupSortContainerElm.classList.remove('slick-groupby-sort-asc-icon'); } - this.removeGroupBy(id, headerColumnElm, entry); - }); + } } } @@ -407,7 +445,7 @@ export class SlickDraggableGrouping { if (columnAllowed) { for (const col of this._gridColumns) { if (col.id === columnId) { - if (col.grouping !== null && !isEmptyObject(col.grouping)) { + if (col.grouping && !isEmptyObject(col.grouping)) { const columnNameElm = headerColumnElm.querySelector('.slick-column-name'); const entryElm = createDomElement('div', { id: `${this._gridUid}_${col.id}_entry`, @@ -421,6 +459,7 @@ export class SlickDraggableGrouping { }); entryElm.appendChild(groupTextElm); + // delete icon const groupRemoveIconElm = createDomElement('div', { className: 'slick-groupby-remove' }); if (this._addonOptions.deleteIconCssClass) { groupRemoveIconElm.classList.add(...this._addonOptions.deleteIconCssClass.split(' ')); @@ -429,24 +468,33 @@ export class SlickDraggableGrouping { groupRemoveIconElm.classList.add('slick-groupby-remove-icon'); } + // sorting icons + let groupSortContainerElm: HTMLDivElement | undefined; + if (this._addonOptions?.hideGroupSortIcons !== true) { + if (col.grouping?.sortAsc === undefined) { + col.grouping.sortAsc = true; + } + groupSortContainerElm = createDomElement('div', { className: 'slick-groupby-sort' }); + this.getGroupBySortIcon(groupSortContainerElm, col.grouping.sortAsc); + entryElm.appendChild(groupSortContainerElm); + } + entryElm.appendChild(groupRemoveIconElm); entryElm.appendChild(document.createElement('div')); containerElm.appendChild(entryElm); // if we're grouping by only 1 group, at the root, we'll analyze Toggle All and add collapsed/expanded class if (this._groupToggler && this.columnsGroupBy.length === 0) { - const togglerIcon = this._groupToggler.querySelector('.slick-group-toggle-all-icon'); - if (col.grouping?.collapsed) { - togglerIcon?.classList.add('collapsed'); - togglerIcon?.classList.remove('expanded'); - } else { - togglerIcon?.classList.add('expanded'); - togglerIcon?.classList.remove('collapsed'); - } + this.toggleGroupAll(col); } this.addColumnGroupBy(col); - this.addGroupByRemoveClickHandler(col.id, headerColumnElm, entryElm); + this.addGroupByRemoveClickHandler(col.id, groupRemoveIconElm, headerColumnElm, entryElm); + + // when Sorting group is enabled, let's add all handlers + if (groupSortContainerElm) { + this.addGroupSortClickHandler(col, groupSortContainerElm); + } } } } @@ -458,6 +506,17 @@ export class SlickDraggableGrouping { } } + protected toggleGroupAll({ grouping }: Column, collapsed?: boolean) { + const togglerIcon = this._groupToggler?.querySelector('.slick-group-toggle-all-icon'); + if (collapsed === true || grouping?.collapsed) { + togglerIcon?.classList.add('collapsed'); + togglerIcon?.classList.remove('expanded'); + } else { + togglerIcon?.classList.add('expanded'); + togglerIcon?.classList.remove('collapsed'); + } + } + protected removeFromArray(arrayToModify: any[], itemToRemove: any) { if (Array.isArray(arrayToModify)) { const itemIdx = arrayToModify.findIndex(a => a.id === itemToRemove.id); diff --git a/packages/common/src/interfaces/draggableGroupingOption.interface.ts b/packages/common/src/interfaces/draggableGroupingOption.interface.ts index 199b3465c..f14d409a1 100644 --- a/packages/common/src/interfaces/draggableGroupingOption.interface.ts +++ b/packages/common/src/interfaces/draggableGroupingOption.interface.ts @@ -17,6 +17,15 @@ export interface DraggableGroupingOption { /** Defaults to False, should we display a toggle all button (typically aligned on the left before any of the column group) */ hideToggleAllButton?: boolean; + /** Defaults to False, should we show the Sorting icons on each group by element? */ + hideGroupSortIcons?: boolean; + + /** an extra CSS class to add to the sort ascending icon (default undefined), if sortAscIconCssClass is undefined then slick-groupby-sort-asc-icon class will be added */ + sortAscIconCssClass?: string; + + /** an extra CSS class to add to the sort descending icon (default undefined), if sortDescIconCssClass is undefined then slick-groupby-sort-desc-icon class will be added */ + sortDescIconCssClass?: string; + /** Defaults to "Toggle all Groups", placeholder of the Toggle All button that can optionally show up in the pre-header row. */ toggleAllPlaceholderText?: string; diff --git a/packages/common/src/interfaces/grouping.interface.ts b/packages/common/src/interfaces/grouping.interface.ts index a930e7965..e6e00f858 100644 --- a/packages/common/src/interfaces/grouping.interface.ts +++ b/packages/common/src/interfaces/grouping.interface.ts @@ -38,4 +38,7 @@ export interface Grouping { /** Set some predefined Grouping values */ predefinedValues?: any[]; + + /** defaults to true, so far only used internally by SlickDraggableGrouping */ + sortAsc?: boolean } diff --git a/packages/common/src/sortComparers/__tests__/booleanSortComparer.spec.ts b/packages/common/src/sortComparers/__tests__/booleanSortComparer.spec.ts new file mode 100644 index 000000000..7f4ebcb2f --- /dev/null +++ b/packages/common/src/sortComparers/__tests__/booleanSortComparer.spec.ts @@ -0,0 +1,32 @@ +import { SortDirectionNumber } from '../../enums/index'; +import { SortComparers } from '../index'; + +describe('the Boolean SortComparer', () => { + it('should return original unsorted array when no direction is provided', () => { + const direction = null as any; + const inputArray = [false, true, true, false]; + inputArray.sort((value1, value2) => SortComparers.boolean(value1, value2, direction)); + expect(inputArray).toEqual([false, true, true, false]); + }); + + it('should return original unsorted array when neutral (0) direction is provided', () => { + const direction = SortDirectionNumber.neutral; + const inputArray = [false, true, null, true, false]; + inputArray.sort((value1, value2) => SortComparers.boolean(value1, value2, direction)); + expect(inputArray).toEqual([false, true, null, true, false]); + }); + + it('should return all False values before True values when sorting in ascending order', () => { + const direction = SortDirectionNumber.asc; + const inputArray = [false, null, true, true, false]; + inputArray.sort((value1, value2) => SortComparers.boolean(value1, value2, direction)); + expect(inputArray).toEqual([null, false, false, true, true]); + }); + + it('should return all True values before False values when sorting in descending order', () => { + const direction = SortDirectionNumber.desc; + const inputArray = [false, true, true, false]; + inputArray.sort((value1, value2) => SortComparers.boolean(value1, value2, direction)); + expect(inputArray).toEqual([true, true, false, false]); + }); +}); diff --git a/packages/common/src/sortComparers/__tests__/sortUtilities.spec.ts b/packages/common/src/sortComparers/__tests__/sortUtilities.spec.ts index 2bf1284ed..518c9b7fa 100644 --- a/packages/common/src/sortComparers/__tests__/sortUtilities.spec.ts +++ b/packages/common/src/sortComparers/__tests__/sortUtilities.spec.ts @@ -4,6 +4,12 @@ import { sortByFieldType } from '../sortUtilities'; import { SortComparers } from '../sortComparers.index'; describe('sortUtilities', () => { + it('should call the SortComparers.boolean when FieldType is boolean', () => { + const spy = jest.spyOn(SortComparers, 'boolean'); + sortByFieldType(FieldType.boolean, 0, 4, SortDirectionNumber.asc, { id: 'field1', field: 'field1' }); + expect(spy).toHaveBeenCalledWith(0, 4, SortDirectionNumber.asc, { id: 'field1', field: 'field1' }, undefined); + }); + it('should call the SortComparers.numeric when FieldType is number', () => { const spy = jest.spyOn(SortComparers, 'numeric'); sortByFieldType(FieldType.number, 0, 4, SortDirectionNumber.asc, { id: 'field1', field: 'field1' }); diff --git a/packages/common/src/sortComparers/__tests__/stringSortComparer.spec.ts b/packages/common/src/sortComparers/__tests__/stringSortComparer.spec.ts index bad20d956..af8b2bb6a 100644 --- a/packages/common/src/sortComparers/__tests__/stringSortComparer.spec.ts +++ b/packages/common/src/sortComparers/__tests__/stringSortComparer.spec.ts @@ -85,7 +85,7 @@ describe('the String SortComparer', () => { // from MDN specification quote: All undefined elements are sorted to the end of the array. const columnDef = { id: 'name', field: 'name' } as Column; const direction = SortDirectionNumber.asc; - const inputArray = ['Jose Silva', 'José', 'amazon', 'zebra' , 'Chêvre', '', '@at', 'John', 'Abe', 'abc', 'Ângelo']; + const inputArray = ['Jose Silva', 'José', 'amazon', 'zebra', 'Chêvre', '', '@at', 'John', 'Abe', 'abc', 'Ângelo']; inputArray.sort((value1, value2) => stringSortComparer(value1, value2, direction, columnDef, { ignoreAccentOnStringFilterAndSort: true } as GridOption)); expect(inputArray).toEqual(['', '@at', 'Abe', 'Ângelo', 'Chêvre', 'John', 'José', 'Jose Silva', 'abc', 'amazon', 'zebra']); }); @@ -94,7 +94,7 @@ describe('the String SortComparer', () => { // from MDN specification quote: All undefined elements are sorted to the end of the array. const columnDef = { id: 'name', field: 'name' } as Column; const direction = SortDirectionNumber.desc; - const inputArray = ['Jose Silva', 'José', 'amazon', 'zebra' , 'Chêvre', '', '@at', 'John', 'Abe', 'abc', 'Ângelo']; + const inputArray = ['Jose Silva', 'José', 'amazon', 'zebra', 'Chêvre', '', '@at', 'John', 'Abe', 'abc', 'Ângelo']; inputArray.sort((value1, value2) => stringSortComparer(value1, value2, direction, columnDef, { ignoreAccentOnStringFilterAndSort: true } as GridOption)); expect(inputArray).toEqual(['zebra', 'amazon', 'abc', 'Jose Silva', 'José', 'John', 'Chêvre', 'Ângelo', 'Abe', '@at', '']); }); diff --git a/packages/common/src/sortComparers/booleanSortComparer.ts b/packages/common/src/sortComparers/booleanSortComparer.ts new file mode 100644 index 000000000..f9d39143b --- /dev/null +++ b/packages/common/src/sortComparers/booleanSortComparer.ts @@ -0,0 +1,24 @@ +import { SortComparer } from '../interfaces/index'; +import { SortDirectionNumber } from '../enums/sortDirectionNumber.enum'; + +export const booleanSortComparer: SortComparer = (value1: any, value2: any, sortDirection: number | SortDirectionNumber) => { + if (sortDirection === undefined || sortDirection === null) { + sortDirection = SortDirectionNumber.neutral; + } + let position = 0; + + if (value1 === value2) { + position = 0; + } else if (value1 === null) { + position = -1; + } else if (value2 === null) { + position = 1; + } else { + if (sortDirection) { + position = value1 < value2 ? -1 : 1; + } else { + position = value1 < value2 ? 1 : -1; + } + } + return sortDirection * position; +}; diff --git a/packages/common/src/sortComparers/index.ts b/packages/common/src/sortComparers/index.ts index f6533c06e..f872dfca1 100644 --- a/packages/common/src/sortComparers/index.ts +++ b/packages/common/src/sortComparers/index.ts @@ -1,3 +1,4 @@ +export * from './booleanSortComparer'; export * from './objectStringSortComparer'; export * from './stringSortComparer'; export * from './sortUtilities'; diff --git a/packages/common/src/sortComparers/sortComparers.index.ts b/packages/common/src/sortComparers/sortComparers.index.ts index fa0e9c408..6d08376a1 100644 --- a/packages/common/src/sortComparers/sortComparers.index.ts +++ b/packages/common/src/sortComparers/sortComparers.index.ts @@ -1,3 +1,4 @@ +import { booleanSortComparer } from './booleanSortComparer'; import { numericSortComparer } from './numericSortComparer'; import { objectStringSortComparer } from './objectStringSortComparer'; import { stringSortComparer } from './stringSortComparer'; @@ -8,6 +9,9 @@ import { FieldType } from '../enums/fieldType.enum'; export * from './sortUtilities'; export const SortComparers = { + /** SortComparer method to sort values as regular strings */ + boolean: booleanSortComparer, + /** SortComparer method to sort values by Date object type (uses Moment.js ISO_8601 standard format, optionally include time) */ date: getAssociatedDateSortComparer(FieldType.date), diff --git a/packages/common/src/sortComparers/sortUtilities.ts b/packages/common/src/sortComparers/sortUtilities.ts index c6aa8aab9..dafa0a3e4 100644 --- a/packages/common/src/sortComparers/sortUtilities.ts +++ b/packages/common/src/sortComparers/sortUtilities.ts @@ -7,6 +7,9 @@ export function sortByFieldType(fieldType: typeof FieldType[keyof typeof FieldTy let sortResult = 0; switch (fieldType) { + case FieldType.boolean: + sortResult = SortComparers.boolean(value1, value2, sortDirection, sortColumn, gridOptions); + break; case FieldType.float: case FieldType.integer: case FieldType.number: diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 533cc55a1..d22ffc32c 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -604,10 +604,10 @@ $slick-date-range-filter-border-radius: 4px !default; /* Draggable Grouping Plugin */ $slick-draggable-group-column-background-color: transparent !default; -$slick-draggable-group-column-border: 1px solid transparent !default; +$slick-draggable-group-column-border: 1px solid #d3d3d3 !default; $slick-draggable-group-column-border-radius: 6px !default; -$slick-draggable-group-column-padding: 0 5px !default; -$slick-draggable-group-column-margin-right: 2px !default; +$slick-draggable-group-column-padding: 0 4px !default; +$slick-draggable-group-column-margin-right: 8px !default; $slick-draggable-group-drop-border-hover: 1px dashed #ff9e9e !default; $slick-draggable-group-drop-border: 1px solid #e0e0e0 !default; $slick-draggable-group-drop-border-top: $slick-draggable-group-drop-border !default; @@ -624,8 +624,22 @@ $slick-draggable-group-droppable-active-bgcolor: #fafafa !default; $slick-draggable-group-droppable-hover-bgcolor: #ffffff !default; $slick-draggable-group-placeholder-font-style: italic !default; $slick-draggable-group-placeholder-color: #616161 !default; +$slick-draggable-group-by-remove-icon: "\f00d" !default; +$slick-draggable-group-by-remove-icon-size: calc(#{$slick-icon-font-size} + 2px) !default; +$slick-draggable-group-sort-asc-icon: $slick-icon-sort-asc !default; +$slick-draggable-group-sort-asc-icon-size: calc(#{$slick-icon-font-size} + 2px) !default; +$slick-draggable-group-sort-desc-icon: $slick-icon-sort-desc !default; +$slick-draggable-group-sort-desc-icon-size: calc(#{$slick-icon-font-size} + 2px) !default; +$slick-draggable-group-sort-icon-color: $slick-icon-color !default; +$slick-draggable-group-sort-icon-hover-color: darken($slick-icon-color, 5%) !default; +$slick-draggable-group-sort-icon-margin-left: 2px !default; +$slick-draggable-group-sort-icon-padding-left: 5px !default; +$slick-draggable-group-sort-icon-padding-right: 0px !default; +$slick-draggable-group-sort-icon-font-size: 16px !default; +$slick-draggable-group-sort-icon-vertical-align: baseline !default; $slick-draggable-group-delete-color: pink !default; $slick-draggable-group-delete-hover-color: red !default; +$slick-draggable-group-delete-margin-left: 2px !default; $slick-draggable-group-delete-padding-left: 5px !default; $slick-draggable-group-delete-padding-right: 0px !default; $slick-draggable-group-delete-font-size: 16px !default; diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 10d03ee9c..13473126d 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -925,10 +925,24 @@ input.flatpickr.form-control { z-index: 1; } + .slick-groupby-sort { + cursor: pointer; + display: inline-flex; + margin-left: var(--slick-draggable-group-sort-icon-margin-left, $slick-draggable-group-sort-icon-margin-left); + color: var(--slick-draggable-group-sort-icon-color, $slick-draggable-group-sort-icon-color); + font-size: var(--slick-draggable-group-sort-icon-font-size, $slick-draggable-group-sort-icon-font-size); + padding-left: var(--slick-draggable-group-sort-icon-padding-left, $slick-draggable-group-sort-icon-padding-left); + padding-right: var(--slick-draggable-group-sort-icon-padding-right, $slick-draggable-group-sort-icon-padding-right); + vertical-align: var(--slick-draggable-group-sort-icon-vertical-align, $slick-draggable-group-sort-icon-vertical-align); + &:hover { + color: var(--slick-draggable-group-sort-icon-hover-color, $slick-draggable-group-sort-icon-hover-color); + } + } + .slick-groupby-remove { cursor: pointer; display: inline-flex; - margin-left: 5px; + margin-left: var(--slick-draggable-group-delete-margin-left, $slick-draggable-group-delete-margin-left); color: var(--slick-draggable-group-delete-color, $slick-draggable-group-delete-color); font-size: var(--slick-draggable-group-delete-font-size, $slick-draggable-group-delete-font-size); padding-left: var(--slick-draggable-group-delete-padding-left, $slick-draggable-group-delete-padding-left); @@ -938,6 +952,22 @@ input.flatpickr.form-control { color: var(--slick-draggable-group-delete-hover-color, $slick-draggable-group-delete-hover-color); } } + + .slick-groupby-remove-icon::before { + font-family: var(--slick-icon-font-family, $slick-icon-font-family); + font-size: var(--slick-draggable-group-by-remove-icon-size, $slick-draggable-group-by-remove-icon-size); + content: var(--slick-draggable-group-by-remove-icon, $slick-draggable-group-by-remove-icon); + } + .slick-groupby-sort-asc-icon::before { + font-family: var(--slick-icon-font-family, $slick-icon-font-family); + font-size: var(--slick-draggable-group-sort-asc-icon-size, $slick-draggable-group-sort-asc-icon-size); + content: var(--slick-draggable-group-sort-asc-icon, $slick-draggable-group-sort-asc-icon); + } + .slick-groupby-sort-desc-icon::before { + font-family: var(--slick-icon-font-family, $slick-icon-font-family); + font-size: var(--slick-draggable-group-sort-desc-icon-size, $slick-draggable-group-sort-desc-icon-size); + content: var(--slick-draggable-group-sort-desc-icon, $slick-draggable-group-sort-desc-icon); + } } .slick-dropzone-hover { background-color: var(--slick-draggable-group-droppable-hover-bgcolor, $slick-draggable-group-droppable-hover-bgcolor); diff --git a/test/cypress/e2e/example03.cy.js b/test/cypress/e2e/example03.cy.js index 0893d277c..0b9ec25ce 100644 --- a/test/cypress/e2e/example03.cy.js +++ b/test/cypress/e2e/example03.cy.js @@ -47,6 +47,32 @@ describe('Example 03 - Draggable Grouping', { retries: 1 }, () => { cy.get(`[style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 4'); }); + it('should click on the group by Duration sort icon and expect data to become sorted as descending order with all rows being expanded', () => { + cy.get('.mdi-arrow-up:nth(0)').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1); + }); + + 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(`[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'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 98'); + }); + + it('should click on the group by Duration sort icon and now expect data to become sorted as ascending order with all rows being expanded', () => { + cy.get('.mdi-arrow-down:nth(0)').click(); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1); + }); + + 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(`[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'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration: 2'); + }); + it('should click on Expand All columns and expect 1st row as grouping title and 2nd row as a regular row', () => { cy.get('[data-test="add-50k-rows-btn"]').click(); cy.get('[data-test="group-duration-sort-value-btn"]').click();