feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
|
||||
|
||||
// The queues that wait on an admin (#5605), in the order the Overview lists
|
||||
// them. The rail and the Overview read the same list, so a queue that
|
||||
// needs you is counted the same in both places.
|
||||
// them. The rails and the Overview read the same list, so a queue that
|
||||
// needs you is counted the same in every place. Requests live under Admin;
|
||||
// the library's health queues under Collection (M504).
|
||||
export type QueueKey = keyof AdminSummary;
|
||||
|
||||
export type QueueDef = {
|
||||
@@ -17,9 +18,9 @@ export type QueueDef = {
|
||||
|
||||
export const QUEUES: QueueDef[] = [
|
||||
{ key: 'requests', href: '/admin/requests', one: 'request to decide', many: 'requests to decide', none: 'requests' },
|
||||
{ key: 'reports', href: '/admin/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
|
||||
{ key: 'duplicates', href: '/admin/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
|
||||
{ key: 'suspect', href: '/admin/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
|
||||
{ key: 'reports', href: '/collection/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
|
||||
{ key: 'duplicates', href: '/collection/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
|
||||
{ key: 'suspect', href: '/collection/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
|
||||
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
|
||||
];
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import SectionRail from '#lib/components/SectionRail.svelte';
|
||||
import type { WantCounts } from '#lib/api/types.js';
|
||||
import { countOf } from '#lib/admin/queues.js';
|
||||
import type { AdminSummary, WantCounts } from '#lib/api/types.js';
|
||||
|
||||
// The Collection's sections, down the left (#5572): the catalogue of
|
||||
// artists first, then the sections that show state. Activity carries a
|
||||
// second, accented count when downloads are waiting on a person. Unmatched
|
||||
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
|
||||
// holds the albums held but wanted for a better copy (#5524).
|
||||
// holds the albums held but wanted for a better copy (#5524). Reports,
|
||||
// Duplicates and Suspect sources are the library's health queues (M504):
|
||||
// their counts come from the admin summary and are accented, as they wait
|
||||
// on a person.
|
||||
|
||||
let { counts }: { counts?: WantCounts } = $props();
|
||||
let { counts, summary }: { counts?: WantCounts; summary?: AdminSummary } = $props();
|
||||
|
||||
// An artist's page belongs to the catalogue.
|
||||
const items = $derived([
|
||||
@@ -19,7 +23,10 @@
|
||||
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
|
||||
{ href: '/collection/following', label: 'Following', count: counts?.following },
|
||||
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
|
||||
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
|
||||
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched },
|
||||
{ href: '/collection/reports', label: 'Reports', attention: countOf(summary, 'reports') },
|
||||
{ href: '/collection/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// the Collection are, so the sections sit in the same place everywhere
|
||||
// and fit a phone. Each queue carries how many wait on you, from one
|
||||
// summary read that is refreshed whenever you move between admin pages.
|
||||
// The library's health queues are under Collection (M504).
|
||||
const summaryStore = createAdminSummaryQuery();
|
||||
const summary = $derived($summaryStore.data);
|
||||
|
||||
@@ -28,9 +29,6 @@
|
||||
const items: RailItem[] = $derived([
|
||||
{ href: '/admin', label: 'Overview', exact: true },
|
||||
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
|
||||
{ href: '/admin/reports', label: 'Reports', attention: countOf(summary, 'reports') },
|
||||
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
|
||||
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
|
||||
{ href: '/admin/library', label: 'Library' },
|
||||
{ href: '/admin/acquisition', label: 'Acquisition' },
|
||||
|
||||
@@ -116,6 +116,16 @@ describe('admin Overview page', () => {
|
||||
expect(screen.getByTestId('all-clear').textContent).not.toMatch(/requests/);
|
||||
});
|
||||
|
||||
// The library's health queues live under Collection (M504); the Overview
|
||||
// still lists them, linking there.
|
||||
test('a library queue that waits on you opens its Collection page', () => {
|
||||
setupOverview(summary({ duplicates: { count: 5, since: ago(4) } }));
|
||||
render(OverviewPage);
|
||||
expect(screen.getByRole('link', { name: /5 sets of duplicates to review/i }).getAttribute('href')).toBe(
|
||||
'/collection/duplicates'
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed scan needs you too', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
@@ -158,9 +168,6 @@ describe('admin layout', () => {
|
||||
expect(links).toEqual([
|
||||
['/admin', 'Overview'],
|
||||
['/admin/requests', 'Requests 2'],
|
||||
['/admin/reports', 'Reports'],
|
||||
['/admin/duplicates', 'Duplicates 5'],
|
||||
['/admin/suspect-sources', 'Suspect sources'],
|
||||
['/admin/missing-files', 'Missing files'],
|
||||
['/admin/library', 'Library'],
|
||||
['/admin/acquisition', 'Acquisition'],
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
|
||||
// The old address stays because notifications and the Android app link to
|
||||
// it, and a link already sent should still land somewhere useful. A
|
||||
// universal load, as the app is adapter-static with ssr off.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/admin/reports');
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
|
||||
// The old address stays because notifications and the Android app link to
|
||||
// it, and a link already sent should still land somewhere useful. A
|
||||
// universal load, as the app is adapter-static with ssr off.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/admin/reports');
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||
import CollectionRail from '#lib/components/wanted/CollectionRail.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
@@ -28,6 +30,15 @@
|
||||
return parts.join(', ') + '. Nothing needs you.';
|
||||
});
|
||||
const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
|
||||
|
||||
// The library's health queues (M504) count from the admin summary, the
|
||||
// same read Admin's Overview makes. The line under the verdict names the
|
||||
// ones with something in them; each links to its page.
|
||||
const summaryStore = createAdminSummaryQuery();
|
||||
const summary = $derived($summaryStore.data);
|
||||
const health = $derived(
|
||||
QUEUES.filter((d) => d.href.startsWith('/collection/') && countOf(summary, d.key) > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -50,12 +61,21 @@
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if health.length > 0}
|
||||
<p class="text-sm text-text-primary" data-testid="collection-health">
|
||||
To look at:
|
||||
{#each health as d, i (d.key)}<a href={d.href} class="text-accent-fg hover:underline"
|
||||
>{queueLabel(d, countOf(summary, d.key))}</a
|
||||
>{i < health.length - 1 ? ', ' : '.'}
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<div class="md:sticky md:top-4">
|
||||
<CollectionRail {counts} />
|
||||
<CollectionRail {counts} {summary} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
|
||||
@@ -3,22 +3,31 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { mockInfiniteQuery } from '../../test-utils/query';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import type { AdminSummary, WantArtistsPage, WantCounts } from '#lib/api/types.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
|
||||
vi.mock('#lib/api/admin.js', () => ({ createAdminSummaryQuery: vi.fn() }));
|
||||
|
||||
import WantedLayout from './+layout.svelte';
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||
|
||||
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, upgrades: 6, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
|
||||
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(c: WantCounts = counts, path = '/collection') {
|
||||
const none = { count: 0, since: null };
|
||||
const clear: AdminSummary = { requests: none, reports: none, duplicates: none, suspect: none, missing: none };
|
||||
|
||||
function setup(c: WantCounts = counts, path = '/collection', summary: AdminSummary = clear) {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
vi.mocked(createAdminSummaryQuery).mockReturnValue(
|
||||
mockQuery({ data: summary }) as unknown as ReturnType<typeof createAdminSummaryQuery>
|
||||
);
|
||||
const page: WantArtistsPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
@@ -47,7 +56,10 @@ describe('/collection layout', () => {
|
||||
['/collection/activity', 'Activity 3'],
|
||||
['/collection/following', 'Following 2'],
|
||||
['/collection/blocked', 'Blocked 4'],
|
||||
['/collection/unmatched', 'Unmatched 3']
|
||||
['/collection/unmatched', 'Unmatched 3'],
|
||||
['/collection/reports', 'Reports'],
|
||||
['/collection/duplicates', 'Duplicates'],
|
||||
['/collection/suspect-sources', 'Suspect sources']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
|
||||
@@ -76,6 +88,33 @@ describe('/collection layout', () => {
|
||||
expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
|
||||
});
|
||||
|
||||
// The library's health queues moved here from Admin (M504): their counts
|
||||
// come from the admin summary, accented, and a line names the ones waiting.
|
||||
test('reports, duplicates and suspect sources carry what waits on you, and a line links to each', () => {
|
||||
setup(counts, '/collection', {
|
||||
...clear,
|
||||
reports: { count: 2, since: null },
|
||||
duplicates: { count: 5, since: null },
|
||||
missing: { count: 9, since: null }
|
||||
});
|
||||
const nav = screen.getByRole('navigation', { name: /collection sections/i });
|
||||
const dup = Array.from(nav.querySelectorAll('a')).find((a) => a.getAttribute('href') === '/collection/duplicates')!;
|
||||
expect(dup.textContent?.replace(/\s+/g, ' ').trim()).toBe('Duplicates 5');
|
||||
const health = screen.getByTestId('collection-health');
|
||||
expect(health.textContent?.replace(/\s+/g, ' ').trim()).toBe(
|
||||
'To look at: 2 broken tracks reported, 5 sets of duplicates to review.'
|
||||
);
|
||||
expect(Array.from(health.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual([
|
||||
'/collection/reports',
|
||||
'/collection/duplicates'
|
||||
]);
|
||||
});
|
||||
|
||||
test('with nothing waiting there is no health line', () => {
|
||||
setup();
|
||||
expect(screen.queryByTestId('collection-health')).toBeNull();
|
||||
});
|
||||
|
||||
test("an artist's page sits under Catalogue", () => {
|
||||
setup(counts, '/collection/artist/blur');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Duplicates')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
+1
-1
@@ -147,7 +147,7 @@
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Reports')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
+1
-1
@@ -63,7 +63,7 @@ function setup(q: AdminQuarantineRow[], e: AdminPlaybackError[], opts: { error?:
|
||||
return render(Page);
|
||||
}
|
||||
|
||||
describe('/admin/reports', () => {
|
||||
describe('/collection/reports', () => {
|
||||
test('a flagged track and a track that failed to play are one list, one row per track, newest first', () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1), failure('e2', 't2', 'Intro', 9)]);
|
||||
const rows = screen.getAllByTestId('report-row');
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Suspect sources')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Suspect sources')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
Reference in New Issue
Block a user