feat(magazine): filter the page grid by status from the legend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
319d4401da
commit
0244fceb55
@ -6,6 +6,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Review opens at the top.** Opening a page in review no longer keeps the scroll position of the page grid, which could leave the sticky page bar covering the top of the page picture.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -351,6 +351,12 @@ system has settled. Each card shows, top to bottom: the page, **how many article
|
|||||||
*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do
|
*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do
|
||||||
here". Click the page image to open it in Review.
|
here". Click the page image to open it in Review.
|
||||||
|
|
||||||
|
**Filter by status.** Every entry of the legend (*not reviewed yet*, *has content, not quality-checked*, *quality check clean*, *quality check found
|
||||||
|
problems*, *reviewed*, *advertisement*, *reviewed + audio generated*, *reviewed, nothing to narrate*) is a button showing how many pages have that status. Press one
|
||||||
|
to show only those pages; press several to show pages that have **any** of them; press one again to take it off. A status no page has is greyed out.
|
||||||
|
It combines with **Show only pages that need me** (a page must match both), the summary says "Showing N of M pages", and **Show all pages** clears every
|
||||||
|
filter at once. The buttons are real toggle buttons (they announce whether they are on) and can be used from the keyboard.
|
||||||
|
|
||||||
**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found*
|
**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found*
|
||||||
lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink
|
lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink
|
||||||
overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the
|
overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the
|
||||||
|
|||||||
@ -1205,6 +1205,28 @@ function magTogglePagesTodo(on) {
|
|||||||
renderContents();
|
renderContents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter the page grid by status: each legend entry is a toggle, several can be on at once (a page matches if its status
|
||||||
|
// is any of the chosen ones), and it combines with "Show only pages that need me". The choice survives re-renders.
|
||||||
|
function magTogglePagesStatus(status) {
|
||||||
|
const set = state.pagesStatus || (state.pagesStatus = new Set());
|
||||||
|
if (set.has(status)) set.delete(status); else set.add(status);
|
||||||
|
renderContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function magClearPagesFilters() {
|
||||||
|
state.pagesStatus = new Set();
|
||||||
|
state.pagesOnlyTodo = false;
|
||||||
|
renderContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which pages the grid shows, given the current filters. Pure, so it can be tested without a DOM.
|
||||||
|
function magPageVisible(statusName, needs, filters) {
|
||||||
|
const f = filters || {};
|
||||||
|
if (f.onlyTodo && !needs) return false;
|
||||||
|
const chosen = f.status instanceof Set ? f.status : new Set(f.status || []);
|
||||||
|
return chosen.size === 0 || chosen.has(statusName);
|
||||||
|
}
|
||||||
|
|
||||||
function renderPagesView(host) {
|
function renderPagesView(host) {
|
||||||
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
|
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
|
||||||
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
|
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
|
||||||
@ -1229,17 +1251,30 @@ function renderPagesView(host) {
|
|||||||
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
|
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
|
||||||
}
|
}
|
||||||
const needing = [...guides.values()].filter((g) => g.guide.needs).length;
|
const needing = [...guides.values()].filter((g) => g.guide.needs).length;
|
||||||
|
const counts = new Map();
|
||||||
|
guides.forEach((g) => counts.set(g.st, (counts.get(g.st) || 0) + 1));
|
||||||
|
const chosen = state.pagesStatus instanceof Set ? state.pagesStatus : new Set();
|
||||||
|
const filters = { onlyTodo: state.pagesOnlyTodo, status: chosen };
|
||||||
|
const shown = [...guides.values()].filter((g) => magPageVisible(g.st, g.guide.needs, filters)).length;
|
||||||
let html = '<div class="mag-pages-help">' +
|
let html = '<div class="mag-pages-help">' +
|
||||||
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
|
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
|
||||||
|
(shown !== pageCount ? '<span class="mag-pages-shown"> Showing ' + shown + ' of ' + pageCount + ' pages.</span> ' : '') +
|
||||||
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
|
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
|
||||||
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
|
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
|
||||||
'<p class="mag-pages-legend" aria-label="Card colours">' +
|
'<div class="mag-pages-legend" role="group" aria-label="Filter the pages by status">' +
|
||||||
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
|
legend.map(([cls, label]) => {
|
||||||
'</p></div>';
|
const n = counts.get(cls) || 0;
|
||||||
|
const on = chosen.has(cls);
|
||||||
|
return '<button type="button" class="mag-pages-legend-item mag-legend-filter' + (on ? ' is-on' : '') + '" aria-pressed="' + on + '" data-status="' + cls + '"' +
|
||||||
|
(!n && !on ? ' disabled' : '') + ' onclick="magTogglePagesStatus(\'' + cls + '\')" title="' + (on ? 'Stop showing only' : 'Show only') + ' pages that are: ' + esc(label) + '">' +
|
||||||
|
'<span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + esc(label) + ' <span class="mag-legend-count">' + n + '</span></button>';
|
||||||
|
}).join('') +
|
||||||
|
(chosen.size || state.pagesOnlyTodo ? '<button type="button" class="btn-sm mag-legend-clear" onclick="magClearPagesFilters()">Show all pages</button>' : '') +
|
||||||
|
'</div></div>';
|
||||||
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
|
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
|
||||||
for (let p = 1; p <= pageCount; p++) {
|
for (let p = 1; p <= pageCount; p++) {
|
||||||
const { st, arts, guide } = guides.get(p);
|
const { st, arts, guide } = guides.get(p);
|
||||||
if (state.pagesOnlyTodo && !guide.needs) continue;
|
if (!magPageVisible(st, guide.needs, filters)) continue;
|
||||||
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
|
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
|
||||||
// first article on it). A separate Review button per article only appears when the page
|
// first article on it). A separate Review button per article only appears when the page
|
||||||
// really holds several, to say which one is meant.
|
// really holds several, to say which one is meant.
|
||||||
@ -1276,7 +1311,7 @@ function renderPagesView(host) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
}
|
}
|
||||||
if (state.pagesOnlyTodo && !needing) html += '<p class="muted">No page needs you.</p>';
|
if (!shown) html += '<p class="muted">' + (chosen.size ? 'No page has that status' + (state.pagesOnlyTodo ? ' and needs you' : '') + '.' : 'No page needs you.') + '</p>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
}
|
}
|
||||||
@ -5433,12 +5468,16 @@ async function magPlayPodcast() {
|
|||||||
// ── View switching ───────────────────────────────────────────────────────────
|
// ── View switching ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function magShow(view) {
|
function magShow(view) {
|
||||||
|
const changed = state.view !== view;
|
||||||
state.view = view;
|
state.view = view;
|
||||||
if (view !== 'review') magClearFloatingPanels();
|
if (view !== 'review') magClearFloatingPanels();
|
||||||
['library', 'contents', 'review', 'read'].forEach((v) => {
|
['library', 'contents', 'review', 'read'].forEach((v) => {
|
||||||
const el = $('mag-view-' + v);
|
const el = $('mag-view-' + v);
|
||||||
if (el) el.hidden = v !== view;
|
if (el) el.hidden = v !== view;
|
||||||
});
|
});
|
||||||
|
// A new view starts at its top: the scroll offset of the page grid (which grows with the filter legend) must not
|
||||||
|
// carry into review, where it would leave the sticky page bar covering the top of the page picture.
|
||||||
|
if (changed && view === 'review') { const main = $('main-content'); if (main) main.scrollTop = 0; }
|
||||||
if (view !== 'read') magPause();
|
if (view !== 'read') magPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5900,6 +5939,9 @@ root.magRead = magRead;
|
|||||||
root.magPageGuide = magPageGuide;
|
root.magPageGuide = magPageGuide;
|
||||||
root.magArticleFlow = magArticleFlow;
|
root.magArticleFlow = magArticleFlow;
|
||||||
root.magTogglePagesTodo = magTogglePagesTodo;
|
root.magTogglePagesTodo = magTogglePagesTodo;
|
||||||
|
root.magTogglePagesStatus = magTogglePagesStatus;
|
||||||
|
root.magClearPagesFilters = magClearPagesFilters;
|
||||||
|
root.magPageVisible = magPageVisible;
|
||||||
root.magFindSearch = magFindSearch;
|
root.magFindSearch = magFindSearch;
|
||||||
root.magCatAdd = magCatAdd;
|
root.magCatAdd = magCatAdd;
|
||||||
root.magCatRemove = magCatRemove;
|
root.magCatRemove = magCatRemove;
|
||||||
|
|||||||
@ -5659,3 +5659,14 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
|
|||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; line-height: 1.4; }
|
background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; line-height: 1.4; }
|
||||||
.mag-review-split.is-stale { opacity: .4; pointer-events: none; filter: grayscale(.6); transition: opacity .15s; }
|
.mag-review-split.is-stale { opacity: .4; pointer-events: none; filter: grayscale(.6); transition: opacity .15s; }
|
||||||
@media (prefers-reduced-motion: reduce) { .mag-review-split.is-stale { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .mag-review-split.is-stale { transition: none; } }
|
||||||
|
|
||||||
|
/* Legend entries are status filters */
|
||||||
|
.mag-legend-filter { font: inherit; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 999px;
|
||||||
|
padding: 4px 10px; cursor: pointer; }
|
||||||
|
.mag-legend-filter:hover:not(:disabled) { border-color: var(--border); background: var(--panel); }
|
||||||
|
.mag-legend-filter:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.mag-legend-filter.is-on, .mag-legend-filter.is-on:hover:not(:disabled) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--surface)); font-weight: 700; }
|
||||||
|
.mag-legend-filter:disabled { opacity: .45; cursor: default; }
|
||||||
|
.mag-legend-count { min-width: 1.6em; padding: 0 6px; border-radius: 999px; background: var(--panel); font-size: 12px; text-align: center; }
|
||||||
|
.mag-pages-shown { font-weight: 600; color: var(--accent); }
|
||||||
|
.mag-legend-clear { margin-left: 6px; }
|
||||||
|
|||||||
@ -2328,3 +2328,110 @@ test.describe('A turned page shows at once, and its net turn is visible', () =>
|
|||||||
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Filtering the page grid by status', () => {
|
||||||
|
const art = (id, title, pages, status, extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
|
||||||
|
async function openGrid(page) {
|
||||||
|
await mockBackend(page, { articleStatus: 'approved' });
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [
|
||||||
|
art('a1', 'Done piece', [3], 'approved', { has_audio: true }),
|
||||||
|
art('a2', 'Open piece', [4], 'detected'),
|
||||||
|
art('a3', 'Approved piece', [5], 'approved'),
|
||||||
|
art('a4', 'Problem piece', [6], 'detected'),
|
||||||
|
], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { summary: {}, pages: [
|
||||||
|
{ page: 6, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }] } }));
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
await page.locator('#mag-library-list .mag-card').first().click();
|
||||||
|
await page.locator('#mag-contents-view-toggle').click();
|
||||||
|
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
const shownPages = (page) => page.locator('.mag-pages-grid .mag-card-wrap').evaluateAll(els => els.map(e => Number(e.dataset.page)));
|
||||||
|
const chip = (page, status) => page.locator(`.mag-legend-filter[data-status="${status}"]`);
|
||||||
|
|
||||||
|
test('the filter logic: nothing chosen shows everything, several choices are "any of", and it combines with needs-me', async ({ page }) => {
|
||||||
|
await mockBackend(page);
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
const v = (st, needs, f) => page.evaluate(([a, b, c]) => window.magPageVisible(a, b, c), [st, needs, f]);
|
||||||
|
expect(await v('audio', false, {})).toBe(true);
|
||||||
|
expect(await v('audio', false, { status: [] })).toBe(true);
|
||||||
|
expect(await v('audio', false, { status: ['flagged'] })).toBe(false);
|
||||||
|
expect(await v('audio', false, { status: ['flagged', 'audio'] })).toBe(true);
|
||||||
|
expect(await v('flagged', true, { onlyTodo: true, status: ['flagged'] })).toBe(true);
|
||||||
|
expect(await v('flagged', false, { onlyTodo: true, status: ['flagged'] })).toBe(false); // both filters must agree
|
||||||
|
expect(await v('audio', true, { onlyTodo: true, status: ['flagged'] })).toBe(false);
|
||||||
|
expect(await v('audio', false, { onlyTodo: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each legend entry is a toggle button with the number of pages in that status', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
const group = page.getByRole('group', { name: 'Filter the pages by status' });
|
||||||
|
await expect(group.getByRole('button', { pressed: false }).first()).toBeVisible();
|
||||||
|
await expect(chip(page, 'audio')).toContainText('1');
|
||||||
|
await expect(chip(page, 'detected')).toContainText('1');
|
||||||
|
await expect(chip(page, 'flagged')).toContainText('1');
|
||||||
|
await expect(chip(page, 'blank')).toContainText('4');
|
||||||
|
await expect(chip(page, 'ad')).toBeDisabled(); // none of those: nothing to filter to
|
||||||
|
await expect(chip(page, 'audio')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing a status shows only those pages, and the summary says how many', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
expect((await shownPages(page)).length).toBe(8);
|
||||||
|
await chip(page, 'blank').click();
|
||||||
|
expect(await shownPages(page)).toEqual([1, 2, 7, 8]);
|
||||||
|
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.mag-pages-shown')).toHaveText(/Showing 4 of 8 pages/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several statuses add up, and choosing one again takes it off', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
await chip(page, 'audio').click();
|
||||||
|
await chip(page, 'flagged').click();
|
||||||
|
expect(await shownPages(page)).toEqual([3, 6]);
|
||||||
|
await chip(page, 'audio').click();
|
||||||
|
expect(await shownPages(page)).toEqual([6]);
|
||||||
|
await chip(page, 'flagged').click();
|
||||||
|
expect((await shownPages(page)).length).toBe(8);
|
||||||
|
await expect(page.locator('.mag-pages-shown')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it combines with "Show only pages that need me"', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
await page.getByLabel('Show only pages that need me').check();
|
||||||
|
const needing = await shownPages(page);
|
||||||
|
expect(needing).toEqual(expect.arrayContaining([4, 6]));
|
||||||
|
await chip(page, 'detected').click();
|
||||||
|
expect(await shownPages(page)).toEqual([4]); // needs me AND detected
|
||||||
|
await chip(page, 'detected').click();
|
||||||
|
await chip(page, 'audio').click();
|
||||||
|
expect(await shownPages(page)).toEqual([]); // page 3 is done: it does not need me
|
||||||
|
await expect(page.locator('.mag-pages-grid + *, .mag-pages-grid')).toContainText('No page has that status and needs you');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Show all pages" clears every filter and only appears while one is on', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
|
||||||
|
await chip(page, 'blank').click();
|
||||||
|
await page.getByLabel('Show only pages that need me').check();
|
||||||
|
await page.getByRole('button', { name: 'Show all pages' }).click();
|
||||||
|
expect((await shownPages(page)).length).toBe(8);
|
||||||
|
await expect(page.getByLabel('Show only pages that need me')).not.toBeChecked();
|
||||||
|
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the filter survives a change of the column count and works from the keyboard', async ({ page }) => {
|
||||||
|
await openGrid(page);
|
||||||
|
await chip(page, 'flagged').focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
expect(await shownPages(page)).toEqual([6]);
|
||||||
|
await page.evaluate(() => window.magSetPagesCols(5));
|
||||||
|
expect(await shownPages(page)).toEqual([6]);
|
||||||
|
await expect(chip(page, 'flagged')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user