From c42f1cedda373472f3896f8721d50c8719eb866e Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Thu, 3 Jun 2021 12:47:14 -0300 Subject: [PATCH] refactor: refactor autocomplete tag input in separate components and move shared logic to state --- .../components/AutocompleteTagHint.tsx | 42 +++++ .../components/AutocompleteTagInput.tsx | 154 +++++------------- .../components/AutocompleteTagResult.tsx | 59 +++++++ app/assets/javascripts/components/NoteTag.tsx | 16 +- .../components/NoteTagsContainer.tsx | 6 +- .../ui_models/app_state/active_note_state.ts | 92 +++++++++-- .../javascripts/views/editor/editor-view.pug | 1 - 7 files changed, 224 insertions(+), 146 deletions(-) create mode 100644 app/assets/javascripts/components/AutocompleteTagHint.tsx create mode 100644 app/assets/javascripts/components/AutocompleteTagResult.tsx diff --git a/app/assets/javascripts/components/AutocompleteTagHint.tsx b/app/assets/javascripts/components/AutocompleteTagHint.tsx new file mode 100644 index 000000000..65630b4f4 --- /dev/null +++ b/app/assets/javascripts/components/AutocompleteTagHint.tsx @@ -0,0 +1,42 @@ +import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; +import { Icon } from './Icon'; + +type Props = { + appState: AppState; + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; +}; + +export const AutocompleteTagHint = observer( + ({ appState, closeOnBlur }: Props) => { + const { autocompleteSearchQuery, autocompleteTagResults } = + appState.activeNote; + + const onTagHintClick = async () => { + await appState.activeNote.createAndAddNewTag(); + }; + + return ( + <> + {autocompleteTagResults.length > 0 && ( +
+ )} + + + ); + } +); diff --git a/app/assets/javascripts/components/AutocompleteTagInput.tsx b/app/assets/javascripts/components/AutocompleteTagInput.tsx index 4fbfab6a0..fa8efc6fc 100644 --- a/app/assets/javascripts/components/AutocompleteTagInput.tsx +++ b/app/assets/javascripts/components/AutocompleteTagInput.tsx @@ -1,53 +1,35 @@ -import { WebApplication } from '@/ui_models/application'; -import { SNTag } from '@standardnotes/snjs'; -import { FunctionalComponent } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { Icon } from './Icon'; import { Disclosure, DisclosurePanel } from '@reach/disclosure'; import { useCloseOnBlur } from './utils'; import { AppState } from '@/ui_models/app_state'; +import { AutocompleteTagResult } from './AutocompleteTagResult'; +import { AutocompleteTagHint } from './AutocompleteTagHint'; +import { observer } from 'mobx-react-lite'; type Props = { - application: WebApplication; appState: AppState; }; -export const AutocompleteTagInput: FunctionalComponent = ({ - application, - appState, -}) => { - const { tagElements, tags } = appState.activeNote; +export const AutocompleteTagInput = observer(({ appState }: Props) => { + const { + autocompleteSearchQuery, + autocompleteTagHintVisible, + autocompleteTagResults, + tagElements, + tags, + } = appState.activeNote; - const [searchQuery, setSearchQuery] = useState(''); const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownMaxHeight, setDropdownMaxHeight] = useState('auto'); - const [hintVisible, setHintVisible] = useState(true); - - const getActiveNoteTagResults = (query: string) => { - const { activeNote } = appState.activeNote; - return application.searchTags(query, activeNote); - }; - - const [tagResults, setTagResults] = useState(() => { - return getActiveNoteTagResults(''); - }); const inputRef = useRef(); const dropdownRef = useRef(); - const clearResults = () => { - setSearchQuery(''); - setTagResults(getActiveNoteTagResults('')); - }; - - const [closeOnBlur] = useCloseOnBlur( - dropdownRef, - (visible: boolean) => { - setDropdownVisible(visible); - clearResults(); - } - ); + const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => { + setDropdownVisible(visible); + appState.activeNote.clearAutocompleteSearch(); + }); const showDropdown = () => { const { clientHeight } = document.documentElement; @@ -58,43 +40,29 @@ export const AutocompleteTagInput: FunctionalComponent = ({ const onSearchQueryChange = (event: Event) => { const query = (event.target as HTMLInputElement).value; - setTagResults(getActiveNoteTagResults(query)); - setSearchQuery(query); - }; - - const onTagOptionClick = async (tag: SNTag) => { - await appState.activeNote.addTagToActiveNote(tag); - clearResults(); - }; - - const createAndAddNewTag = async () => { - const newTag = await application.findOrCreateTag(searchQuery); - await appState.activeNote.addTagToActiveNote(newTag); - clearResults(); - }; - - const onTagHintClick = async () => { - await createAndAddNewTag(); + appState.activeNote.setAutocompleteSearchQuery(query); + appState.activeNote.searchActiveNoteAutocompleteTags(); }; const onFormSubmit = async (event: Event) => { event.preventDefault(); - await createAndAddNewTag(); + await appState.activeNote.createAndAddNewTag(); }; useEffect(() => { - setHintVisible( - searchQuery !== '' && !tagResults.some((tag) => tag.title === searchQuery) - ); - }, [tagResults, searchQuery]); + appState.activeNote.searchActiveNoteAutocompleteTags(); + }, [appState.activeNote]); return ( -
0 ? 'mt-2' : ''}`}> + 0 ? 'mt-2' : ''}`} + > = ({ onKeyUp={(event) => { if ( event.key === 'Backspace' && - searchQuery === '' && + autocompleteSearchQuery === '' && tagElements.length > 0 ) { tagElements[tagElements.length - 1]?.focus(); @@ -117,66 +85,24 @@ export const AutocompleteTagInput: FunctionalComponent = ({ style={{ maxHeight: dropdownMaxHeight }} >
- {tagResults.map((tag) => { - return ( - - ); - })} + {autocompleteTagResults.map((tagResult) => ( + + ))}
- {hintVisible && ( - <> - {tagResults.length > 0 && ( -
- )} - - + {autocompleteTagHintVisible && ( + )} )}
); -}; +}); diff --git a/app/assets/javascripts/components/AutocompleteTagResult.tsx b/app/assets/javascripts/components/AutocompleteTagResult.tsx new file mode 100644 index 000000000..f61830f16 --- /dev/null +++ b/app/assets/javascripts/components/AutocompleteTagResult.tsx @@ -0,0 +1,59 @@ +import { AppState } from '@/ui_models/app_state'; +import { SNTag } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { Icon } from './Icon'; + +type Props = { + appState: AppState; + tagResult: SNTag; + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; +}; + +export const AutocompleteTagResult = observer( + ({ appState, tagResult, closeOnBlur }: Props) => { + const { autocompleteSearchQuery } = appState.activeNote; + + const onTagOptionClick = async (tag: SNTag) => { + await appState.activeNote.addTagToActiveNote(tag); + appState.activeNote.clearAutocompleteSearch(); + }; + + return ( + + ); + } +); diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx index 36c9bba8a..a0bf2f73c 100644 --- a/app/assets/javascripts/components/NoteTag.tsx +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -1,18 +1,16 @@ import { Icon } from './Icon'; -import { FunctionalComponent } from 'preact'; import { useRef, useState } from 'preact/hooks'; import { AppState } from '@/ui_models/app_state'; import { SNTag } from '@standardnotes/snjs/dist/@types'; +import { observer } from 'mobx-react-lite'; type Props = { appState: AppState; tag: SNTag; }; -export const NoteTag: FunctionalComponent = ({ appState, tag }) => { - const { - tagsContainerMaxWidth, - } = appState.activeNote; +export const NoteTag = observer(({ appState, tag }: Props) => { + const { tagsContainerMaxWidth } = appState.activeNote; const [showDeleteButton, setShowDeleteButton] = useState(false); const deleteTagRef = useRef(); @@ -41,14 +39,14 @@ export const NoteTag: FunctionalComponent = ({ appState, tag }) => { let nextTagElement; switch (event.key) { - case "Backspace": + case 'Backspace': deleteTag(); break; - case "ArrowLeft": + case 'ArrowLeft': previousTagElement = appState.activeNote.getPreviousTagElement(tag); previousTagElement?.focus(); break; - case "ArrowRight": + case 'ArrowRight': nextTagElement = appState.activeNote.getNextTagElement(tag); nextTagElement?.focus(); break; @@ -92,4 +90,4 @@ export const NoteTag: FunctionalComponent = ({ appState, tag }) => { )} ); -}; +}); diff --git a/app/assets/javascripts/components/NoteTagsContainer.tsx b/app/assets/javascripts/components/NoteTagsContainer.tsx index ff0d2e087..dbf73fceb 100644 --- a/app/assets/javascripts/components/NoteTagsContainer.tsx +++ b/app/assets/javascripts/components/NoteTagsContainer.tsx @@ -2,16 +2,14 @@ import { AppState } from '@/ui_models/app_state'; import { observer } from 'mobx-react-lite'; import { toDirective } from './utils'; import { AutocompleteTagInput } from './AutocompleteTagInput'; -import { WebApplication } from '@/ui_models/application'; import { NoteTag } from './NoteTag'; import { useEffect } from 'preact/hooks'; type Props = { - application: WebApplication; appState: AppState; }; -const NoteTagsContainer = observer(({ application, appState }: Props) => { +const NoteTagsContainer = observer(({ appState }: Props) => { const { tags, tagsContainerMaxWidth, @@ -35,7 +33,7 @@ const NoteTagsContainer = observer(({ application, appState }: Props) => { tag={tag} /> ))} - + ); }); diff --git a/app/assets/javascripts/ui_models/app_state/active_note_state.ts b/app/assets/javascripts/ui_models/app_state/active_note_state.ts index 775120d21..c7a0dc124 100644 --- a/app/assets/javascripts/ui_models/app_state/active_note_state.ts +++ b/app/assets/javascripts/ui_models/app_state/active_note_state.ts @@ -1,17 +1,12 @@ -import { - SNNote, - ContentType, - SNTag, -} from '@standardnotes/snjs'; -import { - action, - makeObservable, - observable, -} from 'mobx'; +import { SNNote, ContentType, SNTag } from '@standardnotes/snjs'; +import { action, computed, makeObservable, observable } from 'mobx'; import { WebApplication } from '../application'; import { AppState } from './app_state'; export class ActiveNoteState { + autocompleteSearchQuery = ''; + autocompleteTagResultElements: (HTMLButtonElement | undefined)[] = []; + autocompleteTagResults: SNTag[] = []; tagElements: (HTMLButtonElement | undefined)[] = []; tags: SNTag[] = []; tagsContainerMaxWidth: number | 'auto' = 0; @@ -22,11 +17,22 @@ export class ActiveNoteState { appEventListeners: (() => void)[] ) { makeObservable(this, { + autocompleteSearchQuery: observable, + autocompleteTagResultElements: observable, + autocompleteTagResults: observable, tagElements: observable, tags: observable, tagsContainerMaxWidth: observable, + autocompleteTagHintVisible: computed, + + clearAutocompleteSearch: action, + setAutocompleteSearchQuery: action, + setAutocompleteTagResultElement: action, + setAutocompleteTagResultElements: action, + setAutocompleteTagResults: action, setTagElement: action, + setTagElements: action, setTags: action, setTagsContainerMaxWidth: action, reloadTags: action, @@ -43,8 +49,41 @@ export class ActiveNoteState { return this.appState.notes.activeEditor?.note; } + get autocompleteTagHintVisible(): boolean { + return ( + this.autocompleteSearchQuery !== '' && + !this.autocompleteTagResults.some( + (tagResult) => tagResult.title === this.autocompleteSearchQuery + ) + ); + } + + setAutocompleteSearchQuery(query: string): void { + this.autocompleteSearchQuery = query; + } + + setAutocompleteTagResultElement( + tagResult: SNTag, + element: HTMLButtonElement + ): void { + const tagIndex = this.getTagIndex(tagResult, this.autocompleteTagResults); + if (tagIndex > -1) { + this.autocompleteTagResultElements.splice(tagIndex, 1, element); + } + } + + setAutocompleteTagResultElements( + elements: (HTMLButtonElement | undefined)[] + ): void { + this.autocompleteTagResultElements = elements; + } + + setAutocompleteTagResults(results: SNTag[]): void { + this.autocompleteTagResults = results; + } + setTagElement(tag: SNTag, element: HTMLButtonElement): void { - const tagIndex = this.getTagIndex(tag); + const tagIndex = this.getTagIndex(tag, this.tags); if (tagIndex > -1) { this.tagElements.splice(tagIndex, 1, element); } @@ -62,19 +101,38 @@ export class ActiveNoteState { this.tagsContainerMaxWidth = width; } - getTagIndex(tag: SNTag): number { - return this.tags.findIndex(t => t.uuid === tag.uuid); + clearAutocompleteSearch(): void { + this.setAutocompleteSearchQuery(''); + this.searchActiveNoteAutocompleteTags(); + } + + async createAndAddNewTag(): Promise { + const newTag = await this.application.findOrCreateTag(this.autocompleteSearchQuery); + await this.addTagToActiveNote(newTag); + this.clearAutocompleteSearch(); + } + + searchActiveNoteAutocompleteTags(): void { + const newResults = this.application.searchTags( + this.autocompleteSearchQuery, + this.activeNote + ); + this.setAutocompleteTagResults(newResults); + } + + getTagIndex(tag: SNTag, tagsArr: SNTag[]): number { + return tagsArr.findIndex((t) => t.uuid === tag.uuid); } getPreviousTagElement(tag: SNTag): HTMLButtonElement | undefined { - const previousTagIndex = this.getTagIndex(tag) - 1; + const previousTagIndex = this.getTagIndex(tag, this.tags) - 1; if (previousTagIndex > -1 && this.tagElements.length > previousTagIndex) { return this.tagElements[previousTagIndex]; } } getNextTagElement(tag: SNTag): HTMLButtonElement | undefined { - const nextTagIndex = this.getTagIndex(tag) + 1; + const nextTagIndex = this.getTagIndex(tag, this.tags) + 1; if (nextTagIndex > -1 && this.tagElements.length > nextTagIndex) { return this.tagElements[nextTagIndex]; } @@ -94,9 +152,7 @@ export class ActiveNoteState { const EDITOR_ELEMENT_ID = 'editor-column'; const editorWidth = document.getElementById(EDITOR_ELEMENT_ID)?.clientWidth; if (editorWidth) { - this.appState.activeNote.setTagsContainerMaxWidth( - editorWidth - ); + this.setTagsContainerMaxWidth(editorWidth); } } diff --git a/app/assets/javascripts/views/editor/editor-view.pug b/app/assets/javascripts/views/editor/editor-view.pug index 9cb2f8e4c..dcaa5b45d 100644 --- a/app/assets/javascripts/views/editor/editor-view.pug +++ b/app/assets/javascripts/views/editor/editor-view.pug @@ -53,7 +53,6 @@ ng-if='self.appState.notes.selectedNotesCount > 0' ) note-tags-container( - application='self.application' app-state='self.appState' ) .sn-component(ng-if='self.note')