/** * 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, }