Fold the Jobs tab into the places the work is started #147
@@ -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<string> {
|
||||
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`<h3>${this.heading}</h3>` : nothing}
|
||||
<div class="card">
|
||||
${[...active, ...finished].map(
|
||||
(job) => html`
|
||||
<div class="job-entry">
|
||||
<job-row
|
||||
.job=${job}
|
||||
variant="full"
|
||||
@job-control=${applyJobControl}
|
||||
></job-row>
|
||||
<button
|
||||
type="button"
|
||||
class="details-btn"
|
||||
@click=${() => this.openDetails(job.id)}
|
||||
>
|
||||
Details${job.warnCount
|
||||
? ` · ${job.warnCount}⚠`
|
||||
: ''}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<job-details-drawer
|
||||
job-id=${this.drawerJobId}
|
||||
?open=${this.drawerOpen}
|
||||
@drawer-closed=${this.onDrawerClosed}
|
||||
></job-details-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'job-panel': JobPanel;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export type JobState =
|
||||
export type JobKind =
|
||||
| 'library-scan'
|
||||
| 'index-build'
|
||||
| 'download'
|
||||
| 'autotag-apply'
|
||||
| 'catalog-enrich';
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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<void> {
|
||||
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('<job-panel>', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
stub('jobs.Service.GetJobs', []);
|
||||
await snapshot([]);
|
||||
});
|
||||
|
||||
it('renders only the kinds it was asked for', async () => {
|
||||
const el = await fixture<LitElement>('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<LitElement>('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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||
|
||||
await snapshot([job()]);
|
||||
await el.updateComplete;
|
||||
|
||||
const row = rows(el)[0]!;
|
||||
|
||||
shadow<HTMLButtonElement>(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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||
|
||||
await snapshot([job()]);
|
||||
await el.updateComplete;
|
||||
|
||||
const row = rows(el)[0]!;
|
||||
|
||||
shadow<HTMLButtonElement>(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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||
|
||||
await snapshot([job({ state: 'complete' })]);
|
||||
await el.updateComplete;
|
||||
|
||||
const row = rows(el)[0]!;
|
||||
|
||||
shadow<HTMLButtonElement>(row, 'button[aria-label^="Dismiss"]')?.click();
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']);
|
||||
expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user