diff --git a/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts b/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts index 04f0598..3bedff7 100644 --- a/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts @@ -38,6 +38,8 @@ export class MediaDbAdvancedSearchModal extends Modal { keyPressCallback(event: KeyboardEvent): void { if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); void this.search(); } } @@ -57,8 +59,9 @@ export class MediaDbAdvancedSearchModal extends Modal { if (!this.isBusy) { this.isBusy = true; - this.searchBtn?.setDisabled(false); + this.searchBtn?.setDisabled(true); this.searchBtn?.setButtonText('Searching...'); + this.searchBtn?.buttonEl.addClass('media-db-plugin-button-loading'); this.submitCallback?.({ query: this.query, apis: apis }); } diff --git a/packages/obsidian/src/modals/MediaDbIdSearchModal.ts b/packages/obsidian/src/modals/MediaDbIdSearchModal.ts index 1f65d30..1427d1e 100644 --- a/packages/obsidian/src/modals/MediaDbIdSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbIdSearchModal.ts @@ -38,6 +38,8 @@ export class MediaDbIdSearchModal extends Modal { keyPressCallback(event: KeyboardEvent): void { if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); void this.search(); } } @@ -55,8 +57,9 @@ export class MediaDbIdSearchModal extends Modal { if (!this.isBusy) { this.isBusy = true; - this.searchBtn?.setDisabled(false); + this.searchBtn?.setDisabled(true); this.searchBtn?.setButtonText('Searching...'); + this.searchBtn?.buttonEl.addClass('media-db-plugin-button-loading'); this.submitCallback?.({ query: this.query, api: this.selectedApi }); } diff --git a/packages/obsidian/src/modals/MediaDbSearchModal.ts b/packages/obsidian/src/modals/MediaDbSearchModal.ts index e62828d..03398cb 100644 --- a/packages/obsidian/src/modals/MediaDbSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbSearchModal.ts @@ -41,6 +41,8 @@ export class MediaDbSearchModal extends Modal { keyPressCallback(event: KeyboardEvent): void { if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); void this.search(); } } @@ -60,8 +62,9 @@ export class MediaDbSearchModal extends Modal { if (!this.isBusy) { this.isBusy = true; - this.searchBtn?.setDisabled(false); + this.searchBtn?.setDisabled(true); this.searchBtn?.setButtonText('Searching...'); + this.searchBtn?.buttonEl.addClass('media-db-plugin-button-loading'); this.submitCallback?.({ query: this.query, types: types }); } diff --git a/packages/obsidian/src/styles.css b/packages/obsidian/src/styles.css index 18a0f55..71687f3 100644 --- a/packages/obsidian/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -65,6 +65,10 @@ small.media-db-plugin-list-text { } .media-db-plugin-search-input { + box-sizing: border-box; + display: block; + max-width: 100%; + min-width: 100%; width: 100%; } @@ -72,6 +76,29 @@ small.media-db-plugin-list-text { /*outline: 1px solid white;*/ } +.media-db-plugin-button-loading { + align-items: center; + display: inline-flex; + gap: var(--size-4-2); + justify-content: center; +} + +.media-db-plugin-button-loading::before { + animation: media-db-plugin-button-loading-spin 0.8s linear infinite; + border: 2px solid currentColor; + border-radius: 50%; + border-top-color: transparent; + content: ''; + height: 14px; + width: 14px; +} + +@keyframes media-db-plugin-button-loading-spin { + to { + transform: rotate(360deg); + } +} + .media-db-plugin-preview { border-radius: var(--modal-radius); border: var(--modal-border-width) solid var(--modal-border-color); diff --git a/packages/obsidian/src/utils/MediaDbEntryHelper.ts b/packages/obsidian/src/utils/MediaDbEntryHelper.ts index 2f558d3..7714161 100644 --- a/packages/obsidian/src/utils/MediaDbEntryHelper.ts +++ b/packages/obsidian/src/utils/MediaDbEntryHelper.ts @@ -8,7 +8,9 @@ import type { SeasonModel } from 'packages/obsidian/src/models/SeasonModel'; import type { MDBError } from 'packages/obsidian/src/utils/MDBError'; import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; import { MediaType } from 'packages/obsidian/src/utils/MediaType'; -import type { SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper'; +import type { ModalLifecycle, ModalSession, SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper'; +import type { Outcome } from 'packages/obsidian/src/utils/result'; +import { OutcomeStatus } from 'packages/obsidian/src/utils/result'; export class MediaDbEntryHelper { readonly plugin: MediaDbPlugin; @@ -21,13 +23,34 @@ export class MediaDbEntryHelper { this.plugin.errorReporter.report(error); } + private getModalData(modalResult: Outcome): T | undefined { + if (modalResult.status === OutcomeStatus.Ok) { + return modalResult.data; + } + + if (modalResult.status === OutcomeStatus.Error) { + this.reportMdbError(modalResult.error); + } + + return undefined; + } + + private async runModalQuery(session: ModalSession, query: () => Promise): Promise { + return this.getModalData(await this.plugin.modalHelper.runModalTask(session, query, { kind: MDBErrorKind.Api, message: 'API query failed' })); + } + async createLinkWithSearchModal(): Promise { - const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({}); + const advancedSearchSession = await this.plugin.modalHelper.createAdvancedSearchModalSession({}); + const advancedSearch = this.getModalData(advancedSearchSession.modalResult); if (!advancedSearch) { return; } - const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis); + const apiSearchResults = await this.runModalQuery(advancedSearchSession, () => this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis)); + if (!apiSearchResults) { + return; + } + if (!apiSearchResults.ok) { this.reportMdbError(apiSearchResults.error); return; @@ -57,14 +80,19 @@ export class MediaDbEntryHelper { } async createEntryWithSearchModal(searchModalOptions?: SearchModalOptions): Promise { - const searchData = await this.plugin.modalHelper.promptSearchModal(searchModalOptions ?? {}); + const searchSession = await this.plugin.modalHelper.createSearchModalSession(searchModalOptions ?? {}); + const searchData = this.getModalData(searchSession.modalResult); if (!searchData) { return; } const types = searchData.types; const apis = this.plugin.apiManager.apis.filter(api => api.hasTypeOverlap(types)).map(api => api.apiName); - const apiSearchResults = await this.plugin.apiManager.query(searchData.query, apis); + const apiSearchResults = await this.runModalQuery(searchSession, () => this.plugin.apiManager.query(searchData.query, apis)); + if (!apiSearchResults) { + return; + } + if (!apiSearchResults.ok) { this.reportMdbError(apiSearchResults.error); return; @@ -114,12 +142,17 @@ export class MediaDbEntryHelper { } async createEntryWithAdvancedSearchModal(): Promise { - const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({}); + const advancedSearchSession = await this.plugin.modalHelper.createAdvancedSearchModalSession({}); + const advancedSearch = this.getModalData(advancedSearchSession.modalResult); if (!advancedSearch) { return; } - const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis); + const apiSearchResults = await this.runModalQuery(advancedSearchSession, () => this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis)); + if (!apiSearchResults) { + return; + } + if (!apiSearchResults.ok) { this.reportMdbError(apiSearchResults.error); return; @@ -153,12 +186,17 @@ export class MediaDbEntryHelper { let proceed = false; while (!proceed) { - const idSearchData = await this.plugin.modalHelper.promptIdSearchModal({}); + const idSearchSession = await this.plugin.modalHelper.createIdSearchModalSession({}); + const idSearchData = this.getModalData(idSearchSession.modalResult); if (!idSearchData) { return; } - const queriedIdResult = await this.plugin.apiManager.queryDetailedInfoById(idSearchData.query, idSearchData.api); + const queriedIdResult = await this.runModalQuery(idSearchSession, () => this.plugin.apiManager.queryDetailedInfoById(idSearchData.query, idSearchData.api)); + if (!queriedIdResult) { + return; + } + if (!queriedIdResult.ok) { this.reportMdbError(queriedIdResult.error); return; diff --git a/packages/obsidian/src/utils/ModalHelper.ts b/packages/obsidian/src/utils/ModalHelper.ts index 36f4b61..b014a8d 100644 --- a/packages/obsidian/src/utils/ModalHelper.ts +++ b/packages/obsidian/src/utils/ModalHelper.ts @@ -105,9 +105,16 @@ export const SELECTMODALOPTIONSDEFAULT: SelectModalOptions = { submitButtonText: 'Ok', }; -interface ModalCoreResult { +export interface ModalLifecycle { + open(): void; + close(): void; +} + +export interface ModalSession { modalResult: Outcome; modal: TModal; + close(): void; + isCancelled(): boolean; } export class ModalHelper { @@ -117,17 +124,39 @@ export class ModalHelper { this.plugin = plugin; } - private async openModalCore( + private async openModalCore( createModal: () => TModal, wireHandlers: (modal: TModal, resolve: (result: Outcome) => void) => void, - ): Promise> { + ): Promise> { const modal = createModal(); + let closeRequested = false; + let cancelledByUser = false; + let resolved = false; const modalResult = await new Promise>(resolve => { - wireHandlers(modal, resolve); + const resolveSession = (result: Outcome): void => { + if (result.status === OutcomeStatus.Cancelled && !closeRequested) { + cancelledByUser = true; + } + + if (!resolved) { + resolved = true; + resolve(result); + } + }; + + wireHandlers(modal, resolveSession); modal.open(); }); - return { modalResult, modal }; + return { + modalResult, + modal, + close: (): void => { + closeRequested = true; + modal.close(); + }, + isCancelled: (): boolean => cancelledByUser, + }; } private async resolveOutcome(outcomePromise: Promise>): Promise { @@ -144,8 +173,45 @@ export class ModalHelper { return undefined; } + async runModalTask( + session: ModalSession, + task: () => Promise, + errorFallback: MDBError = { kind: MDBErrorKind.Modal, message: 'Modal task failed' }, + ): Promise> { + try { + const result = await task(); + + if (session.isCancelled()) { + return cancelled(); + } + + return success(result); + } catch (err) { + if (session.isCancelled()) { + return cancelled(); + } + + return failure(toMdbError(err, errorFallback)); + } finally { + if (!session.isCancelled()) { + session.close(); + } + } + } + async createSearchModalOutcome(searchModalOptions: SearchModalOptions): Promise> { - const { modalResult, modal } = await this.openModalCore( + const session = await this.createSearchModalSession(searchModalOptions); + const { modalResult } = session; + + if (modalResult.status === OutcomeStatus.Ok) { + session.close(); + } + + return modalResult; + } + + async createSearchModalSession(searchModalOptions: SearchModalOptions): Promise> { + return await this.openModalCore( () => new MediaDbSearchModal(this.plugin, searchModalOptions), (modal, resolve) => { modal.setSubmitCb(res => resolve(success(res))); @@ -159,12 +225,6 @@ export class ModalHelper { }); }, ); - - if (modalResult.status === OutcomeStatus.Ok) { - modal.close(); - } - - return modalResult; } async promptSearchModal(searchModalOptions: SearchModalOptions): Promise { @@ -172,7 +232,18 @@ export class ModalHelper { } async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise> { - const { modalResult, modal } = await this.openModalCore( + const session = await this.createAdvancedSearchModalSession(advancedSearchModalOptions); + const { modalResult } = session; + + if (modalResult.status === OutcomeStatus.Ok) { + session.close(); + } + + return modalResult; + } + + async createAdvancedSearchModalSession(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise> { + return await this.openModalCore( () => new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions), (modal, resolve) => { modal.setSubmitCb(res => resolve(success(res))); @@ -186,12 +257,6 @@ export class ModalHelper { }); }, ); - - if (modalResult.status === OutcomeStatus.Ok) { - modal.close(); - } - - return modalResult; } async promptAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise { @@ -199,7 +264,18 @@ export class ModalHelper { } async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise> { - const { modalResult, modal } = await this.openModalCore( + const session = await this.createIdSearchModalSession(idSearchModalOptions); + const { modalResult } = session; + + if (modalResult.status === OutcomeStatus.Ok) { + session.close(); + } + + return modalResult; + } + + async createIdSearchModalSession(idSearchModalOptions: IdSearchModalOptions): Promise> { + return await this.openModalCore( () => new MediaDbIdSearchModal(this.plugin, idSearchModalOptions), (modal, resolve) => { modal.setSubmitCb(res => resolve(success(res))); @@ -213,12 +289,6 @@ export class ModalHelper { }); }, ); - - if (modalResult.status === OutcomeStatus.Ok) { - modal.close(); - } - - return modalResult; } async promptIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise { @@ -226,7 +296,7 @@ export class ModalHelper { } async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise> { - const { modalResult, modal } = await this.openModalCore( + const session = await this.openModalCore( () => new MediaDbSearchResultModal(this.plugin, selectModalOptions), (modal, resolve) => { modal.setSubmitCb(res => resolve(success(res))); @@ -241,9 +311,10 @@ export class ModalHelper { }); }, ); + const { modalResult } = session; if (modalResult.status === OutcomeStatus.Ok || modalResult.status === OutcomeStatus.Skipped) { - modal.close(); + session.close(); } return modalResult; @@ -254,7 +325,7 @@ export class ModalHelper { } async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise> { - const { modalResult, modal } = await this.openModalCore( + const session = await this.openModalCore( () => new MediaDbPreviewModal(this.plugin, previewModalOptions), (modal, resolve) => { modal.setSubmitCb(res => resolve(success(res))); @@ -268,9 +339,10 @@ export class ModalHelper { }); }, ); + const { modalResult } = session; if (modalResult.status === OutcomeStatus.Ok) { - modal.close(); + session.close(); } return modalResult; diff --git a/test/modal-helper.test.ts b/test/modal-helper.test.ts new file mode 100644 index 0000000..02d8f98 --- /dev/null +++ b/test/modal-helper.test.ts @@ -0,0 +1,113 @@ +import { expect, mock, test } from 'bun:test'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import type { ModalLifecycle, ModalSession } from 'packages/obsidian/src/utils/ModalHelper'; +import type { MDBError } from 'packages/obsidian/src/utils/MDBError'; +import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; +import { OutcomeStatus } from 'packages/obsidian/src/utils/result'; + +mock.module('obsidian', () => ({ + AbstractInputSuggest: class {}, + Component: class { + load(): void {} + unload(): void {} + }, + DropdownComponent: class {}, + MarkdownRenderer: { render: async (): Promise => {} }, + MarkdownView: class {}, + Modal: class { + app: unknown; + + constructor(app: unknown) { + this.app = app; + } + + open(): void {} + close(): void {} + }, + Notice: class {}, + normalizePath: (path: string): string => path, + moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }), + parseYaml: (): unknown => ({}), + Plugin: class {}, + PluginSettingTab: class {}, + requestUrl: async (): Promise => ({}), + SecretComponent: class {}, + Setting: class {}, + SettingGroup: class {}, + stringifyYaml: (value: unknown): string => String(value), + TFile: class {}, + TFolder: class {}, + TextComponent: class {}, + ToggleComponent: class {}, +})); + +class FakeModal implements ModalLifecycle { + closeCount = 0; + + open(): void { + // Test modal does not need to render. + } + + close(): void { + this.closeCount += 1; + } +} + +function createModalSession(cancelled: () => boolean): ModalSession { + const modal = new FakeModal(); + + return { + modal, + modalResult: { status: OutcomeStatus.Ok, data: undefined }, + close: (): void => modal.close(), + isCancelled: cancelled, + }; +} + +async function createHelper(): Promise { + const { ModalHelper } = await import('packages/obsidian/src/utils/ModalHelper'); + return new ModalHelper({} as MediaDbPlugin); +} + +test('runModalTask closes the modal after active task success', async () => { + const helper = await createHelper(); + const session = createModalSession(() => false); + + const outcome = await helper.runModalTask(session, async () => 'done'); + + expect(outcome).toEqual({ status: OutcomeStatus.Ok, data: 'done' }); + expect(session.modal.closeCount).toBe(1); +}); + +test('runModalTask ignores late success after cancellation', async () => { + const helper = await createHelper(); + let cancelled = false; + const session = createModalSession(() => cancelled); + + const outcome = await helper.runModalTask(session, async () => { + cancelled = true; + return 'late result'; + }); + + expect(outcome).toEqual({ status: OutcomeStatus.Cancelled }); + expect(session.modal.closeCount).toBe(0); +}); + +test('runModalTask ignores late errors after cancellation', async () => { + const helper = await createHelper(); + let cancelled = false; + const session = createModalSession(() => cancelled); + const fallback: MDBError = { kind: MDBErrorKind.Modal, message: 'Task failed' }; + + const outcome = await helper.runModalTask( + session, + async () => { + cancelled = true; + throw new Error('Late failure'); + }, + fallback, + ); + + expect(outcome).toEqual({ status: OutcomeStatus.Cancelled }); + expect(session.modal.closeCount).toBe(0); +});