Files
peardock/ui/tableColumns.js
T
Raven Scott 60315d53f0
Release rolling / release (push) Successful in 7m47s
Add show/hide columns picker and hide table horizontal scroll
Reusable column visibility control after Clear on containers and on
other resource tables, with preferences persisted. Suppress horizontal
scrollbars so columns ellipsize instead of overflowing.
2026-07-16 14:53:54 -04:00

335 lines
9.8 KiB
JavaScript

/**
* 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 <th>/<td> 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<string, boolean>}
*/
function loadVisibility(storageKey, columns) {
/** @type {Record<string, boolean>} */
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<string, boolean>} 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<string, boolean>} 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<string, boolean>} 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<string, boolean> }|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 = `<i class="fas fa-columns" aria-hidden="true"></i><span class="pd-col-picker-btn-label">${
opts.buttonLabel || 'Columns'
}</span>`
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<ReturnType<typeof initColumnPicker>>} */
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,
}