Sort stories through column headings

This commit is contained in:
tim
2026-10-01 16:08:42 +02:00
parent b536a1f116
commit e4a17029c1
4 changed files with 279 additions and 11 deletions
+27
View File
@@ -103,6 +103,33 @@ test('story timestamps fit their text and chapter details use plain small text',
assert.equal(details.padding, undefined);
});
test('sortable headings show direction and remain usable above story cards on narrow screens', () => {
assert.equal(declarations('.stories-table th button').background, 'none');
assert.equal(declarations('.stories-table th[aria-sort] button::after').content, "'↕'");
assert.equal(declarations(".stories-table th[aria-sort='ascending'] button::after").content, "'↑'");
assert.equal(declarations(".stories-table th[aria-sort='descending'] button::after").content, "'↓'");
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
assert.equal(declarations('.stories-table thead tr', mobile).display, 'flex');
assert.notEqual(declarations('.stories-table th', mobile).display, 'none');
assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/);
});
test('sortable headings fill the column with one clickable highlight without shifting layout', () => {
assert.equal(declarations('.stories-table th').padding, '0');
const button = declarations('.stories-table th button');
assert.equal(button['inline-size'], '100%');
assert.equal(button['justify-content'], 'flex-start');
assert.equal(button.padding, '6px 8px');
assert.equal(button.margin, undefined);
const highlight = declarations('.stories-table th button:is(:hover, :focus-visible)');
assert.equal(highlight.background, 'var(--info-bg)');
assert.equal(highlight.color, 'var(--info)');
assert.equal(highlight['text-decoration'], undefined);
assert.equal(highlight.padding, undefined);
assert.equal(highlight.border, undefined);
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
});
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
+150 -2
View File
@@ -1,10 +1,10 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
import { click, field, find, input, install, nodes, server, settle } from './flow-helpers.mjs';
const { app } = install();
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js');
const { fmtDate } = await import('../../bindery/static/js/lib.js');
const story = (id, title, changes = {}) => ({
@@ -16,7 +16,9 @@ const rows = () => {
const table = nodes(app).find((el) => el.className === 'table-wrap stories-table');
return nodes(table).find((el) => el.tagName === 'tbody').children;
};
const titles = () => rows().map((row) => row.children[0].textContent);
const cell = (row, label) => row.children.find((el) => el.attributes['data-label'] === label);
const heading = (label) => nodes(app).find((el) => el.tagName === 'th' && el.textContent === label);
async function mount(t, list) {
const backend = server(t);
@@ -28,6 +30,152 @@ async function mount(t, list) {
return backend;
}
test('column headings toggle sorting, with newest dates first and missing dates always last', async (t) => {
const backend = await mount(t, [
story(3, 'Gamma', { last_changed: '2026-01-01T12:00:00Z', last_checked: '2026-01-03T12:00:00Z' }),
story(2, 'Beta', { last_changed: '2026-01-02T12:00:00Z', last_checked: '2026-01-02T12:00:00Z' }),
story(4, 'No date'),
story(1, 'Alpha', { last_changed: '2026-01-03T12:00:00Z', last_checked: '2026-01-01T12:00:00Z' }),
]);
assert.equal(field(app, 'Sort stories by'), undefined);
assert.equal(heading('Title').attributes['aria-sort'], 'ascending');
assert.equal(heading('Last change').attributes['aria-sort'], 'none');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, newest first');
await click(app, 'Last change');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
assert.equal(heading('Title').attributes['aria-sort'], 'none');
assert.equal(heading('Last change').attributes['aria-sort'], 'descending');
assert.equal(document.activeElement, find(app, 'button', 'Last change'));
assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, oldest first');
await click(app, 'Last change');
assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']);
assert.equal(heading('Last change').attributes['aria-sort'], 'ascending');
await click(app, 'Last check');
assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']);
assert.equal(heading('Last check').attributes['aria-sort'], 'descending');
await click(app, 'Last check');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
assert.equal(heading('Last check').attributes['aria-sort'], 'ascending');
await click(app, 'Title');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
await click(app, 'Title');
assert.deepEqual(titles(), ['No date', 'Gamma', 'Beta', 'Alpha']);
assert.equal(heading('Title').attributes['aria-sort'], 'descending');
await click(app, 'Last change');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
assert.equal(backend.sent('GET', 'stories').length, 1);
});
test('title sorting and tie breaks keep ignoring leading articles', async (t) => {
await mount(t, ['The Zebra', 'an Owl', 'Anthem', 'A Bee', 'Theory', 'Cat'].map((title, i) => story(i + 1, title)));
const order = ['Anthem', 'A Bee', 'Cat', 'an Owl', 'Theory', 'The Zebra'];
assert.deepEqual(titles(), order);
await click(app, 'Title');
assert.deepEqual(titles(), [...order].reverse());
await click(app, 'Chapters');
assert.deepEqual(titles(), order);
});
test('chapter sorting compares counts numerically and keeps equal counts in title order', async (t) => {
await mount(t, [
story(1, 'Zeta', { chapter_count: 2, offsite_count: 2, revised_count: 1 }),
story(2, 'Alpha', { chapter_count: 10 }),
story(3, 'Beta', { chapter_count: 10 }),
story(4, 'Gamma', { chapter_count: 0 }),
story(5, 'Omega', { chapter_count: 100 }),
]);
assert.equal(find(app, 'button', 'Chapters').attributes['aria-label'], 'Sort by chapters, most first');
await click(app, 'Chapters');
assert.deepEqual(titles(), ['Omega', 'Alpha', 'Beta', 'Zeta', 'Gamma']);
assert.equal(heading('Chapters').attributes['aria-sort'], 'descending');
await click(app, 'Chapters');
assert.deepEqual(titles(), ['Gamma', 'Zeta', 'Alpha', 'Beta', 'Omega']);
assert.equal(heading('Chapters').attributes['aria-sort'], 'ascending');
});
for (const [label, key] of [['Author', 'author'], ['Site', 'site'], ['Status', 'status']]) {
test(`${label} sorts alphabetically in either direction and uses titles to break ties`, async (t) => {
await mount(t, [
story(1, 'Zeta', { [key]: 'ok' }),
story(3, 'Beta', { [key]: 'error' }),
story(2, 'Alpha', { [key]: 'ERROR' }),
story(4, 'Gamma', { [key]: null }),
story(5, 'Omega', { [key]: 'queued' }),
]);
await click(app, label);
assert.deepEqual(titles(), ['Gamma', 'Alpha', 'Beta', 'Zeta', 'Omega']);
assert.equal(heading(label).attributes['aria-sort'], 'ascending');
await click(app, label);
assert.deepEqual(titles(), ['Omega', 'Zeta', 'Alpha', 'Beta', 'Gamma']);
assert.equal(heading(label).attributes['aria-sort'], 'descending');
});
}
test('date sorting compares instants, handles invalid dates and breaks ties by title and id', async (t) => {
await mount(t, [
story(7, 'Missing', { last_changed: 'not a date' }),
story(6, 'Unknown'),
story(5, 'Epoch', { last_changed: '1970-01-01T00:00:00Z' }),
story(4, 'Before epoch', { last_changed: '1969-01-01T00:00:00Z' }),
story(3, 'Zulu', { last_changed: '2026-01-02T00:00:00+02:00' }),
story(2, 'alpha', { last_changed: '2026-01-01T23:00:00Z' }),
story(1, 'Alpha', { last_changed: '2026-01-01T23:00:00Z' }),
story(8, '', { last_changed: '2026-01-01T23:00:00Z' }),
]);
await click(app, 'Last change');
assert.deepEqual(titles(), ['Alpha', 'alpha', 'https://example.com/8', 'Zulu', 'Epoch', 'Before epoch', 'Missing', 'Unknown']);
assert.equal(cell(rows()[6], 'Last change').textContent, 'not a date');
assert.equal(cell(rows()[7], 'Last change').textContent, '');
await click(app, 'Last change');
assert.deepEqual(titles(), ['Before epoch', 'Epoch', 'Zulu', 'Alpha', 'alpha', 'https://example.com/8', 'Missing', 'Unknown']);
});
test('headings sort the filtered list after the initial story request finishes', async (t) => {
let resolve;
await mount(t, () => new Promise((done) => { resolve = done; }));
assert.equal(find(app, 'button', 'Last check'), undefined);
input(field(app, 'Filter stories'), 'Reader');
resolve([
story(1, 'Alpha', { last_checked: '2026-01-01T00:00:00Z' }),
story(2, 'Beta', { last_checked: '2026-01-02T00:00:00Z' }),
]);
await settle();
await click(app, 'Last check');
assert.deepEqual(titles(), ['Beta', 'Alpha']);
});
test('filtering and job refreshes keep the selected sort without dropping hidden stories', async (t) => {
let list = [
story(1, 'Alpha', { last_changed: '2026-01-01T00:00:00Z' }),
story(2, 'Beta', { last_changed: '2026-01-02T00:00:00Z' }),
story(3, 'Gamma', { last_changed: '2026-01-03T00:00:00Z', author: 'Someone else' }),
];
const backend = await mount(t, () => list);
await click(app, 'Last change');
input(field(app, 'Filter stories'), 'Reader');
assert.deepEqual(titles(), ['Beta', 'Alpha']);
backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]);
startPolling();
await settle();
list = [{ ...list[0], last_changed: '2026-01-04T00:00:00Z' }, ...list.slice(1)];
backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
await refreshJobs();
await settle();
assert.equal(backend.sent('GET', 'stories').length, 2);
assert.equal(heading('Last change').attributes['aria-sort'], 'descending');
assert.equal(document.activeElement, find(app, 'button', 'Last change'));
assert.deepEqual(titles(), ['Alpha', 'Beta']);
input(field(app, 'Filter stories'), 'missing');
assert.match(app.textContent, /No stories match/);
input(field(app, 'Filter stories'), '');
assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']);
await click(app, 'Last check');
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma']);
await click(app, 'Last change');
assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']);
});
test('date columns keep each timestamp on one line, with last change before last check', async (t) => {
const changed = '2026-01-02T03:04:00Z';
const checked = '2026-01-03T05:06:00Z';