From 2662c78080d60c7718951596ec4167a77276f178 Mon Sep 17 00:00:00 2001 From: mProjectsCode Date: Tue, 25 Oct 2022 16:36:53 +0200 Subject: [PATCH] Add simple search modal, fix preview modal not closing on cancel --- src/main.ts | 43 +++++++- src/modals/MediaDbPreviewModal.ts | 2 +- src/modals/MediaDbSearchModal.ts | 140 +++++++++++++++++++++++++ src/modals/MediaDbSearchResultModal.ts | 1 + src/utils/ModalHelper.ts | 106 +++++++++++++++++++ src/utils/Utils.ts | 10 ++ 6 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 src/modals/MediaDbSearchModal.ts diff --git a/src/main.ts b/src/main.ts index 280d308..2a4bf17 100644 --- a/src/main.ts +++ b/src/main.ts @@ -65,13 +65,18 @@ export default class MediaDbPlugin extends Plugin { // register command to open search modal this.addCommand({ id: 'open-media-db-search-modal', - name: 'Add new Media DB entry', + name: 'Create Media DB entry', + callback: () => this.createEntryWithSearchModal(), + }); + this.addCommand({ + id: 'open-media-db-advanced-search-modal', + name: 'Create Media DB entry (advanced search)', callback: () => this.createEntryWithAdvancedSearchModal(), }); // register command to open id search modal this.addCommand({ id: 'open-media-db-id-search-modal', - name: 'Add new Media DB entry by id', + name: 'Create Media DB entry by id', callback: () => this.createEntryWithIdSearchModal(), }); // register command to update the open note @@ -152,12 +157,40 @@ export default class MediaDbPlugin extends Plugin { } async createEntryWithSearchModal() { + let types: string[] = []; + let apiSearchResults: MediaTypeModel[] = await this.modalHelper.openSearchModal({}, async (searchModalData) => { + types = searchModalData.types; + const apis = this.apiManager.apis.filter(x => x.hasTypeOverlap(searchModalData.types)).map(x => x.apiName); + return await this.apiManager.query(searchModalData.query, apis); + }); + if (!apiSearchResults) { + // TODO: add new notice saying no results found? + return; + } + + // filter the results + apiSearchResults = apiSearchResults.filter(x => types.contains(x.type)); + + let selectResults: MediaTypeModel[]; + let proceed: boolean; + + while (!proceed) { + selectResults = await this.modalHelper.openSelectModal({elements: apiSearchResults}, async (selectModalData) => { + return await this.queryDetails(selectModalData.selected); + }); + if (!selectResults) { + return; + } + + proceed = await this.modalHelper.openPreviewModal({elements: selectResults}, async (previewModalData) => { + return previewModalData.confirmed; + }); + } + + await this.createMediaDbNotes(selectResults); } - /** - * TODO: further refactor: extract it into own method, pass the action (api query) as lambda as well as an options object - */ async createEntryWithAdvancedSearchModal() { let apiSearchResults: MediaTypeModel[] = await this.modalHelper.openAdvancedSearchModal({}, async (advancedSearchModalData) => { return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis); diff --git a/src/modals/MediaDbPreviewModal.ts b/src/modals/MediaDbPreviewModal.ts index ef69a8c..a8fae0b 100644 --- a/src/modals/MediaDbPreviewModal.ts +++ b/src/modals/MediaDbPreviewModal.ts @@ -59,7 +59,7 @@ export class MediaDbPreviewModal extends Modal { const bottomSettingRow = new Setting(contentEl); bottomSettingRow.addButton(btn => { btn.setButtonText('Cancel'); - btn.onClick(() => this.closeCallback()); + btn.onClick(() => this.close()); btn.buttonEl.addClass('media-db-plugin-button'); this.cancelButton = btn; }); diff --git a/src/modals/MediaDbSearchModal.ts b/src/modals/MediaDbSearchModal.ts new file mode 100644 index 0000000..050ae82 --- /dev/null +++ b/src/modals/MediaDbSearchModal.ts @@ -0,0 +1,140 @@ +import {ButtonComponent, Modal, Notice, Setting, TextComponent, ToggleComponent} from 'obsidian'; +import {MediaTypeModel} from '../models/MediaTypeModel'; +import MediaDbPlugin from '../main'; +import { + ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS, + AdvancedSearchModalData, + AdvancedSearchModalOptions, + SEARCH_MODAL_DEFAULT_OPTIONS, + SearchModalData, + SearchModalOptions, +} from '../utils/ModalHelper'; +import {MEDIA_TYPES} from '../utils/MediaTypeManager'; +import {unCamelCase} from '../utils/Utils'; + +export class MediaDbSearchModal extends Modal { + plugin: MediaDbPlugin; + + query: string; + isBusy: boolean; + title: string; + selectedTypes: { name: string, selected: boolean }[]; + + searchBtn: ButtonComponent; + + submitCallback?: (res: SearchModalData) => void; + closeCallback?: (err?: Error) => void; + + + constructor(plugin: MediaDbPlugin, searchModalOptions: SearchModalOptions) { + searchModalOptions = Object.assign({}, SEARCH_MODAL_DEFAULT_OPTIONS, searchModalOptions); + super(plugin.app); + + this.plugin = plugin; + this.selectedTypes = []; + this.title = searchModalOptions.modalTitle; + this.query = searchModalOptions.prefilledSearchString; + + for (const mediaType of MEDIA_TYPES) { + this.selectedTypes.push({name: mediaType, selected: searchModalOptions.preselectedTypes.contains(mediaType)}); + } + } + + setSubmitCallback(submitCallback: (res: SearchModalData) => void): void { + this.submitCallback = submitCallback; + } + + setCloseCallback(closeCallback: (err?: Error) => void): void { + this.closeCallback = closeCallback; + } + + keyPressCallback(event: KeyboardEvent) { + if (event.key === 'Enter') { + this.search(); + } + } + + async search(): Promise { + if (!this.query || this.query.length < 3) { + new Notice('MDB | Query too short'); + return; + } + + const types: string[] = this.selectedTypes.filter(x => x.selected).map(x => x.name); + + if (types.length === 0) { + new Notice('MDB | No Type selected'); + return; + } + + if (!this.isBusy) { + this.isBusy = true; + this.searchBtn.setDisabled(false); + this.searchBtn.setButtonText('Searching...'); + + this.submitCallback({query: this.query, types: types}); + } + } + + onOpen() { + const {contentEl} = this; + + contentEl.createEl('h2', {text: this.title}); + + const placeholder = 'Search by title'; + const searchComponent = new TextComponent(contentEl); + searchComponent.inputEl.style.width = '100%'; + searchComponent.setPlaceholder(placeholder); + searchComponent.setValue(this.query); + searchComponent.onChange(value => (this.query = value)); + searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this)); + + contentEl.appendChild(searchComponent.inputEl); + searchComponent.inputEl.focus(); + + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + contentEl.createEl('h3', {text: 'APIs to search'}); + + for (const mediaType of MEDIA_TYPES) { + const apiToggleListElementWrapper = contentEl.createEl('div', {cls: 'media-db-plugin-list-wrapper'}); + + const apiToggleTextWrapper = apiToggleListElementWrapper.createEl('div', {cls: 'media-db-plugin-list-text-wrapper'}); + apiToggleTextWrapper.createEl('span', {text: unCamelCase(mediaType), cls: 'media-db-plugin-list-text'}); + + const apiToggleComponentWrapper = apiToggleListElementWrapper.createEl('div', {cls: 'media-db-plugin-list-toggle'}); + + const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); + apiToggleComponent.setTooltip(unCamelCase(mediaType)); + apiToggleComponent.setValue(this.selectedTypes.find(x => x.name === mediaType).selected); + apiToggleComponent.onChange((value) => { + this.selectedTypes.find(x => x.name === mediaType).selected = value; + }); + apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl); + } + + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + + new Setting(contentEl) + .addButton(btn => { + btn.setButtonText('Cancel'); + btn.onClick(() => this.close()); + btn.buttonEl.addClass('media-db-plugin-button'); + }) + .addButton(btn => { + btn.setButtonText('Ok'); + btn.setCta(); + btn.onClick(() => { + this.search(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + this.searchBtn = btn; + }); + } + + onClose() { + this.closeCallback(); + const {contentEl} = this; + contentEl.empty(); + } + +} diff --git a/src/modals/MediaDbSearchResultModal.ts b/src/modals/MediaDbSearchResultModal.ts index bce91ee..e747d37 100644 --- a/src/modals/MediaDbSearchResultModal.ts +++ b/src/modals/MediaDbSearchResultModal.ts @@ -62,6 +62,7 @@ export class MediaDbSearchResultModal extends SelectModal { } onClose() { + this.closeCallback(); } } diff --git a/src/utils/ModalHelper.ts b/src/utils/ModalHelper.ts index 8ca3bee..5360fc2 100644 --- a/src/utils/ModalHelper.ts +++ b/src/utils/ModalHelper.ts @@ -6,6 +6,7 @@ import {Notice} from 'obsidian'; import MediaDbPlugin from '../main'; import {MediaDbPreviewModal} from 'src/modals/MediaDbPreviewModal'; import {CreateNoteOptions} from './Utils'; +import {MediaDbSearchModal} from '../modals/MediaDbSearchModal'; export enum ModalResultCode { @@ -15,6 +16,17 @@ export enum ModalResultCode { ERROR, } +/** + * Object containing the data {@link ModalHelper.createSearchModal} returns. + * On {@link ModalResultCode.SUCCESS} this contains {@link SearchModalData}. + * On {@link ModalResultCode.ERROR} this contains a reference to that error. + */ +export interface SearchModalResult { + code: ModalResultCode.SUCCESS | ModalResultCode.CLOSE | ModalResultCode.ERROR, + data?: SearchModalData, + error?: Error, +} + /** * Object containing the data {@link ModalHelper.createAdvancedSearchModal} returns. * On {@link ModalResultCode.SUCCESS} this contains {@link AdvancedSearchModalData}. @@ -59,6 +71,16 @@ export interface PreviewModalResult { error?: Error, } +/** + * The data the search modal returns. + * - query: the query string + * - types: the selected APIs + */ +export interface SearchModalData { + query: string, + types: string[], +} + /** * The data the advanced search modal returns. * - query: the query string @@ -95,6 +117,18 @@ export interface PreviewModalData { confirmed: boolean, } +/** + * Options for the search modal. + * - modalTitle: the title of the modal + * - preselectedTypes: a list of preselected Types + * - prefilledSearchString: prefilled query + */ +export interface SearchModalOptions { + modalTitle?: string, + preselectedTypes?: string[], + prefilledSearchString?: string, +} + /** * Options for the advanced search modal. * - modalTitle: the title of the modal @@ -144,6 +178,12 @@ export interface PreviewModalOptions { createNoteOptions?: CreateNoteOptions, } +export const SEARCH_MODAL_DEFAULT_OPTIONS: SearchModalOptions = { + modalTitle: 'Media DB Search', + preselectedTypes: [], + prefilledSearchString: '', +}; + export const ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS: AdvancedSearchModalOptions = { modalTitle: 'Media DB Advanced Search', preselectedAPIs: [], @@ -180,6 +220,68 @@ export class ModalHelper { this.plugin = plugin; } + /** + * Creates an {@link MediaDbSearchModal}, then sets callbacks and awaits them, + * returning either the user input once submitted or nothing once closed. + * The modal needs ot be manually closed by calling `close()` on the modal reference. + * + * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} + * @returns the user input or nothing and a reference to the modal. + */ + async createSearchModal(searchModalOptions: SearchModalOptions): Promise<{ searchModalResult: SearchModalResult, searchModal: MediaDbSearchModal }> { + const modal = new MediaDbSearchModal(this.plugin, searchModalOptions); + const res: SearchModalResult = await new Promise((resolve, reject) => { + modal.setSubmitCallback(res => resolve({code: ModalResultCode.SUCCESS, data: res})); + modal.setCloseCallback(err => { + if (err) { + resolve({code: ModalResultCode.ERROR, error: err}); + } + resolve({code: ModalResultCode.CLOSE}); + }); + + modal.open(); + }); + return {searchModalResult: res, searchModal: modal}; + } + + /** + * Opens an {@link MediaDbSearchModal} and awaits its result, + * then executes the `submitCallback` returning the callbacks result and closing the modal. + * + * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} + * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed + * @returns the user input or nothing and a reference to the modal. + */ + async openSearchModal(searchModalOptions: SearchModalOptions, submitCallback: (searchModalData: SearchModalData) => Promise): Promise { + const {searchModalResult, searchModal} = await this.createSearchModal(searchModalOptions); + console.debug(`MDB | searchModal closed with code ${searchModalResult.code}`) + + if (searchModalResult.code === ModalResultCode.ERROR) { + // there was an error in the modal itself + console.warn(searchModalResult.error); + new Notice(searchModalResult.error.toString()); + searchModal.close(); + return undefined; + } + + if (searchModalResult.code === ModalResultCode.CLOSE) { + // modal is already being closed + return undefined; + } + + try { + let callbackRes: MediaTypeModel[]; + callbackRes = await submitCallback(searchModalResult.data); + searchModal.close(); + return callbackRes; + } catch (e) { + console.warn(e); + new Notice(e.toString()); + searchModal.close(); + return undefined; + } + } + /** * Creates an {@link MediaDbAdvancedSearchModal}, then sets callbacks and awaits them, * returning either the user input once submitted or nothing once closed. @@ -214,6 +316,7 @@ export class ModalHelper { */ async openAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions, submitCallback: (advancedSearchModalData: AdvancedSearchModalData) => Promise): Promise { const {advancedSearchModalResult, advancedSearchModal} = await this.createAdvancedSearchModal(advancedSearchModalOptions); + console.debug(`MDB | advencedSearchModal closed with code ${advancedSearchModalResult.code}`) if (advancedSearchModalResult.code === ModalResultCode.ERROR) { // there was an error in the modal itself @@ -275,6 +378,7 @@ export class ModalHelper { */ async openIdSearchModal(idSearchModalOptions: IdSearchModalOptions, submitCallback: (idSearchModalData: IdSearchModalData) => Promise): Promise { const {idSearchModalResult, idSearchModal} = await this.createIdSearchModal(idSearchModalOptions); + console.debug(`MDB | idSearchModal closed with code ${idSearchModalResult.code}`) if (idSearchModalResult.code === ModalResultCode.ERROR) { // there was an error in the modal itself @@ -337,6 +441,7 @@ export class ModalHelper { */ async openSelectModal(selectModalOptions: SelectModalOptions, submitCallback: (selectModalData: SelectModalData) => Promise): Promise { const {selectModalResult, selectModal} = await this.createSelectModal(selectModalOptions); + console.debug(`MDB | selectModal closed with code ${selectModalResult.code}`) if (selectModalResult.code === ModalResultCode.ERROR) { // there was an error in the modal itself @@ -388,6 +493,7 @@ export class ModalHelper { async openPreviewModal(previewModalOptions: PreviewModalOptions, submitCallback: (previewModalData: PreviewModalData) => Promise): Promise { const {previewModalResult, previewModal} = await this.createPreviewModal(previewModalOptions); + console.debug(`MDB | previewModal closed with code ${previewModalResult.code}`) if (previewModalResult.code === ModalResultCode.ERROR) { // there was an error in the modal itself diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 12ee1e7..2790c82 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -219,3 +219,13 @@ export function migrateObject(object: T, oldData: any, default object[key] = oldData.hasOwnProperty(key) ? oldData[key] : defaultData[key]; } } + +export function unCamelCase(str: string): string { + return str + // insert a space between lower & upper + .replace(/([a-z])([A-Z])/g, '$1 $2') + // space before last upper in a sequence followed by lower + .replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3') + // uppercase the first character + .replace(/^./, function(str){ return str.toUpperCase(); }); +}