diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index 447ac6b..cdc7072 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -2,12 +2,14 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import { EventsOn } from '@runtime/runtime'; import { AddLibrary, GetAllLibrariesWithTrackCounts, } from '@go/library/library.js'; import { describeError, explainError } from '@utils/describe-error'; import { nameDialogsIn } from '@utils/name-dialog'; +import { Events } from '../../events'; import { pickDirectory } from '../../utils/pick-directory'; /** @@ -19,6 +21,13 @@ import { pickDirectory } from '../../utils/pick-directory'; * prompting the user to pick their music folder, registers it through the * library CRUD API, and dismisses itself. AddLibrary emits LibraryAdded * and kicks off the initial scan automatically. + * + * **The dismissal follows the library existing, not the button being + * pressed.** `AddLibrary` emits `LibraryAdded` whoever calls it, so the + * wizard waits on the state it exists to wait for rather than on a step + * in its own flow — a library arriving by any other route (Settings, a + * direct call) leaves a full-screen modal up otherwise, intercepting + * every pointer event. */ @customElement('first-run-wizard') export class FirstRunWizard extends LitElement { @@ -37,9 +46,18 @@ export class FirstRunWizard extends LitElement { /** Error message from a failed pick/save, if any. */ @state() private errorMessage = ''; + /** Unsubscribe from LibraryAdded, while this element is connected. */ + private cancelLibraryAdded?: () => void; + override async connectedCallback(): Promise { super.connectedCallback(); + // Subscribed before the read below, so a library arriving while + // that call is in flight is not answered with a stale empty list. + this.cancelLibraryAdded = EventsOn(Events.LibraryAdded, () => { + this.dismiss(); + }); + try { const existing = await GetAllLibrariesWithTrackCounts(); @@ -54,6 +72,8 @@ export class FirstRunWizard extends LitElement { return; } + if (this.finished) return; + this.active = true; await this.updateComplete; @@ -61,6 +81,13 @@ export class FirstRunWizard extends LitElement { if (this.dialog) this.dialog.open = true; } + override disconnectedCallback(): void { + this.cancelLibraryAdded?.(); + this.cancelLibraryAdded = undefined; + + super.disconnectedCallback(); + } + static override styles = css` wa-dialog { --width: 480px; @@ -239,6 +266,20 @@ export class FirstRunWizard extends LitElement { if (!this.finished) e.preventDefault(); }; + /** + * Close, and stay closed: a library exists, so setup is over. + * + * `finished` is set first, or `preventClose` cancels the hide this + * asks for. + */ + private dismiss(): void { + this.finished = true; + + if (this.dialog) this.dialog.open = false; + + this.active = false; + } + private handleChoose = async (): Promise => { this.errorMessage = ''; @@ -264,11 +305,7 @@ export class FirstRunWizard extends LitElement { try { await AddLibrary(this.selectedDirectory); - this.finished = true; - - if (this.dialog) this.dialog.open = false; - - this.active = false; + this.dismiss(); } catch (err) { this.errorMessage = explainError( err, diff --git a/frontend/test/components/first-run-wizard.test.ts b/frontend/test/components/first-run-wizard.test.ts new file mode 100644 index 0000000..c2d6244 --- /dev/null +++ b/frontend/test/components/first-run-wizard.test.ts @@ -0,0 +1,123 @@ +/** + * #175: the first-run wizard's dismissal follows the library existing, + * not its own button being pressed. + * + * The wizard is a modal that blocks every pointer event, so a library + * arriving by another route — Settings, a direct call — used to leave + * it up over an app that was already set up. `LibraryAdded` is emitted + * by `AddLibrary` whoever calls it, which is what makes one + * subscription the whole fix. + */ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { Events } from '../../src/events'; +import { emit, stub } from '../support/harness'; +import { fixture, shadow, shadowAll } from '../support/render'; +import { wails } from '../support/wails-fake'; + +import '@components/first-run-wizard/first-run-wizard'; + +import type { FirstRunWizard } from '@components/first-run-wizard/first-run-wizard'; + +/** A library row, as `GetAllLibrariesWithTrackCounts` returns one. */ +const aLibrary = { + id: 1, + name: 'Music', + path: '/home/logan/Music', + trackCount: 9, +}; + +/** Mount the wizard on a fresh install: no libraries yet. */ +async function wizardOnAFreshInstall(): Promise { + stub('library.Library.GetAllLibrariesWithTrackCounts', []); + + return fixture('first-run-wizard'); +} + +/** Whether the wizard is rendering its modal at all. */ +function isShowing(el: FirstRunWizard): boolean { + return shadow(el, 'wa-dialog') !== null; +} + +beforeEach(() => { + stub('library.Library.AddLibrary', aLibrary); +}); + +describe('first-run-wizard', () => { + it('shows on a fresh install and stays up until a library exists', async () => { + const el = await wizardOnAFreshInstall(); + + expect(isShowing(el)).toBe(true); + }); + + it('stays hidden when a library is already configured', async () => { + stub('library.Library.GetAllLibrariesWithTrackCounts', [aLibrary]); + + const el = await fixture('first-run-wizard'); + + expect(isShowing(el)).toBe(false); + }); + + it('dismisses when a library appears by another route', async () => { + const el = await wizardOnAFreshInstall(); + + expect(isShowing(el)).toBe(true); + + emit(Events.LibraryAdded, aLibrary); + await el.updateComplete; + + expect(isShowing(el)).toBe(false); + }); + + it('does not raise itself when a library arrives while it is asking', async () => { + // The read is still in flight when the event lands, so its + // answer — an empty list — is stale by the time it returns. + let answer: (libraries: unknown[]) => void = () => {}; + + stub( + 'library.Library.GetAllLibrariesWithTrackCounts', + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + + const el = await fixture('first-run-wizard'); + + emit(Events.LibraryAdded, aLibrary); + answer([]); + + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + expect(isShowing(el)).toBe(false); + }); + + it('still dismisses through its own Get Started button', async () => { + stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true); + stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music'); + + const { resetDirectoryPickerCache } = await import( + '@utils/pick-directory' + ); + + resetDirectoryPickerCache(); + + const el = await wizardOnAFreshInstall(); + const [choose, finish] = shadowAll(el, '.btn'); + + choose?.click(); + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + finish?.click(); + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + expect( + wails.calls.filter((c) => c.path === 'library.Library.AddLibrary'), + ).toHaveLength(1); + expect(isShowing(el)).toBe(false); + }); +});