import { BindingEventService } from '@slickgrid-universal/binding'; import type { BasePubSubService, EventSubscription } from '@slickgrid-universal/event-pub-sub'; import { classNameToList, createDomElement, emptyElement, isEmptyObject } from '@slickgrid-universal/utils'; import type { SortableEvent, Options as SortableOptions } from 'sortablejs'; import Sortable from 'sortablejs/modular/sortable.core.esm.js'; import { SlickEvent, SlickEventData, SlickEventHandler, type SlickDataView, type SlickGrid } from '../core/index.js'; import { SortDirectionNumber } from '../enums/index.js'; import type { Column, DOMMouseOrTouchEvent, DraggableGrouping, DraggableGroupingOption, GridOption, Grouping, GroupingGetterFunction, } from '../interfaces/index.js'; import type { SharedService } from '../services/shared.service.js'; import { sortByFieldType } from '../sortComparers/sortUtilities.js'; import type { ExtensionUtility } from './extensionUtility.js'; /** * * Draggable Grouping contributed by: Muthukumar Selconstasu * muthukumar{dot}se{at}gmail{dot}com * github.com/muthukumarse/Slickgrid * * NOTES: * 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: * this.columns = [{ * id: 'cost', name: 'Cost', field: 'cost', * grouping: { * getter: 'cost', * formatter: (g) => `Cost: ${g.value} (${g.count} items)`, * aggregators: [new Aggregators.Sum('cost')], * aggregateCollapsed: true, * collapsed: true * } * }]; */ export class SlickDraggableGrouping { columnsGroupBy: Column[] = []; onGroupChanged: SlickEvent; readonly pluginName = 'DraggableGrouping'; protected _addonOptions!: DraggableGrouping; protected _bindingEventService: BindingEventService; protected _droppableInstance?: Sortable; protected _dropzoneElm!: HTMLDivElement; protected _dropzonePlaceholderElm!: HTMLDivElement; protected _eventHandler!: SlickEventHandler; protected _grid?: SlickGrid; protected _gridColumns: Column[] = []; protected _gridUid = ''; protected _groupToggler?: HTMLDivElement; protected _isInitialized = false; protected _reorderedColumns: Column[] = []; protected _sortableLeftInstance?: Sortable; protected _sortableRightInstance?: Sortable; 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', } as DraggableGroupingOption; /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ constructor( protected readonly extensionUtility: ExtensionUtility, protected readonly pubSubService: BasePubSubService, protected readonly sharedService: SharedService ) { this._bindingEventService = new BindingEventService(); this.onGroupChanged = new SlickEvent<{ caller?: string; groupColumns: Grouping[] }>('onGroupChanged'); this._eventHandler = new SlickEventHandler(); } get addonOptions(): DraggableGroupingOption { return this._addonOptions; } /** Getter of SlickGrid DataView object */ get dataView(): SlickDataView { return this.grid?.getData() ?? {}; } get dropboxElement(): HTMLDivElement { return this._dropzoneElm; } get droppableInstance(): Sortable | undefined { return this._droppableInstance; } get sortableLeftInstance(): Sortable | undefined { return this._sortableLeftInstance; } get sortableRightInstance(): Sortable | undefined { return this._sortableRightInstance; } get eventHandler(): SlickEventHandler { return this._eventHandler; } get grid(): SlickGrid { return this._grid ?? this.sharedService.slickGrid ?? {}; } get gridOptions(): GridOption { return this.sharedService.gridOptions ?? {}; } /** Getter for the grid uid */ get gridUid(): string { return this._gridUid || (this.grid?.getUID() ?? ''); } get gridContainer(): HTMLElement { return this.grid.getContainerNode(); } set isInitialized(state: boolean) { this._isInitialized = state; } /** Initialize plugin. */ init(grid: SlickGrid, groupingOptions?: DraggableGrouping): this { this._addonOptions = { ...this._defaults, ...groupingOptions }; this._grid = grid; if (grid) { this._gridUid = grid.getUID(); this._gridColumns = grid.getColumns(); this._dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); if (!this._dropzoneElm) { throw new Error( '[Slickgrid-Universal] Draggable Grouping requires the pre-header to be created and shown for the plugin to work correctly (use `createPreHeaderPanel` and `showPreHeaderPanel`).' ); } this._dropzoneElm.classList.add('slick-dropzone'); // add PubSub instance to all SlickEvent this.onGroupChanged.setPubSubService(this.pubSubService); // add optional group "Toggle All" with its button & text when provided if (!this._addonOptions.hideToggleAllButton) { this._groupToggler = createDomElement('div', { className: 'slick-group-toggle-all', title: this._addonOptions.toggleAllPlaceholderText ?? '', style: { display: 'none' }, }); const groupTogglerIconElm = createDomElement('span', { className: 'sgi slick-group-toggle-all-icon' }, this._groupToggler); if (this.gridOptions.enableTranslate && this._addonOptions.toggleAllButtonTextKey) { this._addonOptions.toggleAllButtonText = this.extensionUtility.translateWhenEnabledAndServiceExist( this._addonOptions.toggleAllButtonTextKey, 'TEXT_TOGGLE_ALL_GROUPS' ); } if (this.gridOptions.enableTranslate && this._addonOptions.toggleAllPlaceholderTextKey) { this._addonOptions.toggleAllPlaceholderText = this.extensionUtility.translateWhenEnabledAndServiceExist( this._addonOptions.toggleAllPlaceholderTextKey, 'TEXT_TOGGLE_ALL_GROUPS' ); } this._groupToggler.title = this._addonOptions.toggleAllPlaceholderText ?? ''; if (this._addonOptions.toggleAllButtonText) { this._groupToggler.appendChild( createDomElement('span', { className: 'slick-group-toggle-all-text', textContent: this._addonOptions.toggleAllButtonText || '', }) ); } this._dropzoneElm.appendChild(this._groupToggler); // when calling Expand/Collapse All Groups from Context Menu, we also need to inform this plugin as well of the action this._subscriptions.push( this.pubSubService.subscribe('onContextMenuCollapseAllGroups', () => this.toggleGroupToggler(groupTogglerIconElm, true, false)), this.pubSubService.subscribe('onContextMenuExpandAllGroups', () => this.toggleGroupToggler(groupTogglerIconElm, false, false)) ); } this._dropzonePlaceholderElm = createDomElement('div', { className: 'slick-draggable-dropzone-placeholder' }, this._dropzoneElm); if (this.gridOptions.enableTranslate && this._addonOptions?.dropPlaceHolderTextKey) { this._addonOptions.dropPlaceHolderText = this.extensionUtility.translateWhenEnabledAndServiceExist( this._addonOptions.dropPlaceHolderTextKey, 'TEXT_TOGGLE_ALL_GROUPS' ); } this._dropzonePlaceholderElm.textContent = this._addonOptions?.dropPlaceHolderText ?? this._defaults.dropPlaceHolderText ?? ''; this.setupColumnDropbox(); this._eventHandler.subscribe(grid.onHeaderCellRendered, (_e, args) => { const node = args.node; if (!isEmptyObject(args.column?.grouping) && node) { node.style.cursor = 'pointer'; // add the pointer cursor on each column title // also optionally add an icon beside each column title that can be dragged if (this._addonOptions.groupIconCssClass) { const groupableIconElm = createDomElement('span', { className: 'slick-column-groupable' }, node); if (this._addonOptions.groupIconCssClass) { groupableIconElm.classList.add(...classNameToList(this._addonOptions.groupIconCssClass)); } } } }); // when calling Clear All Groups from Context Menu, we also need to inform this plugin as well of the action this._subscriptions.push(this.pubSubService.subscribe('onContextMenuClearGrouping', () => this.clearDroppedGroups())); for (const col of this._gridColumns) { const columnId = col.field; grid.updateColumnHeader(columnId); } } return this; } /** Dispose the plugin. */ dispose(): void { this.destroySortableInstances(); if (this._droppableInstance?.el) { this._droppableInstance?.destroy(); } this.onGroupChanged.unsubscribe(); this._eventHandler.unsubscribeAll(); this.pubSubService.unsubscribeAll(this._subscriptions); this._bindingEventService.unbindAll(); emptyElement(this.gridContainer.querySelector(`.${this.gridUid} .slick-preheader-panel,.${this.gridUid} .slick-topheader-panel`)); } clearDroppedGroups(): void { this.columnsGroupBy = []; this.updateGroupBy('clear-all'); const allDroppedGroupingElms = this._dropzoneElm.querySelectorAll('.slick-dropped-grouping'); for (const groupElm of Array.from(allDroppedGroupingElms)) { const groupRemoveBtnElm = this._dropzoneElm.querySelector('.slick-groupby-remove'); groupRemoveBtnElm?.remove(); groupElm?.remove(); } // show placeholder text & hide the "Toggle All" when that later feature is enabled this._dropzonePlaceholderElm.style.display = 'inline-block'; if (this._groupToggler) { this._groupToggler.style.display = 'none'; } } destroySortableInstances(): void { if (this._sortableLeftInstance?.el) { this._sortableLeftInstance?.destroy(); } if (this._sortableRightInstance?.el) { this._sortableRightInstance?.destroy(); } } setAddonOptions(options: Partial): void { this._addonOptions = { ...this._addonOptions, ...options }; } setColumns(cols: Column[]): void { this._gridColumns = cols; } setDroppedGroups(groupingInfo: Array | string): void { this._dropzonePlaceholderElm.style.display = 'none'; const groupingInfos = Array.isArray(groupingInfo) ? groupingInfo : [groupingInfo]; for (const groupInfo of groupingInfos) { const columnElm = this.grid.getHeaderColumn(groupInfo as string); this.handleGroupByDrop(this._dropzoneElm, columnElm); } } /** * Setup the column reordering * NOTE: this function is a standalone function and is called externally and does not have access to `this` instance * @param grid - slick grid object * @param headers - slick grid column header elements * @param _headerColumnWidthDiff - header column width difference * @param setColumns - callback to reassign columns * @param setupColumnResize - callback to setup the column resize * @param columns - columns array * @param getColumnIndex - callback to find index of a column * @param uid - grid UID * @param trigger - callback to execute when triggering a column grouping */ 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 ): { sortableLeftInstance: Sortable; sortableRightInstance: Sortable; } { this.destroySortableInstances(); const dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); const groupTogglerElm = dropzoneElm.querySelector('.slick-group-toggle-all'); const sortableOptions = { animation: 50, ghostClass: 'slick-sortable-placeholder', draggable: '.slick-header-column', dataIdAttr: 'data-id', group: { name: 'shared', pull: 'clone', put: false, }, revertClone: true, // filter: function (_e, target) { // // block column from being able to be dragged if it's already a grouped column // // NOTE: need to disable for now since it also blocks the column reordering // return columnsGroupBy.some(c => c.id === target.getAttribute('data-id')); // }, onStart: (e) => { e.item.classList.add('slick-header-column-active'); if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'inline-block'; } const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); droppedGroupingElms.forEach((droppedGroupingElm) => (droppedGroupingElm.style.display = 'none')); if (groupTogglerElm) { groupTogglerElm.style.display = 'none'; } }, onEnd: (e) => { e.item.classList.remove('slick-header-column-active'); dropzoneElm?.classList.remove('slick-dropzone-hover'); draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); droppedGroupingElms.forEach((droppedGroupingElm) => (droppedGroupingElm.style.display = 'flex')); if (droppedGroupingElms.length) { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'none'; } if (groupTogglerElm) { groupTogglerElm.style.display = 'inline-flex'; } } if (!grid.getEditorLock().commitCurrentEdit()) { return; } const reorderedIds = this.sortableLeftInstance?.toArray() ?? []; // when frozen columns are used, headers has more than one entry and we need the ids from all of them. // though there is only really a left and right header, this will work even if that should change. if (headers.length > 1) { const ids = this._sortableRightInstance?.toArray() ?? []; // Note: the loop below could be simplified with: // reorderedIds.push.apply(reorderedIds,ids); // However, the loop is more in keeping with way-backward compatibility for (const id of ids) { reorderedIds.push(id); } } const finalReorderedColumns: Column[] = []; const reorderedColumns = grid.getColumns(); for (const reorderedId of reorderedIds) { finalReorderedColumns.push(reorderedColumns[getColumnIndex.call(grid, reorderedId)]); } setColumns.call(grid, finalReorderedColumns); trigger.call(grid, grid.onColumnsReordered, { grid, impactedColumns: finalReorderedColumns }); e.stopPropagation(); setupColumnResize.call(grid); }, } as SortableOptions; this._sortableLeftInstance = Sortable.create( this.gridContainer.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-left`) as HTMLDivElement, sortableOptions ); this._sortableRightInstance = Sortable.create( this.gridContainer.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-right`) as HTMLDivElement, sortableOptions ); // user can optionally provide initial groupBy columns const initialGroupIds = this._addonOptions.initialGroupBy ?? this.gridOptions.presets?.grouping; if (initialGroupIds && !this._isInitialized) { this.setDroppedGroups(initialGroupIds); } this._isInitialized = true; return { sortableLeftInstance: this._sortableLeftInstance, sortableRightInstance: this._sortableRightInstance, }; } // // protected functions // ------------------ protected addColumnGroupBy(column: Column): void { this.columnsGroupBy.push(column); this.updateGroupBy('add-group'); } protected addGroupByRemoveClickHandler( id: string | number, groupRemoveIconElm: HTMLDivElement, headerColumnElm: HTMLDivElement, entry: any ): void { this._bindingEventService.bind(groupRemoveIconElm, 'click', () => { const boundedElms = this._bindingEventService.boundedEvents.filter( (boundedEvent: any) => boundedEvent.element === groupRemoveIconElm ); for (const boundedEvent of boundedElms) { this._bindingEventService.unbind(boundedEvent.element, 'click', boundedEvent.listener); } this.removeGroupBy(id, headerColumnElm, entry); }); } protected addGroupSortClickHandler(col: Column, groupSortContainerElm: HTMLDivElement): void { const { grouping, type } = col; this._bindingEventService.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): void { if (sortAsc) { // ascending icon if (this._addonOptions.sortAscIconCssClass) { groupSortContainerElm.classList.remove(...classNameToList(this._addonOptions.sortDescIconCssClass)); groupSortContainerElm.classList.add(...classNameToList(this._addonOptions.sortAscIconCssClass)); } 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(...classNameToList(this._addonOptions.sortAscIconCssClass)); groupSortContainerElm.classList.add(...classNameToList(this._addonOptions.sortDescIconCssClass)); } else { if (!this._addonOptions.sortDescIconCssClass) { groupSortContainerElm.classList.add('slick-groupby-sort-desc-icon'); groupSortContainerElm.classList.remove('slick-groupby-sort-asc-icon'); } } } } protected handleGroupByDrop(containerElm: HTMLDivElement, headerColumnElm: HTMLDivElement): void { const columnId = headerColumnElm.getAttribute('data-id')?.replace(this._gridUid, ''); let columnAllowed = true; for (const colGroupBy of this.columnsGroupBy) { if (colGroupBy.id === columnId) { columnAllowed = false; } } if (columnAllowed) { for (const col of this._gridColumns) { if (col.id === columnId && col.grouping && !isEmptyObject(col.grouping)) { const columnNameElm = headerColumnElm.querySelector('.slick-column-name'); const entryElm = createDomElement('div', { id: `${this._gridUid}_${col.id}_entry`, className: 'slick-dropped-grouping', dataset: { id: `${col.id}` }, }); createDomElement( 'div', { className: 'slick-dropped-grouping-title', style: { display: 'inline-flex' }, textContent: columnNameElm ? columnNameElm.textContent : headerColumnElm.textContent, }, entryElm ); // delete icon const groupRemoveIconElm = createDomElement('div', { className: 'slick-groupby-remove' }); if (this._addonOptions.deleteIconCssClass) { groupRemoveIconElm.classList.add(...classNameToList(this._addonOptions.deleteIconCssClass)); } if (!this._addonOptions.deleteIconCssClass) { groupRemoveIconElm.classList.add('slick-groupby-remove-icon'); } // sorting icons when enabled let groupSortContainerElm: HTMLDivElement | undefined; if (this._addonOptions?.hideGroupSortIcons !== true && col.sortable) { if (col.grouping?.sortAsc === undefined) { col.grouping.sortAsc = true; } groupSortContainerElm = createDomElement('div', { className: 'slick-groupby-sort' }, entryElm); this.getGroupBySortIcon(groupSortContainerElm, col.grouping.sortAsc); } 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) { this.toggleGroupAll(col); } this.addColumnGroupBy(col); this.addGroupByRemoveClickHandler(col.id, groupRemoveIconElm, headerColumnElm, entryElm); // when Sorting group is enabled, let's add all handlers if (groupSortContainerElm) { this.addGroupSortClickHandler(col, groupSortContainerElm); } } } // show the "Toggle All" when feature is enabled if (this._groupToggler && this.columnsGroupBy.length > 0) { this._groupToggler.style.display = 'inline-flex'; } } } protected toggleGroupAll({ grouping }: Column, collapsed?: boolean): void { const togglerIcon = this._groupToggler?.querySelector('.slick-group-toggle-all-icon'); const isCollapsed = collapsed === true || grouping?.collapsed; togglerIcon?.classList.toggle('collapsed', isCollapsed); togglerIcon?.classList.toggle('expanded', !isCollapsed); } protected removeFromArray(arrayToModify: any[], itemToRemove: any): any[] { if (Array.isArray(arrayToModify)) { const itemIdx = arrayToModify.findIndex((a) => a.id === itemToRemove.id); if (itemIdx >= 0) { arrayToModify.splice(itemIdx, 1); } } return arrayToModify; } protected removeGroupBy(id: string | number, _hdrColumnElm: HTMLDivElement, entry: any): void { entry.remove(); const groupByColumns: Column[] = []; this._gridColumns.forEach((col) => (groupByColumns[col.id as number] = col)); this.removeFromArray(this.columnsGroupBy, groupByColumns[id as any]); if (this.columnsGroupBy.length === 0) { // show placeholder text & hide the "Toggle All" when that later feature is enabled this._dropzonePlaceholderElm.style.display = 'inline-block'; if (this._groupToggler) { this._groupToggler.style.display = 'none'; } } this.updateGroupBy('remove-group'); } protected addDragOverDropzoneListeners(): void { const draggablePlaceholderElm = this._dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); if (draggablePlaceholderElm && this._dropzoneElm) { this._bindingEventService.bind(draggablePlaceholderElm, 'dragover', (e: Event) => e.preventDefault()); this._bindingEventService.bind(draggablePlaceholderElm, 'dragenter', () => this._dropzoneElm.classList.add('slick-dropzone-hover')); this._bindingEventService.bind(draggablePlaceholderElm, 'dragleave', () => this._dropzoneElm.classList.remove('slick-dropzone-hover') ); } } protected setupColumnDropbox(): void { const dropzoneElm = this._dropzoneElm; this._droppableInstance = Sortable.create(dropzoneElm, { group: 'shared', ghostClass: 'slick-droppable-sortitem-hover', draggable: '.slick-dropped-grouping', dragoverBubble: true, onAdd: (evt: SortableEvent) => { const el = evt.item as HTMLDivElement; if (el.getAttribute('id')?.replace(this._gridUid, '')) { // use Sortable's clone method to properly clone the element but add the missing clone() type definition (it's missing in `@types/sortablejs`) const clonedElm = (Sortable.utils as Sortable.Utils & { clone(el: T): T }).clone( evt.item as HTMLDivElement ); this.handleGroupByDrop(dropzoneElm, clonedElm); } el.parentNode?.removeChild(el); }, onUpdate: () => { const sortArray = this._droppableInstance?.toArray() ?? []; const newGroupingOrder: Column[] = []; for (const sortGroupId of sortArray) { for (const groupByColumn of this.columnsGroupBy) { if (groupByColumn.id === sortGroupId) { newGroupingOrder.push(groupByColumn); break; } } } this.columnsGroupBy = newGroupingOrder; this.updateGroupBy('sort-group'); }, } as SortableOptions); // Sortable doesn't have onOver, we need to implement it ourselves this.addDragOverDropzoneListeners(); if (this._groupToggler) { this._bindingEventService.bind(this._groupToggler, 'click', ((event: DOMMouseOrTouchEvent) => { // prettier-ignore const target = event.target.classList.contains('slick-group-toggle-all-icon') ? event.target : event.currentTarget.querySelector('.slick-group-toggle-all-icon'); this.toggleGroupToggler(target, target?.classList.contains('expanded')); }) as EventListener); } } protected toggleGroupToggler(targetElm: Element | null, collapsing = true, shouldExecuteDataViewCommand = true): void { if (targetElm) { const isCollapsing = collapsing === true; targetElm.classList.toggle('collapsed', isCollapsing); targetElm.classList.toggle('expanded', !isCollapsing); if (shouldExecuteDataViewCommand) { isCollapsing ? this.dataView.collapseAllGroups() : this.dataView.expandAllGroups(); } } } protected updateGroupBy(originator: string): void { if (this.columnsGroupBy.length === 0) { this.dataView.setGrouping([]); this._dropzonePlaceholderElm.style.display = 'inline-block'; this.triggerOnGroupChangedEvent({ caller: originator, groupColumns: [] }); return; } const groupingArray: Grouping[] = []; this.columnsGroupBy.forEach((element) => groupingArray.push(element.grouping!)); this.dataView.setGrouping(groupingArray); this._dropzonePlaceholderElm.style.display = 'none'; this.triggerOnGroupChangedEvent({ caller: originator, groupColumns: groupingArray }); } /** call notify on slickgrid event and execute onGroupChanged callback when defined as a function by the user */ protected triggerOnGroupChangedEvent(args: { caller?: string; groupColumns: Grouping[] }): void { if (this._addonOptions && typeof this._addonOptions.onGroupChanged === 'function') { this._addonOptions.onGroupChanged(new SlickEventData(), args); } this.onGroupChanged.notify(args); } }