diff --git a/CHANGELOG.md b/CHANGELOG.md index f3065d5..e9ffb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,15 @@ All notable changes to this project will be documented in this file. ## 2.17.5 - 2026-10-23 +### Added + +- Table pagination updates results without reloading the page, preserves search and sorting, and supports browser Back and Forward navigation. + ### Changed - Added query-aligned database indexes for playlist loading, template and slide lookups, session cleanup, background task claiming, and data-source list ordering. - Audit log filtering, sorting, and pagination run in SQL; CSV exports still include all matching events. +- Table header Create buttons use the same right inset as pagination controls. ## 2.17.4 - 2026-10-23 diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 5f0d785..1b3aca4 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -599,6 +599,7 @@ [data-table-search-container] > .card-header:has(> .card-tools) > .card-tools { margin-left: auto; + margin-right: 0; } /* Template animation preview controls */ diff --git a/src/web/public/js/table/table-search.js b/src/web/public/js/table/table-search.js index 6ae48a0..50c95b2 100644 --- a/src/web/public/js/table/table-search.js +++ b/src/web/public/js/table/table-search.js @@ -81,7 +81,9 @@ } var currentTable = container.querySelector('[data-table-searchable]'); - var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card'); + var containers = Array.prototype.slice.call(document.querySelectorAll('[data-table-search-container]')); + var nextContainers = responseDocument.querySelectorAll('[data-table-search-container]'); + var nextContainer = nextContainers[containers.indexOf(container)]; var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null; var currentFooter = container.querySelector('.card-footer'); var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null; @@ -104,12 +106,75 @@ } else if (currentFooter && !nextFooter) { currentFooter.parentNode.removeChild(currentFooter); } else if (!currentFooter && nextFooter) { - currentTable.parentNode.appendChild(nextFooter.cloneNode(true)); + container.appendChild(nextFooter.cloneNode(true)); } return true; } + function loadTableResults(container, nextUrl, historyMode, focusInput) { + if (!container) { + window.location.assign(nextUrl.toString()); + return; + } + + if (container._cancelTableSearch) { + container._cancelTableSearch(); + } + var sequenceId = (container._tableRequestSequence || 0) + 1; + container._tableRequestSequence = sequenceId; + container.setAttribute('data-table-search-loading', 'true'); + container.setAttribute('aria-busy', 'true'); + + return fetch(nextUrl.toString(), { + method: 'GET', + headers: { + 'Accept': 'text/html, application/xhtml+xml', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin', + cache: 'no-store' + }).then(function (response) { + if (!response.ok || (response.redirected && new URL(response.url).pathname !== nextUrl.pathname)) { + throw new Error('Unable to load table results.'); + } + return response.text(); + }).then(function (text) { + if (sequenceId !== container._tableRequestSequence) { + return; + } + var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); + if (!replaceTableResults(container, responseDocument)) { + throw new Error('Missing table results.'); + } + if (historyMode && window.history && window.history[historyMode]) { + window.history[historyMode](window.history.state, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash); + } + var input = container.querySelector('[data-table-search]'); + if (input) { + var searchParam = input.getAttribute('data-table-search-param') || 'search'; + input.value = nextUrl.searchParams.get(searchParam) || ''; + } + rebindTableContainer(container); + var scroller = container.querySelector('.table-responsive'); + if (scroller) { + scroller.scrollTop = 0; + } + if (focusInput) { + focusSearchInput(input); + } + }).catch(function () { + if (sequenceId === container._tableRequestSequence) { + window.location.assign(nextUrl.toString()); + } + }).finally(function () { + if (sequenceId === container._tableRequestSequence) { + container.removeAttribute('data-table-search-loading'); + container.removeAttribute('aria-busy'); + } + }); + } + function initTableSearches(root) { var scope = root && root.querySelectorAll ? root : document; @@ -123,11 +188,15 @@ var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search'; var currentUrl = new URL(window.location.href); var pendingSearchTimer = null; - var requestSequence = 0; - var pendingSearchRequests = 0; var container = input.closest('[data-table-search-container]') || input.closest('.card') || null; input.value = String(currentUrl.searchParams.get(searchParam) || '').trim(); + if (container) { + container._cancelTableSearch = function () { + window.clearTimeout(pendingSearchTimer); + pendingSearchTimer = null; + }; + } var searchGroup = input.closest('.table-search-group'); if (searchGroup) { @@ -160,57 +229,13 @@ } nextUrl.searchParams.delete('page'); - if (nextUrl.toString() === currentUrl.toString()) { - return; - } - - requestSequence += 1; - var sequenceId = requestSequence; - pendingSearchRequests += 1; - if (container) { - container.setAttribute('data-table-search-loading', 'true'); - } - - fetch(nextUrl.toString(), { - method: 'GET', - headers: { - 'Accept': 'text/html, application/xhtml+xml', - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin', - cache: 'no-store' - }).then(function (response) { - if (!response.ok) { - throw new Error('Unable to load search results.'); - } - return response.text(); - }).then(function (text) { - if (sequenceId !== requestSequence) { - return; - } - - var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); - - if (window.history && window.history.replaceState) { - window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash); - } - - if (replaceTableResults(container, responseDocument)) { - currentUrl = nextUrl; - rebindTableContainer(container || document); - focusSearchInput(input); - } - }).catch(function () { - window.location.assign(nextUrl.toString()); - }).finally(function () { - pendingSearchRequests = Math.max(0, pendingSearchRequests - 1); - if (container && pendingSearchRequests === 0) { - container.removeAttribute('data-table-search-loading'); - } - }); + loadTableResults(container, nextUrl, 'replaceState', true); } function scheduleSearchUpdate() { + if (container) { + container._tableRequestSequence = (container._tableRequestSequence || 0) + 1; + } if (pendingSearchTimer) { window.clearTimeout(pendingSearchTimer); } @@ -239,6 +264,11 @@ } if (window.addEventListener) { + window.addEventListener('popstate', function () { + Array.prototype.forEach.call(document.querySelectorAll('[data-table-search-container]'), function (container) { + loadTableResults(container, new URL(window.location.href)); + }); + }); window.addEventListener('resize', function () { syncTablePaginationCards(document); }); @@ -247,6 +277,31 @@ }); } + if (document.addEventListener) { + document.addEventListener('click', function (event) { + var link = event.target.closest && event.target.closest('.pagination a.page-link'); + if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || link.hasAttribute('download') || (link.target && link.target !== '_self')) { + return; + } + var container = link.closest('[data-table-search-container]'); + if (!container) { + return; + } + if (link.closest('.disabled, .active')) { + event.preventDefault(); + return; + } + var nextUrl = new URL(link.href, window.location.href); + var currentUrl = new URL(window.location.href); + if (nextUrl.origin !== currentUrl.origin || nextUrl.pathname !== currentUrl.pathname) { + return; + } + event.preventDefault(); + loadTableResults(container, nextUrl, 'pushState'); + }); + } + + window.loadTableResults = loadTableResults; window.initTableSearches = initTableSearches; window.replaceTableResults = replaceTableResults; syncTablePaginationCards(document); diff --git a/src/web/public/js/table/table-sort.js b/src/web/public/js/table/table-sort.js index 1ea06ec..f3df966 100644 --- a/src/web/public/js/table/table-sort.js +++ b/src/web/public/js/table/table-sort.js @@ -103,6 +103,10 @@ function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) { var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null; + if (typeof window.loadTableResults === 'function') { + return window.loadTableResults(container, nextUrl, 'replaceState'); + } + fetch(nextUrl.toString(), { method: 'GET', headers: { diff --git a/test/table-pagination-height.test.js b/test/table-pagination-height.test.js index 927860f..d03ac51 100644 --- a/test/table-pagination-height.test.js +++ b/test/table-pagination-height.test.js @@ -30,6 +30,13 @@ test('standalone slide thumbnail columns shrink to their content and keep thumbn assert.match(cellRule, /white-space:\s*nowrap;/); }); +test('table header actions retain the same right inset as the pagination footer', () => { + const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); + const toolsRule = stylesheet.match(/\[data-table-search-container\] > \.card-header:has\(> \.card-tools\) > \.card-tools\s*\{([^}]+)\}/)[1]; + + assert.match(toolsRule, /margin-right:\s*0;/); +}); + test('mobile responsive tables keep descriptive columns wide enough for phrases', () => { const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const mobileRule = stylesheet.match(/@media \(max-width: 767\.98px\)\s*\{\s*\.table-responsive > table\.table > tbody > tr > td:is\(([\s\S]*?)\)\s*\{([^}]+)\}/); @@ -119,3 +126,179 @@ test('table pagination cards keep the minimum height for tall content', () => { assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px'); assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true }); }); + +function createNavigationHarness() { + const events = {}; + const requests = []; + const navigations = []; + const history = []; + const body = { outerHTML: 'original rows' }; + const footer = { outerHTML: 'original footer' }; + const attributes = {}; + const inputEvents = {}; + const scroller = { scrollTop: 90 }; + const input = { + value: '', + getAttribute(name) { return attributes[name] || null; }, + setAttribute(name, value) { attributes[name] = value; }, + closest(selector) { return selector === '[data-table-search-container]' ? container : null; }, + addEventListener(name, handler) { inputEvents[name] = handler; }, + focus() {} + }; + const table = { tBodies: [body] }; + const container = { + querySelector(selector) { + return { + '[data-table-searchable]': table, + '.card-footer': footer, + '[data-table-search]': input, + '.table-responsive': scroller + }[selector] || null; + }, + querySelectorAll(selector) { return selector === '[data-table-search]' ? [input] : []; }, + setAttribute(name, value) { attributes[name] = value; }, + removeAttribute(name) { delete attributes[name]; } + }; + const sandbox = { + URL, + document: { + title: 'Tables', + querySelector() { return null; }, + querySelectorAll(selector) { return selector === '[data-table-search-container]' ? [container] : []; }, + addEventListener(name, handler) { events[name] = handler; } + }, + DOMParser: class { + parseFromString(text) { + const nextContainer = { + querySelector(selector) { + if (selector === '[data-table-searchable]') return { tBodies: [{ outerHTML: text + ' rows' }] }; + if (selector === '.card-footer') return { outerHTML: text + ' footer' }; + return null; + } + }; + return { + querySelector() { return text === 'missing' ? null : nextContainer; }, + querySelectorAll() { return text === 'missing' ? [] : [nextContainer]; } + }; + } + }, + fetch(url, options) { + return new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })); + }, + window: { + location: { href: 'http://localhost/slides?search=demo&sort=name', assign(url) { navigations.push(url); } }, + history: { state: { preserved: true } }, + addEventListener(name, handler) { events[name] = handler; }, + clearTimeout() {}, + setTimeout(handler) { events.timer = handler; return 1; } + } + }; + for (const method of ['pushState', 'replaceState']) { + sandbox.window.history[method] = (state, title, url) => { + history.push({ method, state, url }); + sandbox.window.location.href = new URL(url, sandbox.window.location.href).href; + }; + } + const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js'); + vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox); + sandbox.window.initTableSearches(container); + + function click(options = {}) { + const link = { + href: 'http://localhost/slides?search=demo&sort=name&page=2', + hasAttribute() { return false; }, + closest(selector) { + if (selector === '[data-table-search-container]') return container; + return options.disabled ? {} : null; + } + }; + const event = { + button: 0, + target: { closest() { return link; } }, + preventDefault() { this.defaultPrevented = true; }, + ...options + }; + events.click(event); + return event; + } + async function respond(index, text, ok = true) { + requests[index].resolve({ ok, text: async () => text }); + await new Promise(resolve => setImmediate(resolve)); + } + return { sandbox, container, events, requests, navigations, history, body, footer, attributes, input, inputEvents, scroller, click, respond }; +} + +test('pagination replaces only table results and pushes a URL preserving search and sort', async () => { + const harness = createNavigationHarness(); + assert.equal(harness.click().defaultPrevented, true); + assert.equal(harness.attributes['aria-busy'], 'true'); + assert.equal(harness.requests[0].options.headers['X-Requested-With'], 'XMLHttpRequest'); + await harness.respond(0, 'page two'); + assert.equal(harness.body.outerHTML, 'page two rows'); + assert.equal(harness.footer.outerHTML, 'page two footer'); + assert.equal(harness.history[0].method, 'pushState'); + assert.equal(harness.history[0].url, '/slides?search=demo&sort=name&page=2'); + assert.equal(harness.history[0].state.preserved, true); + assert.equal(harness.attributes['aria-busy'], undefined); + assert.equal(harness.scroller.scrollTop, 0); + assert.deepEqual(harness.navigations, []); +}); + +test('pagination leaves modified clicks native and ignores disabled links', () => { + const harness = createNavigationHarness(); + assert.equal(harness.click({ ctrlKey: true }).defaultPrevented, undefined); + assert.equal(harness.click({ button: 1 }).defaultPrevented, undefined); + assert.equal(harness.click({ disabled: true }).defaultPrevented, true); + assert.equal(harness.requests.length, 0); +}); + +test('Back and Forward fetch the URL table state without adding history entries', async () => { + const harness = createNavigationHarness(); + harness.sandbox.window.location.href = 'http://localhost/slides?search=other&page=3'; + harness.events.popstate(); + await harness.respond(0, 'restored'); + assert.equal(harness.body.outerHTML, 'restored rows'); + assert.equal(harness.input.value, 'other'); + assert.equal(harness.history.length, 0); +}); + +test('a newer request wins over slower results and stale failures', async () => { + const harness = createNavigationHarness(); + harness.click(); + harness.events.popstate(); + await harness.respond(1, 'newest'); + await harness.respond(0, 'old'); + assert.equal(harness.body.outerHTML, 'newest rows'); + harness.click(); + harness.events.popstate(); + await harness.respond(3, 'latest'); + await harness.respond(2, 'failed', false); + assert.equal(harness.body.outerHTML, 'latest rows'); + assert.deepEqual(harness.navigations, []); +}); + +test('failed or missing table results fall back to normal navigation without updating history', async () => { + for (const response of [{ text: 'error', ok: false }, { text: 'missing', ok: true }]) { + const harness = createNavigationHarness(); + harness.click(); + await harness.respond(0, response.text, response.ok); + assert.equal(harness.navigations[0], harness.requests[0].url); + assert.equal(harness.history.length, 0); + assert.equal(harness.attributes['aria-busy'], undefined); + } +}); + +test('typing invalidates pending pagination and search still resets to page one', async () => { + const harness = createNavigationHarness(); + harness.click(); + harness.input.value = 'new query'; + harness.inputEvents.input(); + await harness.respond(0, 'outdated'); + assert.equal(harness.body.outerHTML, 'original rows'); + assert.equal(harness.input.value, 'new query'); + harness.events.timer(); + assert.equal(new URL(harness.requests[1].url).searchParams.get('page'), null); + await harness.respond(1, 'searched'); + assert.equal(harness.body.outerHTML, 'searched rows'); + assert.equal(harness.history[0].method, 'replaceState'); +});