/**
* Reusable show/hide columns picker for resource tables.
*
* Usage:
* initColumnPicker({
* table: '#containers-table',
* storageKey: 'peardock.cols.containers',
* mountAfter: '#clear-filters',
* columns: [
* { key: 'name', label: 'Name', always: true },
* { key: 'image', label: 'Image' },
* …
* ],
* })
*
* Mark matching
/ | with data-col="key". Cells without data-col are left alone.
*/
const STORAGE_PREFIX = 'peardock.tableCols.'
/**
* @typedef {{ key: string, label: string, always?: boolean, defaultVisible?: boolean }} ColDef
*/
/**
* @param {string} storageKey
* @param {ColDef[]} columns
* @returns {Record}
*/
function loadVisibility(storageKey, columns) {
/** @type {Record} */
const defaults = {}
for (const c of columns) {
defaults[c.key] = c.always ? true : c.defaultVisible !== false
}
try {
const raw = localStorage.getItem(STORAGE_PREFIX + storageKey)
if (!raw) return defaults
const saved = JSON.parse(raw)
if (!saved || typeof saved !== 'object') return defaults
for (const c of columns) {
if (c.always) {
defaults[c.key] = true
} else if (typeof saved[c.key] === 'boolean') {
defaults[c.key] = saved[c.key]
}
}
} catch {
// ignore
}
return defaults
}
/**
* @param {string} storageKey
* @param {Record} visibility
*/
function saveVisibility(storageKey, visibility) {
try {
localStorage.setItem(STORAGE_PREFIX + storageKey, JSON.stringify(visibility))
} catch {
// ignore
}
}
/**
* Apply visibility classes to all cells matching data-col in table.
* @param {HTMLTableElement} table
* @param {Record} visibility
*/
export function applyColumnVisibility(table, visibility) {
if (!table) return
table.classList.add('pd-col-table')
table.querySelectorAll('[data-col]').forEach((cell) => {
const key = cell.getAttribute('data-col')
if (!key) return
const show = visibility[key] !== false
cell.classList.toggle('pd-col-hidden', !show)
})
}
/**
* Re-apply last known visibility after a table re-render.
* @param {string} tableSelector
* @param {string} storageKey
* @param {ColDef[]} columns
*/
export function refreshTableColumns(tableSelector, storageKey, columns) {
const table =
typeof tableSelector === 'string'
? document.querySelector(tableSelector)
: tableSelector
if (!table) return
const visibility = loadVisibility(storageKey, columns)
applyColumnVisibility(/** @type {HTMLTableElement} */ (table), visibility)
}
/**
* Build dropdown menu DOM.
* @param {ColDef[]} columns
* @param {Record} visibility
* @param {(key: string, on: boolean) => void} onToggle
* @returns {HTMLElement}
*/
function buildMenu(columns, visibility, onToggle) {
const menu = document.createElement('div')
menu.className = 'dropdown-menu dropdown-menu-end dropdown-menu-dark pd-col-picker-menu'
menu.setAttribute('role', 'menu')
const title = document.createElement('div')
title.className = 'pd-col-picker-title'
title.textContent = 'Show / Hide Columns'
menu.appendChild(title)
for (const col of columns) {
const label = document.createElement('label')
label.className = 'pd-col-picker-item' + (col.always ? ' pd-col-picker-item--locked' : '')
label.setAttribute('role', 'menuitemcheckbox')
const input = document.createElement('input')
input.type = 'checkbox'
input.checked = visibility[col.key] !== false
input.disabled = Boolean(col.always)
input.dataset.colKey = col.key
input.addEventListener('change', (e) => {
e.stopPropagation()
onToggle(col.key, input.checked)
})
// Prevent Bootstrap dropdown from closing on checkbox click
label.addEventListener('click', (e) => e.stopPropagation())
const span = document.createElement('span')
span.textContent = col.label
label.appendChild(input)
label.appendChild(span)
menu.appendChild(label)
}
return menu
}
/**
* @param {{
* table: string|HTMLTableElement,
* columns: ColDef[],
* storageKey: string,
* mountAfter?: string|HTMLElement,
* mountIn?: string|HTMLElement,
* buttonLabel?: string,
* }} opts
* @returns {{ refresh: () => void, getVisibility: () => Record }|null}
*/
export function initColumnPicker(opts) {
const table =
typeof opts.table === 'string'
? /** @type {HTMLTableElement|null} */ (document.querySelector(opts.table))
: opts.table
if (!table || !opts.columns?.length) return null
const storageKey = opts.storageKey || table.id || 'default'
let visibility = loadVisibility(storageKey, opts.columns)
// Tag thead cells if missing data-col (by order of non-empty keys matching th index)
// Prefer explicit data-col in HTML.
const apply = () => {
applyColumnVisibility(table, visibility)
}
apply()
// Avoid double-init
const safeKey = String(storageKey).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const existing = document.querySelector(`.pd-col-picker[data-pd-cols="${safeKey}"]`)
if (existing) {
return {
refresh: apply,
getVisibility: () => ({ ...visibility }),
}
}
const wrap = document.createElement('div')
wrap.className = 'dropdown pd-col-picker'
wrap.dataset.pdCols = storageKey
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'btn btn-outline-secondary pd-col-picker-btn'
btn.setAttribute('data-bs-toggle', 'dropdown')
btn.setAttribute('aria-expanded', 'false')
btn.setAttribute('title', 'Show / Hide Columns')
btn.innerHTML = `${
opts.buttonLabel || 'Columns'
}`
const menu = buildMenu(opts.columns, visibility, (key, on) => {
const col = opts.columns.find((c) => c.key === key)
if (col?.always) return
visibility = { ...visibility, [key]: on }
saveVisibility(storageKey, visibility)
apply()
})
wrap.appendChild(btn)
wrap.appendChild(menu)
const after =
typeof opts.mountAfter === 'string'
? document.querySelector(opts.mountAfter)
: opts.mountAfter
const mountIn =
typeof opts.mountIn === 'string'
? document.querySelector(opts.mountIn)
: opts.mountIn
if (after?.parentNode) {
after.parentNode.insertBefore(wrap, after.nextSibling)
} else if (mountIn) {
mountIn.appendChild(wrap)
} else {
// Fallback: before table
table.parentNode?.insertBefore(wrap, table)
}
// Re-apply after DOM mutations inside tbody (row rebuilds)
const tbody = table.tBodies?.[0]
if (tbody && typeof MutationObserver !== 'undefined') {
const mo = new MutationObserver(() => {
// Only re-apply if new cells lack classes
apply()
})
mo.observe(tbody, { childList: true, subtree: false })
}
return {
refresh: apply,
getVisibility: () => ({ ...visibility }),
}
}
/**
* Register all standard peardock resource tables.
*/
export function initAllTableColumnPickers() {
const registries = [
{
table: '#containers-table',
storageKey: 'containers',
mountAfter: '#clear-filters',
columns: [
{ key: 'select', label: 'Select', always: true },
{ key: 'name', label: 'Name', always: true },
{ key: 'image', label: 'Image' },
{ key: 'updates', label: 'Updates' },
{ key: 'status', label: 'State' },
{ key: 'cpu', label: 'CPU' },
{ key: 'memory', label: 'Memory' },
{ key: 'ip', label: 'IP Address' },
{ key: 'actions', label: 'Quick Actions', always: true },
],
},
{
table: '#images-table',
storageKey: 'images',
mountIn: '#images-panel-local .view-toolbar',
columns: [
{ key: 'select', label: 'Select', always: true },
{ key: 'repository', label: 'Repository', always: true },
{ key: 'tags', label: 'Tags' },
{ key: 'id', label: 'Image ID' },
{ key: 'size', label: 'Size' },
{ key: 'created', label: 'Created' },
{ key: 'usage', label: 'Usage' },
{ key: 'actions', label: 'Actions', always: true },
],
},
{
table: '#networks-table',
storageKey: 'networks',
mountIn: '#networks-view .view-toolbar',
columns: [
{ key: 'name', label: 'Name', always: true },
{ key: 'driver', label: 'Driver' },
{ key: 'scope', label: 'Scope' },
{ key: 'subnet', label: 'Subnet' },
{ key: 'gateway', label: 'Gateway' },
{ key: 'containers', label: 'Containers' },
{ key: 'actions', label: 'Actions', always: true },
],
},
{
table: '#volumes-table',
storageKey: 'volumes',
mountIn: '#volumes-view .view-toolbar',
columns: [
{ key: 'name', label: 'Name', always: true },
{ key: 'driver', label: 'Driver' },
{ key: 'mountpoint', label: 'Mountpoint' },
{ key: 'usage', label: 'Usage' },
{ key: 'actions', label: 'Actions', always: true },
],
},
{
table: '#stacks-table',
storageKey: 'stacks',
mountIn: '#stacks-view .view-toolbar',
columns: [
{ key: 'name', label: 'Stack Name', always: true },
{ key: 'services', label: 'Services' },
{ key: 'containers', label: 'Containers' },
{ key: 'status', label: 'Status' },
{ key: 'actions', label: 'Actions', always: true },
],
},
]
/** @type {Array>} */
const apis = []
for (const reg of registries) {
try {
apis.push(initColumnPicker(reg))
} catch (err) {
console.warn('[tableColumns] init failed', reg.storageKey, err)
}
}
return apis
}
export default {
initColumnPicker,
initAllTableColumnPickers,
applyColumnVisibility,
refreshTableColumns,
}
|