diff --git a/frontend/src/components/jobs/job-panel.ts b/frontend/src/components/jobs/job-panel.ts new file mode 100644 index 0000000..5bc5808 --- /dev/null +++ b/frontend/src/components/jobs/job-panel.ts @@ -0,0 +1,229 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { designTokens } from '../../styles/tokens.css'; +import { jobStore } from '@store/job-store'; +import type { Job, JobKind } from '@store/job-store'; +import { isTerminal } from '@store/job-store'; +import './job-row'; +import './job-details-drawer'; +import { applyJobControl } from './job-controls'; +import { jobStateStyles } from './job-format'; + +/** + * The background work of one kind, rendered wherever that work is + * started or configured. + * + * #27 folded the Jobs tab away, and the shape it folded into is this + * rather than one "Background jobs" panel in Settings — which would + * have been the tab again under another name. Reading the app first + * turned up that **four of the five job kinds already had a home** + * showing their work: Settings → Search Index draws per-tier index + * progress, `downloads-view` draws every download's lifecycle state, + * `autotag-view` draws its own apply ring, and only `library-scan` had + * nowhere but the tab. What none of those four had is the *generic* + * affordances — pause, cancel, "Details", the log, and a finished job + * you can dismiss — which is what this carries to each of them. + * + * Three things about it are load-bearing. + * + * **The controls are `applyJobControl`, not a reimplementation.** That + * is what keeps the "you will discard hours of downloading" + * confirmation on an index build alive across the move: it is keyed on + * `KindIndexBuild` inside the shared handler, and a host that rendered + * its own buttons would silently drop it. + * + * **A panel with nothing to say renders nothing at all**, host padding + * included — an idle panel in four places is four pieces of furniture + * describing an absence. That is the rule `startBackfillJob` follows + * for the indicator, one layer up. + * + * **There is no "Clear finished" here**, because `ClearFinishedJobs` is + * global: a Clear in the Libraries panel would silently discard the + * index build's history too. A finished row dismisses itself, which is + * per-job and is what `job-row` already offers. + */ +@customElement('job-panel') +export class JobPanel extends LitElement { + /** + * Comma-separated job kinds, e.g. `index-build,catalog-enrich`. + * + * An attribute rather than a property because every call site is a + * literal in a template, and one of them is inside an HTMX-adjacent + * settings page where a property binding would be one more thing to + * remember. + */ + @property({ type: String }) + kinds = ''; + + /** Heading above the rows. Omitted renders no heading. */ + @property({ type: String }) + heading = ''; + + @state() + private jobs: Job[] = []; + + @state() + private drawerJobId = ''; + + @state() + private drawerOpen = false; + + private unsubscribe: (() => void) | null = null; + + static override styles = [ + designTokens, + jobStateStyles, + css` + :host { + display: block; + margin-top: 1em; + } + + /* An empty panel takes no room at all, margin included. */ + :host([hidden]) { + display: none; + } + + h3 { + font-size: var(--yj-text-sm); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--yj-text-tertiary, #868e96); + margin: 0 0 0.5em; + } + + .card { + background: var(--yj-bg-surface, #2b3035); + border: 1px solid var(--yj-border, #495057); + border-radius: 6px; + overflow: hidden; + } + + .job-entry { + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--yj-border-subtle, #3a4046); + } + + .job-entry:last-child { + border-bottom: none; + } + + job-row { + flex: 1; + /* A grid child's implicit minimum is its content, and a + job title is long. */ + min-width: 0; + } + + .details-btn { + background: none; + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: var(--yj-text-secondary, #adb5bd); + cursor: pointer; + font-family: inherit; + font-size: var(--yj-text-sm); + padding: 0.3em 0.6em; + white-space: nowrap; + } + + .details-btn:hover { + color: var(--yj-text-primary, #e9ecef); + } + `, + ]; + + override connectedCallback() { + super.connectedCallback(); + this.unsubscribe = jobStore.subscribe(() => { + this.jobs = jobStore.jobs; + }); + this.jobs = jobStore.jobs; + void jobStore.init(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.unsubscribe?.(); + this.unsubscribe = null; + } + + /** The kinds this panel answers for. */ + private get wanted(): ReadonlySet { + return new Set( + this.kinds + .split(',') + .map((k) => k.trim()) + .filter(Boolean), + ); + } + + private get mine(): Job[] { + const wanted = this.wanted; + + return this.jobs.filter((job) => wanted.has(job.kind as JobKind)); + } + + private openDetails(id: string) { + this.drawerJobId = id; + this.drawerOpen = true; + } + + private onDrawerClosed = () => { + this.drawerOpen = false; + }; + + override render() { + const mine = this.mine; + + // Hidden rather than empty: see the class comment. The drawer + // goes with it, since it can only have been opened from a row. + this.hidden = mine.length === 0; + + if (mine.length === 0) return nothing; + + const active = mine.filter((job) => !isTerminal(job)); + const finished = mine.filter(isTerminal); + + return html` + ${this.heading ? html`

${this.heading}

` : nothing} +
+ ${[...active, ...finished].map( + (job) => html` +
+ + +
+ `, + )} +
+ + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-panel': JobPanel; + } +} diff --git a/frontend/src/store/job-store.ts b/frontend/src/store/job-store.ts index 29e83f8..7f06c49 100644 --- a/frontend/src/store/job-store.ts +++ b/frontend/src/store/job-store.ts @@ -30,6 +30,7 @@ export type JobState = export type JobKind = | 'library-scan' | 'index-build' + | 'download' | 'autotag-apply' | 'catalog-enrich'; diff --git a/frontend/test/components/job-panel.test.ts b/frontend/test/components/job-panel.test.ts new file mode 100644 index 0000000..978d42b --- /dev/null +++ b/frontend/test/components/job-panel.test.ts @@ -0,0 +1,160 @@ +/** + * Background work, shown where the work is started (#27). + * + * The Jobs tab is gone; each kind's rows now live beside the thing that + * starts it — scans in Settings → Libraries, index work in Search + * Index, downloads under the download clients, the autotag apply in the + * Autotag view. What those four surfaces never had, and what this + * carries to them, is the *generic* affordances: pause, cancel, + * "Details", and a finished job you can dismiss. + * + * The assertions are about which rows a panel owns and what its buttons + * do, not about the store — `job-store` already has the snapshot + * covered, and a panel that renders the right rows for the wrong reason + * would pass either way. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/jobs/job-panel'; +import { emit, flush, lastArgs, calls, resetHarness, stub } from '@test/support/harness'; +import { Events } from '../../src/events'; +import { fixture, shadow, shadowAll } from '@test/support/render'; +import type { LitElement } from 'lit'; + +/** A job snapshot entry, with the fields `job-row` actually reads. */ +const job = (over: Record = {}) => ({ + id: 'scan:1', + kind: 'library-scan', + title: 'Scanning Music', + state: 'running', + current: 3, + total: 10, + caps: { pausable: true, cancellable: true }, + stages: null, + stats: null, + startedAt: Date.now(), + updatedAt: Date.now(), + logCount: 0, + warnCount: 0, + errorCount: 0, + ...over, +}); + +/** Push a full snapshot, which is what the backend emits. */ +async function snapshot(jobs: unknown[]): Promise { + emit(Events.JobsChanged, jobs); + await flush(); +} + +const rows = (el: HTMLElement) => shadowAll(el, 'job-row'); + +const titles = (el: HTMLElement) => + rows(el).map((row) => (row as HTMLElement & { job: { title: string } }).job.title); + +describe('', () => { + beforeEach(async () => { + resetHarness(); + stub('jobs.Service.GetJobs', []); + await snapshot([]); + }); + + it('renders only the kinds it was asked for', async () => { + const el = await fixture('job-panel', { + kinds: 'index-build,catalog-enrich', + }); + + await snapshot([ + job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }), + job({ id: 'idx', kind: 'index-build', title: 'Building the index' }), + job({ id: 'enrich', kind: 'catalog-enrich', title: 'Filling in artists' }), + ]); + await el.updateComplete; + + // The title is inside `job-row`'s own shadow root, so this asks + // the rows what they are drawing rather than reading the panel's + // text -- which would pass whether or not a row rendered. + expect(titles(el)).toEqual(['Building the index', 'Filling in artists']); + }); + + /** + * An idle panel in four places is four pieces of furniture describing + * an absence — and `hidden` rather than an empty render, because the + * host's own margin would otherwise still be spent. + */ + it('takes up no room when it has nothing to say', async () => { + const el = await fixture('job-panel', { kinds: 'download' }); + + await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]); + await el.updateComplete; + + expect(el.hidden).toBe(true); + expect(rows(el)).toHaveLength(0); + + await snapshot([ + job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }), + ]); + await el.updateComplete; + + expect(el.hidden).toBe(false); + expect(rows(el)).toHaveLength(1); + }); + + /** + * The controls go through `applyJobControl`, which is what carries + * the index build's "you will discard hours of downloading" + * confirmation across this move. A host drawing its own buttons would + * have dropped it silently. + */ + it('pauses through the shared handler', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job()]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Pause"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.PauseJob')).toEqual(['scan:1']); + }); + + /** + * Cancelling an index build asks first; cancelling a scan does not, + * because a scan is cheap to re-run. Both answers live in + * `applyJobControl` and both had to survive the move. + */ + it('does not ask before cancelling a scan', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job()]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Stop"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.CancelJob')).toEqual(['scan:1']); + }); + + /** + * A finished job is dismissed one at a time. There is deliberately no + * "Clear finished" here: `ClearFinishedJobs` is global, so a Clear in + * the Libraries panel would discard the index build's history too. + */ + it('keeps finished jobs, dismissible one by one', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job({ state: 'complete' })]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Dismiss"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']); + expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0); + }); +});