slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
513 lines (455 loc) • 22 kB
text/typescript
// @ts-ignore
import type { SortableEvent, SortableInstance, SortableOptions } from 'sortablejs';
import type { Column, DOMMouseOrTouchEvent, DraggableGroupingOption, Grouping, GroupingGetterFunction } from '../models/index.js';
import { BindingEventService as BindingEventService_, SlickEvent as SlickEvent_, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core.js';
import type { SlickDataView } from '../slick.dataview.js';
import type { SlickGrid } from '../slick.grid.js';
// for (iife) load Slick methods from global Slick object, or use imports for (esm)
const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_;
const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_;
const SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_;
const Utils = IIFE_ONLY ? Slick.Utils : Utils_;
/**
*
* Draggable Grouping contributed by: Muthukumar Selvarasu
* 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 Draggable Grouping feature.
*
* USAGE:
*
* Add the plugin .js & .css files and register it with the grid.
*
*
* The plugin expose the following methods:
* destroy: used to destroy the plugin
* setDroppedGroups: provide option to set default grouping on loading
* clearDroppedGroups: provide option to clear grouping
* getSetupColumnReorder: its function to setup draggable feature agains Header Column, should be passed on grid option. Also possible to pass custom function
*
*
* The plugin expose the following event(s):
* onGroupChanged: pass the grouped columns to who subscribed.
*
*/
export class SlickDraggableGrouping {
// --
// public API
pluginName = 'DraggableGrouping' as const;
onGroupChanged = new SlickEvent<{ caller?: string; groupColumns: Grouping[]; }>('onGroupChanged');
// --
// protected props
protected _grid!: SlickGrid;
protected _gridUid = '';
protected _gridColumns: Column[] = [];
protected _dataView!: SlickDataView;
protected _dropzoneElm!: HTMLDivElement;
protected _droppableInstance?: SortableInstance;
protected _dropzonePlaceholder!: HTMLDivElement;
protected _groupToggler?: HTMLDivElement;
protected _isInitialized = false;
protected _options: DraggableGroupingOption;
protected _defaults: DraggableGroupingOption = {
dropPlaceHolderText: 'Drop a column header here to group by the column',
hideGroupSortIcons: false,
hideToggleAllButton: false,
toggleAllButtonText: '',
toggleAllPlaceholderText: 'Toggle all Groups',
};
protected _bindingEventService = new BindingEventService();
protected _handler = new SlickEventHandler();
protected _sortableLeftInstance?: SortableInstance;
protected _sortableRightInstance?: SortableInstance;
protected _columnsGroupBy: Column[] = [];
/**
* @param options {Object} Options:
* deleteIconCssClass: an extra CSS class to add to the delete button (default undefined), if deleteIconCssClass && deleteIconImage undefined then slick-groupby-remove-image class will be added
* deleteIconImage: a url to the delete button image (default undefined)
* groupIconCssClass: an extra CSS class to add to the grouping field hint (default undefined)
* groupIconImage: a url to the grouping field hint image (default undefined)
* dropPlaceHolderText: option to specify set own placeholder note text
*/
constructor(options: Partial<DraggableGroupingOption>) {
this._options = Utils.extend(true, {}, this._defaults, options);
}
/**
* Initialize plugin.
*/
init(grid: SlickGrid) {
this._grid = grid;
Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this);
this._gridUid = this._grid.getUID();
this._gridColumns = this._grid.getColumns();
this._dataView = this._grid.getData();
this._dropzoneElm = this._grid.getTopHeaderPanel() || this._grid.getPreHeaderPanel();
if (!this._dropzoneElm) {
throw new Error(
'[Slickgrid] 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');
const dropPlaceHolderText = this._options.dropPlaceHolderText || 'Drop a column header here to group by the column';
this._dropzonePlaceholder = document.createElement('div');
this._dropzonePlaceholder.className = 'slick-placeholder';
this._dropzonePlaceholder.textContent = dropPlaceHolderText;
this._groupToggler = document.createElement('div');
this._groupToggler.className = 'slick-group-toggle-all expanded';
this._groupToggler.style.display = 'none';
this._dropzoneElm.appendChild(this._dropzonePlaceholder);
this._dropzoneElm.appendChild(this._groupToggler);
this.setupColumnDropbox();
this._handler.subscribe(this._grid.onHeaderCellRendered, (_e, args) => {
const column = args.column;
const node = args.node;
if (!Utils.isEmptyObject(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._options.groupIconCssClass || this._options.groupIconImage) {
const groupableIconElm = document.createElement('span');
groupableIconElm.className = 'slick-column-groupable';
if (this._options.groupIconCssClass) {
groupableIconElm.classList.add(...Utils.classNameToList(this._options.groupIconCssClass));
}
if (this._options.groupIconImage) {
groupableIconElm.style.background = `url(${this._options.groupIconImage}) no-repeat center center`;
}
node.appendChild(groupableIconElm);
}
}
});
for (let i = 0; i < this._gridColumns.length; i++) {
const columnId = this._gridColumns[i].field;
this._grid.updateColumnHeader(columnId);
}
}
/**
* 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
*/
getSetupColumnReorder(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.getTopHeaderPanel() || grid.getPreHeaderPanel();
const groupTogglerElm = dropzoneElm.querySelector<HTMLDivElement>('.slick-group-toggle-all');
const sortableOptions = {
animation: 50,
// chosenClass: 'slick-header-column-active',
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 this.columnsGroupBy.some(c => c.id === target.getAttribute('data-id'));
// },
onStart: (e: SortableEvent) => {
e.item.classList.add('slick-header-column-active');
dropzoneElm.classList.add('slick-dropzone-hover');
dropzoneElm.classList.add('slick-dropzone-placeholder-hover');
const draggablePlaceholderElm = dropzoneElm.querySelector<HTMLDivElement>('.slick-placeholder');
if (draggablePlaceholderElm) {
draggablePlaceholderElm.style.display = 'inline-block';
}
const droppedGroupingElms = dropzoneElm.querySelectorAll<HTMLDivElement>('.slick-dropped-grouping');
droppedGroupingElms.forEach(droppedGroupingElm => droppedGroupingElm.style.display = 'none');
if (groupTogglerElm) {
groupTogglerElm.style.display = 'none';
}
},
onEnd: (e: SortableEvent) => {
e.item.classList.remove('slick-header-column-active');
const draggablePlaceholderElm = dropzoneElm.querySelector<HTMLDivElement>('.slick-placeholder');
dropzoneElm.classList.remove('slick-dropzone-hover');
draggablePlaceholderElm?.classList.remove('slick-dropzone-placeholder-hover');
if (this._dropzonePlaceholder) {
this._dropzonePlaceholder.style.display = 'none';
}
if (draggablePlaceholderElm) {
draggablePlaceholderElm.parentElement?.classList.remove('slick-dropzone-placeholder-hover');
}
const droppedGroupingElms = dropzoneElm.querySelectorAll<HTMLDivElement>('.slick-dropped-grouping');
if (droppedGroupingElms.length) {
droppedGroupingElms.forEach(droppedGroupingElm => droppedGroupingElm.style.display = 'inline-flex');
if (draggablePlaceholderElm) {
draggablePlaceholderElm.style.display = 'none';
}
if (groupTogglerElm) {
groupTogglerElm.style.display = 'inline-block';
}
}
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(document.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-left`) as HTMLDivElement, sortableOptions);
this._sortableRightInstance = Sortable.create(document.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-right`) as HTMLDivElement, sortableOptions);
// user can optionally provide initial groupBy columns
if (this._options.initialGroupBy && !this._isInitialized) {
setTimeout(() => this.setDroppedGroups(this._options.initialGroupBy!));
}
this._isInitialized = true;
return {
sortableLeftInstance: this._sortableLeftInstance,
sortableRightInstance: this._sortableRightInstance
};
}
/**
* Destroy plugin.
*/
destroy() {
this.destroySortableInstances();
if (this._droppableInstance?.el) {
this._droppableInstance?.destroy();
}
this.onGroupChanged.unsubscribe();
this._handler.unsubscribeAll();
this._bindingEventService.unbindAll();
Utils.emptyElement(document.querySelector(`.${this._gridUid} .slick-preheader-panel,.${this._gridUid} .slick-topheader-panel`));
}
protected destroySortableInstances() {
if (this._sortableLeftInstance?.el) {
this._sortableLeftInstance?.destroy();
}
if (this._sortableRightInstance?.el) {
this._sortableRightInstance?.destroy();
}
}
protected addDragOverDropzoneListeners() {
const draggablePlaceholderElm = this._dropzoneElm.querySelector('.slick-placeholder');
if (draggablePlaceholderElm && this._dropzoneElm) {
this._bindingEventService.bind(draggablePlaceholderElm, 'dragover', (e) => 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() {
const dropzoneElm = this._dropzoneElm;
this._droppableInstance = Sortable.create(dropzoneElm, {
group: 'shared',
// chosenClass: 'slick-header-column-active',
ghostClass: 'slick-droppable-sortitem-hover',
draggable: '.slick-dropped-grouping',
dragoverBubble: true,
onAdd: (evt: MouseEvent & { item: any; clone: HTMLElement; originalEvent: MouseEvent; }) => {
const el = evt.item;
const elId = el.getAttribute('id');
if (elId?.replace(this._gridUid, '')) {
this.handleGroupByDrop(dropzoneElm, (Sortable.utils).clone(evt.item));
}
evt.clone.style.opacity = '.5';
el.parentNode?.removeChild(el);
},
onUpdate: () => {
const sortArray = this._droppableInstance?.toArray() ?? [];
const newGroupingOrder: Column[] = [];
for (let i = 0, l = sortArray.length; i < l; i++) {
for (let a = 0, b = this._columnsGroupBy.length; a < b; a++) {
if (this._columnsGroupBy[a].id === sortArray[i]) {
newGroupingOrder.push(this._columnsGroupBy[a]);
break;
}
}
}
this._columnsGroupBy = newGroupingOrder;
this.updateGroupBy('sort-group');
},
});
// Sortable doesn't have onOver, we need to implement it ourselves
this.addDragOverDropzoneListeners();
if (this._groupToggler) {
this._bindingEventService.bind(this._groupToggler, 'click', ((event: DOMMouseOrTouchEvent<HTMLDivElement>) => {
const target = event.target;
this.toggleGroupToggler(target, target?.classList.contains('expanded'));
}) as EventListener);
}
}
protected handleGroupByDrop(containerElm: HTMLDivElement, headerColumnElm: HTMLDivElement) {
const headerColDataId = headerColumnElm.getAttribute('data-id');
const columnId = headerColDataId?.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 && !Utils.isEmptyObject(col.grouping)) {
const columnNameElm = headerColumnElm.querySelector('.slick-column-name');
const entryElm = document.createElement('div');
entryElm.id = `${this._gridUid}_${col.id}_entry`;
entryElm.className = 'slick-dropped-grouping';
entryElm.dataset.id = `${col.id}`;
const groupTextElm = document.createElement('div');
groupTextElm.className = 'slick-dropped-grouping-title';
groupTextElm.style.display = 'inline-flex';
groupTextElm.textContent = columnNameElm ? columnNameElm.textContent : headerColumnElm.textContent;
entryElm.appendChild(groupTextElm);
// delete icon
const groupRemoveIconElm = document.createElement('div');
groupRemoveIconElm.className = 'slick-groupby-remove';
if (this._options.deleteIconCssClass) {
groupRemoveIconElm.classList.add(...Utils.classNameToList(this._options.deleteIconCssClass));
}
if (this._options.deleteIconImage) {
groupRemoveIconElm.classList.add(...Utils.classNameToList(this._options.deleteIconImage));
}
if (!this._options.deleteIconCssClass) {
groupRemoveIconElm.classList.add('slick-groupby-remove-icon');
}
if (!this._options.deleteIconCssClass && !this._options.deleteIconImage) {
groupRemoveIconElm.classList.add('slick-groupby-remove-image');
}
// sorting icons when enabled
if (this._options?.hideGroupSortIcons !== true && col.sortable) {
if (col.grouping?.sortAsc === undefined) {
col.grouping.sortAsc = true;
}
}
entryElm.appendChild(groupRemoveIconElm);
entryElm.appendChild(document.createElement('div'));
containerElm.appendChild(entryElm);
this.addColumnGroupBy(col);
this.addGroupByRemoveClickHandler(col.id, groupRemoveIconElm, headerColumnElm, entryElm);
}
}
// show the "Toggle All" when feature is enabled
if (this._groupToggler && this._columnsGroupBy.length > 0) {
this._groupToggler.style.display = 'inline-block';
}
}
}
protected addColumnGroupBy(column: Column) {
this._columnsGroupBy.push(column);
this.updateGroupBy('add-group');
}
protected addGroupByRemoveClickHandler(id: string | number, groupRemoveIconElm: HTMLDivElement, headerColumnElm: HTMLDivElement, entry: any) {
this._bindingEventService.bind(groupRemoveIconElm, 'click', () => {
const boundedElms = this._bindingEventService.getBoundedEvents().filter(boundedEvent => boundedEvent.element === groupRemoveIconElm);
for (const boundedEvent of boundedElms) {
this._bindingEventService.unbind(boundedEvent.element, 'click', boundedEvent.listener);
}
this.removeGroupBy(id, headerColumnElm, entry);
});
}
setDroppedGroups(groupingInfo: Array<string | GroupingGetterFunction> | string) {
const groupingInfos = Array.isArray(groupingInfo) ? groupingInfo : [groupingInfo];
this._dropzonePlaceholder.style.display = 'none';
for (const groupInfo of groupingInfos) {
const columnElm = this._grid.getHeaderColumn(groupInfo as string);
this.handleGroupByDrop(this._dropzoneElm, columnElm);
}
}
clearDroppedGroups() {
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._dropzonePlaceholder.style.display = 'inline-block';
if (this._groupToggler) {
this._groupToggler.style.display = 'none';
}
}
protected removeFromArray(arrayToModify: any[], itemToRemove: 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) {
entry.remove();
const groupby: Column[] = [];
this._gridColumns.forEach((col) => groupby[col.id as any] = col);
this.removeFromArray(this._columnsGroupBy, groupby[id as any]);
if (this._columnsGroupBy.length === 0) {
this._dropzonePlaceholder.style.display = 'block';
if (this._groupToggler) {
this._groupToggler.style.display = 'none';
}
}
this.updateGroupBy('remove-group');
}
protected toggleGroupToggler(targetElm: Element | null, collapsing = true, shouldExecuteDataViewCommand = true) {
if (targetElm) {
if (collapsing === true) {
targetElm.classList.add('collapsed');
targetElm.classList.remove('expanded');
if (shouldExecuteDataViewCommand) {
this._dataView.collapseAllGroups();
}
} else {
targetElm.classList.remove('collapsed');
targetElm.classList.add('expanded');
if (shouldExecuteDataViewCommand) {
this._dataView.expandAllGroups();
}
}
}
}
protected updateGroupBy(originator: string) {
if (this._columnsGroupBy.length === 0) {
this._dataView.setGrouping([]);
this.onGroupChanged.notify({ caller: originator, groupColumns: [] });
return;
}
const groupingArray: Grouping<any>[] = [];
this._columnsGroupBy.forEach((element) => groupingArray.push(element.grouping!));
this._dataView.setGrouping(groupingArray);
this.onGroupChanged.notify({ caller: originator, groupColumns: groupingArray });
}
}
// extend Slick namespace on window object when building as iife
if (IIFE_ONLY && window.Slick) {
Utils.extend(true, window, {
Slick: {
DraggableGrouping: SlickDraggableGrouping
}
});
}