refactor: refactor autocomplete tag input in separate components and move shared logic to state

This commit is contained in:
Antonella Sgarlatta
2021-06-03 12:47:14 -03:00
parent d49d89f0d5
commit c42f1cedda
7 changed files with 224 additions and 146 deletions

View File

@@ -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 && (
<div className="h-1px my-2 bg-border"></div>
)}
<button
type="button"
className="sn-dropdown-item"
onClick={onTagHintClick}
onBlur={closeOnBlur}
>
<span>Create new tag:</span>
<span className="bg-contrast rounded text-xs color-text py-1 pl-1 pr-2 flex items-center ml-2">
<Icon
type="hashtag"
className="sn-icon--small color-neutral mr-1"
/>
{autocompleteSearchQuery}
</span>
</button>
</>
);
}
);

View File

@@ -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<Props> = ({
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<number | 'auto'>('auto');
const [hintVisible, setHintVisible] = useState(true);
const getActiveNoteTagResults = (query: string) => {
const { activeNote } = appState.activeNote;
return application.searchTags(query, activeNote);
};
const [tagResults, setTagResults] = useState<SNTag[]>(() => {
return getActiveNoteTagResults('');
});
const inputRef = useRef<HTMLInputElement>();
const dropdownRef = useRef<HTMLDivElement>();
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<Props> = ({
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 (
<form onSubmit={onFormSubmit} className={`${tags.length > 0 ? 'mt-2' : ''}`}>
<form
onSubmit={onFormSubmit}
className={`${tags.length > 0 ? 'mt-2' : ''}`}
>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
className="w-80 bg-default text-xs color-text no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom"
value={searchQuery}
value={autocompleteSearchQuery}
onChange={onSearchQueryChange}
type="text"
placeholder="Add tag"
@@ -103,7 +71,7 @@ export const AutocompleteTagInput: FunctionalComponent<Props> = ({
onKeyUp={(event) => {
if (
event.key === 'Backspace' &&
searchQuery === '' &&
autocompleteSearchQuery === '' &&
tagElements.length > 0
) {
tagElements[tagElements.length - 1]?.focus();
@@ -117,66 +85,24 @@ export const AutocompleteTagInput: FunctionalComponent<Props> = ({
style={{ maxHeight: dropdownMaxHeight }}
>
<div className="overflow-y-scroll">
{tagResults.map((tag) => {
return (
<button
key={tag.uuid}
type="button"
className="sn-dropdown-item"
onClick={() => onTagOptionClick(tag)}
onBlur={closeOnBlur}
>
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
{searchQuery === '' ? (
tag.title
) : (
tag.title
.split(new RegExp(`(${searchQuery})`, 'gi'))
.map((substring, index) => (
<span
key={index}
className={`${
substring.toLowerCase() ===
searchQuery.toLowerCase()
? 'font-bold whitespace-pre-wrap'
: 'whitespace-pre-wrap '
}`}
>
{substring}
</span>
))
)}
</span>
</button>
);
})}
{autocompleteTagResults.map((tagResult) => (
<AutocompleteTagResult
key={tagResult.uuid}
appState={appState}
tagResult={tagResult}
closeOnBlur={closeOnBlur}
/>
))}
</div>
{hintVisible && (
<>
{tagResults.length > 0 && (
<div className="h-1px my-2 bg-border"></div>
)}
<button
type="button"
className="sn-dropdown-item"
onClick={onTagHintClick}
onBlur={closeOnBlur}
>
<span>Create new tag:</span>
<span className="bg-contrast rounded text-xs color-text py-1 pl-1 pr-2 flex items-center ml-2">
<Icon
type="hashtag"
className="sn-icon--small color-neutral mr-1"
/>
{searchQuery}
</span>
</button>
</>
{autocompleteTagHintVisible && (
<AutocompleteTagHint
appState={appState}
closeOnBlur={closeOnBlur}
/>
)}
</DisclosurePanel>
)}
</Disclosure>
</form>
);
};
});

View File

@@ -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 (
<button
ref={(element) => {
if (element) {
appState.activeNote.setAutocompleteTagResultElement(
tagResult,
element
);
}
}}
type="button"
className="sn-dropdown-item"
onClick={() => onTagOptionClick(tagResult)}
onBlur={closeOnBlur}
>
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
{autocompleteSearchQuery === ''
? tagResult.title
: tagResult.title
.split(new RegExp(`(${autocompleteSearchQuery})`, 'gi'))
.map((substring, index) => (
<span
key={index}
className={`${
substring.toLowerCase() ===
autocompleteSearchQuery.toLowerCase()
? 'font-bold whitespace-pre-wrap'
: 'whitespace-pre-wrap '
}`}
>
{substring}
</span>
))}
</span>
</button>
);
}
);

View File

@@ -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<Props> = ({ 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<HTMLButtonElement>();
@@ -41,14 +39,14 @@ export const NoteTag: FunctionalComponent<Props> = ({ 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<Props> = ({ appState, tag }) => {
)}
</button>
);
};
});

View File

@@ -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}
/>
))}
<AutocompleteTagInput application={application} appState={appState} />
<AutocompleteTagInput appState={appState} />
</div>
);
});

View File

@@ -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<void> {
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);
}
}

View File

@@ -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')