|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"/> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"/> |
|
<title>DataTables Tags with Custom DataType via DataTable.type()</title> |
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" |
|
rel="stylesheet" |
|
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" |
|
crossorigin="anonymous"> |
|
<link href="https://cdn.datatables.net/2.3.4/css/dataTables.bootstrap5.min.css" rel="stylesheet" |
|
integrity="sha384-zmMNeKbOwzvUmxN8Z/VoYM+i+cwyC14+U9lq4+ZL0Ro7p1GMoh8uq8/HvIBgnh9+" |
|
crossorigin="anonymous"> |
|
<link href="https://cdn.datatables.net/columncontrol/1.1.1/css/columnControl.bootstrap5.min.css" |
|
rel="stylesheet" |
|
integrity="sha384-7CoZnsZoCNq4KW3KkVlviA3k7muuMmoNzO9owW/X5cxkKf7mOt8bgItbLHs2gxMS" |
|
crossorigin="anonymous"> |
|
|
|
<style> |
|
.tag-pill { |
|
margin-right: 0.25rem; |
|
margin-bottom: 0.25rem; |
|
display: inline-block; |
|
} |
|
|
|
.dtcc-mode-toolbar { |
|
display: flex; |
|
gap: 0.5rem; |
|
align-items: center; |
|
margin-bottom: 0.5rem; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container my-4"> |
|
<h3>DataTables Tags with Custom DataType via DataTable.type()</h3> |
|
<table id="example" class="table table-striped table-hover table-bordered" style="width:100%"> |
|
<thead> |
|
<tr> |
|
<th id="th-name" class="sortable">Name <span class="sort-indicator"></span></th> |
|
<th id="th-tags" class="sortable">Tags <span class="sort-indicator"></span></th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
<!-- populated by DataTables --> |
|
</tbody> |
|
</table> |
|
</div> |
|
|
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js" |
|
integrity="sha384-NXgwF8Kv9SSAr+jemKKcbvQsz+teULH/a5UNJvZc6kP47hZgl62M1vGnw6gHQhb1" |
|
crossorigin="anonymous"></script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js" |
|
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" |
|
crossorigin="anonymous"></script> |
|
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.min.js" |
|
integrity="sha384-n+4gDsCQ+x0o5YCJeYqFY1Yl+qgW41qmMAilIyF/bJwIUeTK00QB845/VijT5pQq" |
|
crossorigin="anonymous"></script> |
|
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.bootstrap5.min.js" |
|
integrity="sha384-G85lmdZCo2WkHaZ8U1ZceHekzKcg37sFrs4St2+u/r2UtfvSDQmQrkMsEx4Cgv/W" |
|
crossorigin="anonymous"></script> |
|
<script src="https://cdn.datatables.net/columncontrol/1.1.1/js/dataTables.columnControl.min.js" |
|
integrity="sha384-54KDBrrLqJ67PBkEV2Ej6Jld+sMGBTOvXTSUIUjoDk1an4CQA3ixhqqH/gENp/H7" |
|
crossorigin="anonymous"></script> |
|
<script src="https://cdn.datatables.net/columncontrol/1.1.1/js/columnControl.bootstrap5.min.js" |
|
integrity="sha384-8/VMWgNQydL78AUoxpPO74i5Doeh3fkhJPcMHcWB7I92dF+cGc7t6L6x1/7pCJQf" |
|
crossorigin="anonymous"></script> |
|
|
|
<script> |
|
(function () { |
|
'use strict'; |
|
|
|
// Enable extra debug logging when true |
|
const DEBUG = false; |
|
|
|
// Map of tag -> Bootstrap color class |
|
const TAG_COLORS = { |
|
red: 'danger', |
|
blue: 'primary', |
|
green: 'success', |
|
yellow: 'warning', |
|
cyan: 'info', |
|
important: 'dark', |
|
default: 'secondary' |
|
}; |
|
|
|
// Return bootstrap color class for a tag (case-insensitive) |
|
const getTagColor = tag => TAG_COLORS[(String(tag || '').toLowerCase())] || TAG_COLORS.default; |
|
|
|
// Create a badge element or HTML string for a tag. Keeps textContent for safety. |
|
const tagBadge = (tag, asHtml = true) => { |
|
const t = String(tag || '').trim(); |
|
const span = document.createElement('span'); |
|
span.className = 'badge rounded-pill bg-' + getTagColor(t) + ' tag-pill'; |
|
span.textContent = t; |
|
span.setAttribute('title', t); |
|
span.setAttribute('aria-label', `Tag: ${t}`); |
|
return asHtml ? span.outerHTML : span; |
|
}; |
|
|
|
// Normalize a single tag value |
|
const normalizeTag = v => String(v || '').trim(); |
|
|
|
// Normalize input into an array of non-empty, trimmed strings. |
|
// Accepts Array, JSON-style array string, comma-separated string, or other scalars. |
|
const normalizeToArray = data => { |
|
if (data === null || data === undefined) return []; |
|
if (Array.isArray(data)) return data.map(normalizeTag).filter(Boolean); |
|
if (typeof data === 'string') { |
|
const s = data.trim(); |
|
// Try JSON parse for array-like strings |
|
if (/^\s*\[.*]$/.test(s)) { |
|
try { |
|
const parsed = JSON.parse(s); |
|
if (Array.isArray(parsed)) return parsed.map(normalizeTag).filter(Boolean); |
|
} catch (e) { |
|
// fallthrough to comma split |
|
if (DEBUG) console.debug('JSON parse failed for tags string', s, e); |
|
} |
|
} |
|
return s.split(',').map(p => p.trim()).filter(Boolean); |
|
} |
|
// Fallback: single value to string |
|
return [String(data).trim()].filter(Boolean); |
|
}; |
|
|
|
// Normalize and sort tags (case-insensitive, locale aware) |
|
const normalizeAndSort = data => normalizeToArray(data).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); |
|
|
|
// Escape special regex characters |
|
const escapeRegex = v => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
|
|
// Build lookahead regex that requires all values (AND semantics) |
|
const buildAndLookaheadRegex = values => { |
|
if (!values || !values.length) return ''; |
|
const lookaheads = values.map(v => `(?=.*\\b${escapeRegex(v)}\\b)`).join(''); |
|
return `^${lookaheads}.*$`; |
|
}; |
|
|
|
// Build OR regex matching any value as a whole word |
|
const buildOrRegex = values => { |
|
if (!values || !values.length) return ''; |
|
const inner = values.map(v => escapeRegex(v)).join('|'); |
|
return `\\b(?:${inner})\\b`; |
|
}; |
|
|
|
// Resolve DataTable API from a ColumnControl config or fallback to #example instance |
|
const resolveDataTableApi = config => { |
|
if (config && (config.dt || (config.table && config.table.api))) return config.dt || config.table.api; |
|
if (typeof window !== 'undefined' && window.$) { |
|
const el = $('#example'); |
|
if (el && el.DataTable) return el.DataTable(); |
|
} |
|
return null; |
|
}; |
|
|
|
// Register a custom 'tags' type: detection, ordering key and rendering |
|
DataTable.type('tags', { |
|
detect: data => { |
|
if (Array.isArray(data)) return 'tags'; |
|
if (typeof data === 'string') { |
|
if (data.indexOf(',') !== -1) return 'tags'; |
|
if (/^\s*\[.*]$/.test(data)) return 'tags'; |
|
} |
|
return null; |
|
}, |
|
order: { |
|
pre: data => { |
|
const tags = normalizeToArray(data).map(t => String(t).toLowerCase()).filter(Boolean); |
|
// Maintain previous stable ordering behaviour |
|
tags.sort().reverse(); |
|
return tags.join('|'); |
|
} |
|
}, |
|
className: 'dt-data-tags', |
|
render: function (data, type) { |
|
const arr = normalizeAndSort(data); |
|
if (type === 'display') return arr.map(tag => tagBadge(tag, true)).join(' '); |
|
if (type === 'filter') return arr.join(' '); |
|
return arr.join(','); |
|
} |
|
}); |
|
|
|
// ColumnControl content plugin: checklist with badges and AND/OR filtering |
|
DataTable.ColumnControl.content.tagsSearchList = { |
|
init: function (config) { |
|
const container = document.createElement('div'); |
|
container.className = 'tags-search-list p-2'; |
|
|
|
const api = resolveDataTableApi(config); |
|
if (!api || typeof api.column !== 'function') { |
|
container.textContent = 'DataTable API not found'; |
|
return container; |
|
} |
|
|
|
// Attempt to find the ColumnControl host |
|
let host = (config && (config.host || config.columnControl || config.hostColumnControl || config.ccHost)) || null; |
|
if (!host && typeof this === 'object' && this !== null && typeof this.idx === 'function') host = this; |
|
|
|
// Final fallback: try to obtain host from a tentative column index |
|
if (!host) { |
|
const tentativeIdx = (typeof config?.target === 'number') ? config.target : ((typeof config?.column === 'number') ? config.column : 1); |
|
try { |
|
const colApi = api.column && api.column(tentativeIdx); |
|
if (colApi && typeof colApi.columnControl === 'function') host = colApi.columnControl(); |
|
} catch (e) { |
|
if (DEBUG) console.debug('host resolution failed', e); |
|
} |
|
} |
|
|
|
const resolvedColIdx = (host && typeof host.idx === 'function') ? host.idx() : ((typeof config?.target === 'number') ? config.target : ((typeof config?.column === 'number') ? config.column : 1)); |
|
|
|
// Determine available tags: prefer config, then global preconfig, then scan column data |
|
let tags; |
|
const cfgTags = (config && config.extends && Array.isArray(config.extends.tags)) ? config.extends.tags : (Array.isArray(config.tags) ? config.tags : null); |
|
const globalPre = (typeof window !== 'undefined' && window._columnControlPreconfig) ? window._columnControlPreconfig[resolvedColIdx] : null; |
|
|
|
if (Array.isArray(cfgTags) && cfgTags.length) { |
|
tags = Array.from(new Set(cfgTags.map(normalizeTag))).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); |
|
} else if (Array.isArray(globalPre) && globalPre.length) { |
|
tags = Array.from(new Set(globalPre.map(normalizeTag))).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); |
|
} else { |
|
const rawData = api.column(resolvedColIdx, {search: 'none'}).data().toArray(); |
|
const tagSet = new Set(); |
|
rawData.forEach(d => normalizeToArray(d).forEach(t => { |
|
const s = normalizeTag(t); |
|
if (s) tagSet.add(s); |
|
})); |
|
tags = Array.from(tagSet).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); |
|
} |
|
|
|
if (!host) { |
|
container.textContent = 'ColumnControl host not found'; |
|
return container; |
|
} |
|
|
|
try { |
|
const checklist = new DataTable.ColumnControl.CheckList(api, host, { |
|
search: true, |
|
select: true |
|
}); |
|
const items = tags.map(t => ({label: t, value: t})); |
|
checklist.add(items); |
|
|
|
const updateCounts = () => { |
|
const vals = checklist.values() || []; |
|
const selectNoneSpan = checklist.element().querySelector('.dtcc-list-selectNone span'); |
|
const selectAllSpan = checklist.element().querySelector('.dtcc-list-selectAll span'); |
|
if (selectNoneSpan) selectNoneSpan.innerHTML = vals.length ? '(' + vals.length + ')' : ''; |
|
if (selectAllSpan) { |
|
const total = items.length || checklist.element().querySelectorAll('.dtcc-list-buttons > *').length; |
|
selectAllSpan.innerHTML = total ? '(' + total + ')' : ''; |
|
} |
|
}; |
|
|
|
let filterMode = 'and'; |
|
|
|
const applyFilter = () => { |
|
const vals = checklist.values() || []; |
|
if (!vals.length) { |
|
api.column(resolvedColIdx).search('').draw(); |
|
return; |
|
} |
|
const regex = (filterMode === 'and') ? buildAndLookaheadRegex(vals) : buildOrRegex(vals); |
|
api.column(resolvedColIdx).search(regex, true, false, true).draw(); |
|
}; |
|
|
|
// Mode buttons toolbar (And / Or) |
|
const toolbar = document.createElement('div'); |
|
toolbar.className = 'dtcc-mode-toolbar'; |
|
|
|
const btnAnd = new DataTable.ColumnControl.Button(api, host).text('And').handler(() => { |
|
filterMode = 'and'; |
|
btnAnd.active(true); |
|
btnOr.active(false); |
|
applyFilter(); |
|
}); |
|
const btnOr = new DataTable.ColumnControl.Button(api, host).text('Or').handler(() => { |
|
filterMode = 'or'; |
|
btnOr.active(true); |
|
btnAnd.active(false); |
|
applyFilter(); |
|
}); |
|
|
|
btnAnd.active(true); |
|
toolbar.appendChild(btnAnd.element()); |
|
toolbar.appendChild(btnOr.element()); |
|
container.appendChild(toolbar); |
|
|
|
// Attach badges and handlers to checklist buttons |
|
items.forEach(item => { |
|
const btn = checklist.button(item.value); |
|
if (!btn) return; |
|
const el = btn.element(); |
|
if (!el) return; |
|
|
|
const badgeEl = tagBadge(item.value, false); |
|
el.prepend(badgeEl); |
|
|
|
const textSpan = el.querySelector('.dtcc-button-text'); |
|
if (textSpan) textSpan.remove(); |
|
|
|
el.addEventListener('click', e => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
const newState = !btn.active(); |
|
btn.active(newState); |
|
updateCounts(); |
|
applyFilter(); |
|
}); |
|
}); |
|
|
|
checklist.searchListener(api); |
|
checklist.handler(applyFilter); |
|
|
|
container.appendChild(checklist.element()); |
|
return container; |
|
} catch (err) { |
|
if (DEBUG) console.error('CheckList initialization failed', err); |
|
container.textContent = 'CheckList initialization failed'; |
|
return container; |
|
} |
|
} |
|
}; |
|
|
|
// Sample data |
|
const TAG_POOL = ['red', 'blue', 'green', 'yellow', 'cyan', 'important', 'default']; |
|
|
|
const tableData = Array.from({length: 100}, (_, i) => { |
|
const name = `Item ${i + 1}`; |
|
// random count between 0 and 5 |
|
const count = Math.floor(Math.random() * 6); |
|
const tags = []; |
|
for (let j = 0; j < count; j++) { |
|
const tag = TAG_POOL[(i * 5 + j * 3) % TAG_POOL.length]; |
|
tags.push(tag); |
|
} |
|
// ensure unique tags per row and preserve order |
|
const uniqueTags = Array.from(new Set(tags)); |
|
return {name, tags: uniqueTags}; |
|
}); |
|
|
|
// Initialize table on DOM ready |
|
$(document).ready(function () { |
|
// Precompute tags from source to avoid scanning the table at runtime |
|
const availableTags = Array.from(new Set(tableData.flatMap(r => normalizeToArray(r.tags).map(normalizeTag).filter(Boolean)))).sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); |
|
|
|
$('#example').DataTable({ |
|
data: tableData, |
|
columns: [ |
|
{data: 'name', title: 'Name'}, |
|
{data: 'tags', title: 'Tags'} |
|
], |
|
order: [[0, 'asc']], |
|
ordering: {indicators: false}, |
|
columnControl: [ |
|
{target: 0, content: ['orderStatus', ['searchList']]} |
|
], |
|
columnDefs: [ |
|
{ |
|
targets: 1, |
|
columnControl: [ |
|
{ |
|
target: 0, |
|
content: ['orderStatus', [{ |
|
extend: 'tagsSearchList', |
|
tags: availableTags |
|
}]] |
|
} |
|
] |
|
} |
|
], |
|
// scrollY: '400px', |
|
paging: false, |
|
}); |
|
}); |
|
})(); |
|
</script> |
|
</body> |
|
</html> |