Drag-and-drop column grouping for DataTables 1.10.x, 2.x and 3.x. An easy-to-use groupable extension intended as an alternative to the rowGroup extension by using the drag an drop native funct...

datatables.groupable.bootstrap.css

.eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dt-groupable-dropzone {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
}
.dt-groupable-dropzone-over {
background-color: var(--bs-primary-bg-subtle) !important;
border-color: var(--bs-primary) !important;
}
.dt-groupable-placeholder {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--bs-secondary-color);
}
.dt-groupable-chip {
cursor: grab;
user-select: none;
transition: opacity 0.12s ease, border-color 0.12s ease;
}
.dt-groupable-chip.dt-groupable-dragging {
opacity: 0.4;
}
.dt-groupable-chip.dt-groupable-chip-over {
border-color: var(--bs-primary) !important;
}
.dt-groupable-chip-order {
width: 1.1rem;
height: 1.1rem;
font-size: 0.65rem;
}
.dt-groupable-chip-remove {
line-height: 1;
padding: 0 0.15rem;
}
.dt-groupable-header {
cursor: grab;
position: relative;
}
.dt-header-content {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.dt-drag-handle {
color: var(--bs-secondary-color);
font-size: 0.7rem;
opacity: 0.6;
order: 2;
}
.dt-groupable-header::after, .dt-groupable-header::before {
/*content: '⠿';*/
position: absolute;
display: block;
left: 1.5rem;
top: 50%;
transform: translateY(-50%);
color: var(--bs-secondary-color);
font-size: 0.7rem;
opacity: 0.6;
}
.dt-groupable-header.dt-groupable-dragging {
opacity: 0.4;
}
tr.dt-group-row td {
padding-left: calc( 0.75rem + (var(--dt-group-level, 0) * 1.4rem) ) !important;
}
tr.dt-group-row.dt-group-collapsible {
cursor: pointer;
}
tr.dt-group-row .dt-group-label strong {
color: var(--bs-primary);
}
tr.dt-group-row .dt-group-count {
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
}
.dt-group-toggle {
display: inline-block;
width: 0.7rem;
margin-right: 0.4rem;
font-family: 'JetBrains Mono', monospace;
color: var(--bs-secondary-color);
}
.dt-group-toggle::before {
content: '▾';
}
tr.dt-group-collapsed .dt-group-toggle::before {
content: '▸';
}

datatables.groupable.js

'use strict';
/**
* datatables.groupable.js (No JQuery dependency)
*
* Drag-and-drop column grouping for DataTables 1.10.x, 2.x and 3.x.
*
* An easy-to-use groupable extension intended as an alternative to the rowGroup extension
* by using the drag an drop native functionality from browsers, make it easier to dinamically
* group records without the hassle of writing hardcoded code into the rowGroup methods.
*
* Usage:
*
* const table = new DataTable('#example');
*
* table.groupable({
* dropZone: '#zona-agrupacion', // Container where columns are dropped.
* emptyText: 'Drag a column here to group data', // Text shown when the container is empty.
* collapsible: true, // Whether groups can be collapsed.
* hideGroupedColumns: false, // Whether grouped columns remain visible.
* excludeColumns: [], // Columns excluded from grouping.
* groupLabel: (col, value, count, level) => `${col.title()}: ${value} (${count})`, // Template for each group's label.
* stateSave: false, // Whether the grouping state is persisted.
* stateSaveKey: null, // Custom key used to identify the persisted state (overrides the default).
* stateIncludeCollapsed: true, // Whether collapsed state is included when persisting.
* stateSaveCallback: null, // (state, dt) => void | Promise — called to persist the state. Defaults to localStorage if not provided.
* stateLoadCallback: null, // (dt) => state | Promise — called to load the state. Defaults to localStorage if not provided.
* onChange: (groupColumnIndexes, dt) => {} // Called whenever the grouping changes.
* });
*/
const DEFAULTS = {
dropZone: null,
excludeColumns: [],
collapsible: true,
hideGroupedColumns: false,
emptyText: 'Drag a column here to group data',
groupLabel: null,
onChange: null,
stateSave: false,
stateSaveKey: null,
stateIncludeCollapsed: true,
stateSaveCallback: null,
stateLoadCallback: null
};
class Groupable {
#dt;
#options;
#dropZone;
#groupColumns = [];
#collapsedKeys = new Set();
#previousPageLength = null;
#dragChipFrom = null;
#dragSourceColumn = null;
#headerAbortController = new AbortController();
#dropZoneAbortController = null;
#ready; // Indicates that the promise object has been resolved when the state object is loaded or persisted.
constructor(dtApi, options) {
this.#dt = dtApi;
this.#options = Object.assign({}, DEFAULTS, options);
if (!this.#options.dropZone)
throw new Error(
'groupable(): "dropZone" option is required.'
);
this.#dropZone =
typeof this.#options.dropZone === 'string'
? document.querySelector(this.#options.dropZone)
: this.#options.dropZone;
if (!this.#dropZone)
throw new Error(
'groupable(): "dropZone" selector not found.'
);
this.#renderChips();
this.#bindHeaders();
this.#dt.on('draw.dtGroupable', () => this.#renderGroups());
this.#renderGroups();
this.#ready = this.#loadState();
}
#createElementFromHTML(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstElementChild;
}
clear() {
if (!this.#groupColumns.length) return;
this.#groupColumns.forEach((idx) => {
if (this.#options.hideGroupedColumns)
this.#dt.column(idx).visible(true, false);
});
this.#groupColumns = [];
this.#collapsedKeys.clear();
this.#restorePageLength();
this.#renderChips();
this.#dt.draw(false);
this.#notifyChange();
this.#saveState();
}
getState() {
return this.#buildStateObject();
}
ready() {
return this.#ready;
}
async setState(state) {
this.#applyStateObject(state);
if (this.#groupColumns.length) {
this.#renderChips();
this.#applyOrderAndDraw();
} else {
this.#renderChips();
this.#renderGroups();
}
this.#notifyChange();
}
destroy() {
this.clear();
this.#dt.off('draw.dtGroupable');
this.#headerAbortController.abort();
this.#dropZoneAbortController?.abort();
this.#dropZone.innerHTML = '';
const headerRow = this.#dt.table().header();
headerRow.querySelectorAll('th').forEach((th) => {
th.removeAttribute('draggable');
th.classList.remove('dt-groupable-header', 'dt-groupable-dragging');
});
}
/***
* Binds the datatables header to drag and drop events
* @returns {object}
*/
#bindHeaders() {
const headerRow = this.#dt.table().header();
const { signal } = this.#headerAbortController;
headerRow.querySelectorAll('th').forEach((th) => {
let colIndex;
try {
colIndex = this.#dt.column(th).index();
} catch (e) {
return;
}
if (this.#options.excludeColumns.includes(colIndex)) return;
th.setAttribute('draggable', 'true');
th.classList.add('dt-groupable-header');
th.addEventListener(
'dragstart',
(e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', String(colIndex));
this.#dragSourceColumn = colIndex;
th.classList.add('dt-groupable-dragging');
},
{ signal }
);
th.addEventListener(
'dragend',
() => {
th.classList.remove('dt-groupable-dragging');
this.#dragSourceColumn = null;
},
{ signal }
);
});
}
/***
* Renders the groupable chips on dropzone container
* and binds drag and drop events.
* @returns {void}
*/
#renderChips() {
const zone = this.#dropZone;
this.#dropZoneAbortController?.abort();
this.#dropZoneAbortController = new AbortController();
const { signal } = this.#dropZoneAbortController;
zone.innerHTML = '';
zone.classList.add('dt-groupable-dropzone');
if (!this.#groupColumns.length) {
const placeholder = document.createElement('span');
placeholder.className = 'dt-groupable-placeholder';
placeholder.textContent = this.#options.emptyText;
zone.append(placeholder);
} else {
this.#groupColumns.forEach((colIndex, position) => {
const title = this.#dt.column(colIndex).title();
const chip = this.#createElementFromHTML(`
${position + 1}
</span>
`);
chip.querySelector('.dt-groupable-chip-label').textContent = title;
chip.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(position));
this.#dragChipFrom = position;
chip.classList.add('dt-groupable-dragging');
});
chip.addEventListener('dragend', () => {
chip.classList.remove('dt-groupable-dragging');
this.#dragChipFrom = null;
});
chip.addEventListener('dragover', (e) => {
if (this.#dragChipFrom === null) return;
e.preventDefault();
chip.classList.add('dt-groupable-chip-over');
});
chip.addEventListener('dragleave', () =>
chip.classList.remove('dt-groupable-chip-over')
);
chip.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
chip.classList.remove('dt-groupable-chip-over');
if (this.#dragChipFrom === null) return;
this.#reorderGroupColumns(this.#dragChipFrom, position);
});
chip
.querySelector('.dt-groupable-chip-remove')
.addEventListener('click', () =>
this.#removeGroupColumn(colIndex)
);
zone.append(chip);
});
}
zone.addEventListener(
'dragover',
(e) => {
e.preventDefault();
e.dataTransfer.dropEffect =
this.#dragChipFrom === null ? 'copy' : 'move';
zone.classList.add('dt-groupable-dropzone-over');
},
{ signal }
);
zone.addEventListener(
'dragleave',
(e) => {
if (e.target === zone)
zone.classList.remove('dt-groupable-dropzone-over');
},
{ signal }
);
zone.addEventListener(
'drop',
(e) => {
e.preventDefault();
zone.classList.remove('dt-groupable-dropzone-over');
if (this.#dragChipFrom !== null) return;
const colIndex = this.#dragSourceColumn;
if (colIndex === null || colIndex === undefined) return;
this.#addGroupColumn(colIndex);
},
{ signal }
);
}
#reorderGroupColumns(fromPos, toPos) {
if (fromPos === toPos) return;
const [moved] = this.#groupColumns.splice(fromPos, 1);
this.#groupColumns.splice(toPos, 0, moved);
this.#renderChips();
this.#applyOrderAndDraw();
this.#notifyChange();
this.#saveState();
}
#addGroupColumn(colIndex) {
if (this.#groupColumns.includes(colIndex)) return;
if (!this.#groupColumns.length) {
this.#previousPageLength = this.#dt.page.len();
}
this.#groupColumns.push(colIndex);
if (this.#options.hideGroupedColumns)
this.#dt.column(colIndex).visible(false, false);
this.#renderChips();
this.#applyOrderAndDraw();
this.#notifyChange();
this.#saveState();
}
#removeGroupColumn(colIndex) {
this.#groupColumns = this.#groupColumns.filter(
(c) => c !== colIndex
);
if (this.#options.hideGroupedColumns)
this.#dt.column(colIndex).visible(true, false);
if (!this.#groupColumns.length) {
this.#restorePageLength();
this.#collapsedKeys.clear();
}
this.#renderChips();
this.#applyOrderAndDraw();
this.#notifyChange();
this.#saveState();
}
#restorePageLength() {
if (this.#previousPageLength !== null) {
this.#dt.page.len(this.#previousPageLength);
this.#previousPageLength = null;
}
}
/**
* Applies the datatables draw() method. When using server-side option
* this will make a round-trip to server
* @returns {void}
*/
#applyOrderAndDraw() {
const order = this.#groupColumns.map((idx) => [idx, 'asc']);
this.#dt.order(order).draw();
}
/**
* Dispatches the onChange callback to clients.
* @returns {void}
*/
#notifyChange() {
this.#options.onChange?.(this.#groupColumns.slice(), this.#dt);
}
#renderGroups() {
const tbody = this.#dt.table().body();
tbody.querySelectorAll('tr.dt-group-row').forEach((tr) => tr.remove());
tbody.querySelectorAll('tr').forEach((tr) => {
tr.style.display = '';
});
if (!this.#groupColumns.length) return;
const dt = this.#dt;
const indexes = dt
.rows({ order: 'current', search: 'applied', page: 'all' })
.indexes()
.toArray();
if (!indexes.length) return;
const colSpan = dt.columns(':visible').count();
const levels = this.#groupColumns.length;
let prevKeyParts = null;
const collapseStack = [];
for (let i = 0; i < indexes.length; i++) {
const rowIdx = indexes[i];
const keyParts = this.#groupColumns.map((colIdx) =>
dt.cell(rowIdx, colIdx).data()
);
let changedAt = prevKeyParts === null ? 0 : levels;
if (prevKeyParts !== null) {
for (let level = 0; level < levels; level++) {
if (String(keyParts[level]) !== String(prevKeyParts[level])) {
changedAt = level;
break;
}
}
}
for (let level = changedAt; level < levels; level++) {
const groupKey =
keyParts
.slice(0, level + 1)
.map(String)
.join('␟') +
'␟L' +
level;
const count = this.#countGroupSize(indexes, i, level);
const colIdx = this.#groupColumns[level];
const columnApi = dt.column(colIdx);
const value = keyParts[level];
collapseStack.length = level;
const isCollapsed = this.#collapsedKeys.has(groupKey);
collapseStack[level] = isCollapsed;
const ancestorsCollapsed = collapseStack
.slice(0, level)
.some(Boolean);
const groupRow = this.#buildGroupRow({
level,
colSpan,
columnApi,
value,
count,
groupKey,
isCollapsed,
});
if (ancestorsCollapsed) groupRow.style.display = 'none';
dt.row(rowIdx).node().before(groupRow);
}
const rowHidden = collapseStack.some(Boolean);
if (rowHidden) dt.row(rowIdx).node().style.display = 'none';
prevKeyParts = keyParts;
}
}
#countGroupSize(indexes, startPos, level) {
const dt = this.#dt;
const refParts = this.#groupColumns
.slice(0, level + 1)
.map((colIdx) =>
String(dt.cell(indexes[startPos], colIdx).data())
);
let count = 0;
for (let i = startPos; i < indexes.length; i++) {
const parts = this.#groupColumns
.slice(0, level + 1)
.map((colIdx) => String(dt.cell(indexes[i], colIdx).data()));
if (parts.join('␟') !== refParts.join('␟')) break;
count++;
}
return count;
}
#buildGroupRow({
level,
colSpan,
columnApi,
value,
count,
groupKey,
isCollapsed,
}) {
const label =
typeof this.#options.groupLabel === 'function'
? this.#options.groupLabel(columnApi, value, count, level)
: `${columnApi.title()}: ${value} (${count})`;
const tr = document.createElement('tr');
tr.className = 'dt-group-row table-light';
tr.setAttribute('data-group-key', groupKey);
tr.setAttribute('data-level', level);
tr.style.setProperty('--dt-group-level', level);
const td = document.createElement('td');
td.setAttribute('colspan', colSpan);
const toggle = document.createElement('span');
toggle.className = 'dt-group-toggle';
const labelSpan = document.createElement('span');
labelSpan.className = 'dt-group-label';
labelSpan.innerHTML = label;
td.append(toggle, labelSpan);
tr.append(td);
if (this.#options.collapsible) {
tr.classList.add('dt-group-collapsible');
if (isCollapsed) tr.classList.add('dt-group-collapsed');
tr.addEventListener('click', () => this.#toggleGroup(groupKey));
}
return tr;
}
#toggleGroup(groupKey) {
if (this.#collapsedKeys.has(groupKey))
this.#collapsedKeys.delete(groupKey);
else this.#collapsedKeys.add(groupKey);
this.#renderGroups();
if (this.#options.stateIncludeCollapsed) this.#saveState();
}
/**
* Returns the state key for state persistence.
* @returns {string} The unique state key, built from the custom
*/
#stateKey() {
if (this.#options.stateSaveKey) return this.#options.stateSaveKey;
const tableId = this.#dt.table().node().id || 'dt';
return `groupable_${tableId}_${location.pathname}`;
}
/**
* Build the state object for state persistence.
* @returns {object}
*/
#buildStateObject() {
return {
columns: this.#groupColumns.map((idx) => ({ index: idx, title: this.#dt.column(idx).title() })),
collapsed: this.#options.stateIncludeCollapsed ? Array.from(this.#collapsedKeys) : []
};
}
/**
* Applies the state object issued from persistence storage
* to the dropzone container
* @returns {void}
*/
#applyStateObject(state) {
const totalCols = this.#dt.columns().count();
const columns = Array.isArray(state?.columns) ? state.columns : [];
const indexes = columns
.map((c) => (typeof c === 'object' && c !== null ? c.index : c))
.filter((idx) => Number.isInteger(idx) && idx >= 0 && idx < totalCols);
if (this.#groupColumns.length && this.#options.hideGroupedColumns) {
this.#groupColumns.forEach((idx) => this.#dt.column(idx).visible(true, false));
}
this.#groupColumns = indexes;
this.#collapsedKeys = new Set(Array.isArray(state?.collapsed) ? state.collapsed : []);
if (this.#groupColumns.length) {
if (this.#previousPageLength === null)
this.#previousPageLength = this.#dt.page.len();
if (this.#options.hideGroupedColumns) {
this.#groupColumns.forEach((idx) => this.#dt.column(idx).visible(false, false));
}
} else {
this.#restorePageLength();
}
}
/**
* Saves the oject state to the persistence storage.
* @returns {void}
*/
#saveState() {
if (!this.#options.stateSave) return;
const state = this.#buildStateObject();
if (typeof this.#options.stateSaveCallback === 'function') {
try {
Promise.resolve(this.#options.stateSaveCallback(state, this.#dt)).catch((err) => {
console.error('groupable(): stateSaveCallback has failed to save.', err);
});
} catch (err) {
console.error('groupable(): stateSaveCallback has failed to save.', err);
}
return;
}
try {
localStorage.setItem(this.#stateKey(), JSON.stringify(state));
} catch (err) {
console.warn('groupable(): state could not be saved to localStorage.', err);
}
}
/***
* Loads state from persistence storage.
* @returns {void}
*/
async #loadState() {
if (this.#options.stateSave) {
try {
let state;
if (typeof this.#options.stateLoadCallback === 'function') {
state = await this.#options.stateLoadCallback(this.#dt);
} else {
const raw = localStorage.getItem(this.#stateKey());
state = raw ? JSON.parse(raw) : null;
}
if (state) this.#applyStateObject(state);
} catch (err) {
console.error('groupable(): no se pudo cargar el estado guardado.', err);
}
}
this.#renderChips();
if (this.#groupColumns.length) {
this.#applyOrderAndDraw();
} else {
this.#renderGroups();
}
this.#notifyChange();
}
}
/**
* Register public Groupable Api to DataTable.Api
*/
DataTable.Api.register('groupable()', function (options) {
const settings = this.settings()[0];
settings._groupableInstance?.destroy();
settings._groupableInstance = new Groupable(this, options);
return this;
});
DataTable.Api.register('groupable.clear()', function () {
this.settings()[0]._groupableInstance?.clear();
return this;
});
DataTable.Api.register('groupable.destroy()', function () {
const settings = this.settings()[0];
settings._groupableInstance?.destroy();
settings._groupableInstance = null;
return this;
});
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论