Add new pages for managing tags, tasks, transitions, users, user groups, and their respective JavaScript functionalities

- Implemented tag management page with filtering, data table, and AJAX functionality.
- Created task management page with task listing, filtering, and AJAX data loading.
- Developed transition management page with a data table for transitions.
- Added user management page with comprehensive user details, filtering options, and AJAX support.
- Introduced user group management page with filtering and data table for user groups.
- Enhanced JavaScript for data tables, including state saving, filtering, and AJAX data fetching for all new pages.
This commit is contained in:
matt
2026-02-06 23:54:21 -05:00
parent 122d098be4
commit 87a444b8de
34 changed files with 4579 additions and 688 deletions

View File

@@ -30,44 +30,18 @@
};
/**
* Measure sidebar width and set CSS variable for layout
* Sidebar width is now handled purely by CSS classes (.ots-sidebar-collapsed).
* This function is kept as a no-op for backward compatibility.
*/
function updateSidebarWidth() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const collapsed = sidebar.classList.contains('collapsed');
// If called with a forced mode, use the stored defaults
const forceMode = updateSidebarWidth._forceMode || null;
const base = (forceMode === 'full') ? (window.__otsFullSidebarWidth || 256)
: (forceMode === 'collapsed') ? (window.__otsCollapsedSidebarWidth || 70)
: (collapsed ? 70 : sidebar.offsetWidth || sidebar.getBoundingClientRect().width || 240);
const padding = 5;
const value = Math.max(70, Math.round(base + padding));
// Apply CSS variable used by layout and also set an inline width fallback
document.documentElement.style.setProperty('--ots-sidebar-width', `${value}px`);
try {
// Inline width helps force an immediate reflow when CSS rules/important flags interfere
// Use setProperty with 'important' so stylesheet !important rules can't override it.
sidebar.style.setProperty('width', `${value}px`, 'important');
// Force reflow to encourage the browser to apply the new sizing immediately
// eslint-disable-next-line no-unused-expressions
sidebar.offsetHeight;
} catch (err) {
try { sidebar.style.width = `${value}px`; } catch (e) { /* ignore */ }
}
// Debug logging to help identify timing/specifity issues in the wild
// No-op: CSS handles layout via body.ots-sidebar-collapsed class
if (window.__otsDebug) {
console.log('[OTS] updateSidebarWidth', { collapsed, base, value, cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width') });
const sidebar = document.querySelector('.ots-sidebar');
const collapsed = sidebar ? sidebar.classList.contains('collapsed') : false;
console.log('[OTS] updateSidebarWidth (no-op, CSS-driven)', { collapsed });
}
}
// Helper to request a forced width update
function forceSidebarWidthMode(mode) {
updateSidebarWidth._forceMode = mode; // 'full' | 'collapsed' | null
updateSidebarWidth();
updateSidebarWidth._forceMode = null;
}
/**
* Measure the sidebar header bottom and set the top padding of the nav list
* so nav items always begin below the header (logo + buttons).
@@ -106,86 +80,34 @@
}
/**
* Measure the sidebar and set an explicit left margin on the page wrapper
* so the gap between the sidebar and page content is exactly 5px.
* DISABLED: Cleanup function to remove inline styles that were forcing incorrect margins
* The sidebar layout is now controlled entirely by CSS variables and margin-left.
*/
function updateSidebarGap() {
const sidebar = document.querySelector('.ots-sidebar');
// target likely content containers in this app
// This function is intentionally left minimal.
// Spacing is now handled by CSS: .ots-main { margin-left: var(--ots-sidebar-width) }
// Removing any inline margin-left or padding-left that may have been set previously
const targets = [
document.getElementById('page-wrapper'),
document.querySelector('.ots-main'),
document.getElementById('content-wrapper'),
document.querySelector('#content')
].filter(Boolean);
if (!sidebar || !targets.length) return;
const gap = (typeof window.__otsDesiredSidebarGap !== 'undefined') ? Number(window.__otsDesiredSidebarGap) : 0; // desired gap in px (default 0)
const rect = sidebar.getBoundingClientRect();
// desired inner left padding (allows trimming space inside the content area)
const desiredInnerPadding = (typeof window.__otsDesiredPagePaddingLeft !== 'undefined') ? Number(window.__otsDesiredPagePaddingLeft) : 8;
targets.forEach(pageWrapper => {
const pageRect = pageWrapper.getBoundingClientRect();
const computed = window.getComputedStyle(pageWrapper);
const currentMargin = parseFloat(computed.marginLeft) || 0;
const currentGap = Math.round(pageRect.left - rect.right);
// Calculate how much to adjust margin-left so gap becomes `gap`.
const delta = currentGap - gap;
const newMargin = Math.max(0, Math.round(currentMargin - delta));
try {
pageWrapper.style.setProperty('margin-left', `${newMargin}px`, 'important');
pageWrapper.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important');
pageWrapper.style.removeProperty('margin-left');
pageWrapper.style.removeProperty('padding-left');
} catch (err) {
pageWrapper.style.marginLeft = `${newMargin}px`;
pageWrapper.style.paddingLeft = `${desiredInnerPadding}px`;
pageWrapper.style.marginLeft = '';
pageWrapper.style.paddingLeft = '';
}
// Also adjust common child wrapper padding if present
// Also remove from common child wrappers
try {
const inner = pageWrapper.querySelector('.page-content') || pageWrapper.querySelector('.ots-content') || pageWrapper.querySelector('.container');
if (inner) inner.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important');
} catch (err) {}
if (window.__otsDebug) console.log('[OTS] updateSidebarGap', {
target: pageWrapper.tagName + (pageWrapper.id ? '#'+pageWrapper.id : ''),
sidebarWidth: rect.width,
sidebarRight: rect.right,
pageLeft: pageRect.left,
currentGap,
newMargin
});
// Detect narrow intervening elements (visual separator) and neutralize their visuals
try {
const sampleXs = [Math.round(rect.right + 2), Math.round((rect.right + pageRect.left) / 2), Math.round(pageRect.left - 2)];
const ys = [Math.floor(window.innerHeight / 2), Math.floor(window.innerHeight / 4), Math.floor(window.innerHeight * 0.75)];
const seen = new Set();
sampleXs.forEach(x => {
ys.forEach(y => {
try {
const els = document.elementsFromPoint(x, y) || [];
els.forEach(el => {
if (!el || el === document.documentElement || el === document.body) return;
if (el === sidebar || el === pageWrapper) return;
const b = el.getBoundingClientRect();
// narrow vertical candidates between sidebar and content
if (b.left >= rect.right - 4 && b.right <= pageRect.left + 4 && b.width <= 80 && b.height >= 40) {
const id = el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+el.className.split(' ').join('.') : '');
if (seen.has(id)) return;
seen.add(id);
try {
el.style.setProperty('background', 'transparent', 'important');
el.style.setProperty('background-image', 'none', 'important');
el.style.setProperty('box-shadow', 'none', 'important');
el.style.setProperty('border', 'none', 'important');
el.style.setProperty('pointer-events', 'none', 'important');
if (window.__otsDebug) console.log('[OTS] neutralized intervening element', { id, rect: b });
} catch (err) {}
}
});
} catch (err) {}
});
});
if (inner) {
inner.style.removeProperty('padding-left');
}
} catch (err) {}
});
}
@@ -225,11 +147,65 @@
if (!sidebar) return;
// Mobile-aware toggle: add backdrop, aria-expanded, and focus management
if (toggleBtn) {
let lastFocus = null;
function ensureBackdrop() {
let bd = document.querySelector('.ots-backdrop');
if (!bd) {
bd = document.createElement('div');
bd.className = 'ots-backdrop';
bd.addEventListener('click', function() {
sidebar.classList.remove('active');
bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
});
document.body.appendChild(bd);
}
return bd;
}
toggleBtn.setAttribute('role', 'button');
toggleBtn.setAttribute('aria-controls', 'ots-sidebar');
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
const isNowActive = !sidebar.classList.contains('active');
sidebar.classList.toggle('active');
// On small screens show backdrop and manage focus
if (window.innerWidth <= 768) {
const bd = ensureBackdrop();
if (isNowActive) {
bd.classList.add('show');
toggleBtn.setAttribute('aria-expanded', 'true');
lastFocus = document.activeElement;
const firstFocusable = sidebar.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) firstFocusable.focus(); else { sidebar.setAttribute('tabindex', '-1'); sidebar.focus(); }
document.addEventListener('keydown', escHandler);
} else {
bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
document.removeEventListener('keydown', escHandler);
}
}
updateSidebarStateClass();
});
function escHandler(e) {
if (e.key === 'Escape' || e.key === 'Esc') {
const bd = document.querySelector('.ots-backdrop');
if (sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
if (bd) bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
document.removeEventListener('keydown', escHandler);
}
}
}
}
if (collapseBtn) {
@@ -237,8 +213,9 @@
if (isCollapsed) {
sidebar.classList.add('collapsed');
body.classList.add('ots-sidebar-collapsed');
document.documentElement.classList.add('ots-sidebar-collapsed');
updateSidebarStateClass();
updateSidebarGap();
// updateSidebarGap() disabled - use CSS variables instead
}
collapseBtn.addEventListener('click', function(e) {
@@ -246,26 +223,10 @@
const nowCollapsed = !sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed');
body.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
document.documentElement.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false');
// Force collapsed width immediately
forceSidebarWidthMode('collapsed');
// Recalculate nav offset so items remain below header after collapse
updateSidebarNavOffset();
// Ensure page content gap is updated for collapsed width
updateSidebarGap();
// Re-run shortly after to catch any late layout changes
setTimeout(updateSidebarGap, 80);
updateSidebarStateClass();
// Debug state after toggle
try {
console.log('[OTS] collapseBtn clicked', {
nowCollapsed,
classes: sidebar.className,
inlineStyle: sidebar.getAttribute('style'),
computedWidth: getComputedStyle(sidebar).width,
cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width')
});
} catch (err) {}
});
}
@@ -274,23 +235,10 @@
e.preventDefault();
sidebar.classList.remove('collapsed');
body.classList.remove('ots-sidebar-collapsed');
document.documentElement.classList.remove('ots-sidebar-collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false');
// Force full width when expanding
forceSidebarWidthMode('full');
// Recalculate nav offset after expanding
updateSidebarNavOffset();
// Ensure page content gap is updated for expanded width
updateSidebarGap();
setTimeout(updateSidebarGap, 80);
updateSidebarStateClass();
try {
console.log('[OTS] expandBtn clicked', {
classes: sidebar.className,
inlineStyle: sidebar.getAttribute('style'),
computedWidth: getComputedStyle(sidebar).width,
cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width')
});
} catch (err) {}
});
}
@@ -583,8 +531,7 @@
} else {
sidebar.classList.add('mobile');
}
updateSidebarWidth();
updateSidebarGap();
// updateSidebarGap() disabled - use CSS variables instead
});
}
@@ -651,7 +598,6 @@
input.placeholder = 'Search…';
input.className = 'table-search-input';
input.setAttribute('aria-label', 'Table search');
input.style.minWidth = '180px';
controls.appendChild(input);
@@ -751,6 +697,96 @@
}
}
/**
* Move DataTable row dropdown menus to <body> so they escape
* any overflow:hidden / overflow:auto ancestor containers.
*
* Xibo renders the row action button as:
* <div class="btn-group pull-right dropdown-menu-container">
* <button class="btn btn-white dropdown-toggle" data-toggle="dropdown">
* <div class="dropdown-menu dropdown-menu-right">...items...</div>
* </div>
*
* Bootstrap fires shown/hide.bs.dropdown on the toggle's parent element
* (the .btn-group). We listen on document with a selector that matches
* the actual Xibo markup.
*/
function initRowDropdowns() {
var DROPDOWN_PARENT_SEL = '.dropdown-menu-container, .btn-group';
var SCOPE_SEL = '.XiboData ' + DROPDOWN_PARENT_SEL
+ ', .XiboGrid ' + DROPDOWN_PARENT_SEL
+ ', #datatable-container ' + DROPDOWN_PARENT_SEL
+ ', .dataTables_wrapper ' + DROPDOWN_PARENT_SEL;
$(document).on('shown.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $trigger = $container.find('[data-toggle="dropdown"]');
var $menu = $container.find('.dropdown-menu');
if (!$menu.length || !$trigger.length) return;
// Mark the menu so we can style it and find it later
$menu.addClass('ots-row-dropdown');
// Store original parent so we can put it back on close
$menu.data('ots-original-parent', $container);
// Get trigger position in viewport
var btnRect = $trigger[0].getBoundingClientRect();
// Move to body
$menu.detach().appendTo('body');
// Position below the trigger button, aligned to the right edge
var top = btnRect.bottom + 2;
var left = btnRect.right - $menu.outerWidth();
// Keep within viewport bounds
if (left < 8) left = 8;
if (top + $menu.outerHeight() > window.innerHeight - 8) {
// Open upward if no room below
top = btnRect.top - $menu.outerHeight() - 2;
}
if (top < 8) top = 8;
$menu.css({
position: 'fixed',
top: top + 'px',
left: left + 'px',
display: 'block'
});
});
// When the dropdown closes, move the menu back to its original parent
$(document).on('hide.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $menu = $('body > .dropdown-menu.ots-row-dropdown').filter(function() {
var orig = $(this).data('ots-original-parent');
return orig && orig[0] === $container[0];
});
if ($menu.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($container);
}
});
// Also close any orphaned body-appended dropdown on outside click
$(document).on('click', function(e) {
var $openMenus = $('body > .dropdown-menu.ots-row-dropdown');
if (!$openMenus.length) return;
// If the click is inside the menu itself, let it through
if ($(e.target).closest('.ots-row-dropdown').length) return;
$openMenus.each(function() {
var $menu = $(this);
var $parent = $menu.data('ots-original-parent');
if ($parent && $parent.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($parent);
$parent.removeClass('open show');
}
});
});
}
/**
* Initialize all features when DOM is ready
*/
@@ -759,6 +795,7 @@
initSidebarSectionToggles();
initThemeToggle();
initDropdowns();
initRowDropdowns();
initSearch();
initPageInteractions();
initDataTables();
@@ -767,11 +804,11 @@
initChartSafeguard();
updateSidebarWidth();
updateSidebarNavOffset();
updateSidebarGap();
// updateSidebarGap() disabled - use CSS variables instead
var debouncedUpdate = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
updateSidebarGap();
// updateSidebarGap() disabled - use CSS variables instead
}, 120);
window.addEventListener('resize', debouncedUpdate);
}