Release rolling / release (push) Successful in 7m47s
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.
335 lines
9.8 KiB
JavaScript
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,
|
|
}
|