Add asynchronous table pagination to v2.17.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m11s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-23 21:46:05 +01:00
1 parent 86b9609ea7
commit ab4a40ccd8
5 files changed
+300 -52

No files matched your search

+183
View File
@@ -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');
});