diff --git a/src/container.tsx b/src/container.tsx index fd307fa..68e36c7 100644 --- a/src/container.tsx +++ b/src/container.tsx @@ -5,6 +5,7 @@ import Preferences from "./views/preferences"; import Journals from "./views/journals"; import Documents from "./views/documents"; import Editor from "./views/edit"; +import DocumentCreator from "./views/create"; import { SearchProvider } from "./views/documents/SearchProvider"; import { useJournalsLoader, @@ -12,6 +13,7 @@ import { } from "./hooks/useJournalsLoader"; import { Alert } from "evergreen-ui"; import { Routes, Route, Navigate } from "react-router-dom"; +import "react-day-picker/dist/style.css"; export default observer(function Container() { const { journalsStore, loading, loadingErr } = useJournalsLoader(); @@ -42,7 +44,7 @@ export default observer(function Container() { } /> }> } /> - } /> + } /> } /> } /> diff --git a/src/hooks/stores/journals.ts b/src/hooks/stores/journals.ts index 95c47c7..6310acc 100644 --- a/src/hooks/stores/journals.ts +++ b/src/hooks/stores/journals.ts @@ -100,6 +100,27 @@ export class JournalsStore { this.saving = false; }; + + /** + * Determines the default journal to use when creating a new document. + * + * todo(test): When one or multiple journals are selected, returns the first + * todo(test): When no journals are selected, returns the first active journal + * todo(test): When archived journal selected, returns the selected (archived) journal + */ + defaultJournal = (selectedJournals: string[]) => { + const selectedId = this.journals.find((j) => + selectedJournals.includes(j.name), + )?.id; + + if (selectedId) { + return selectedId; + } else { + // todo: defaulting to first journal, but could use logic such as the last selected + // journal, etc, once that is in place + return this.active[0].id; + } + }; } export type IJournalStore = JournalsStore; diff --git a/src/hooks/useJournalsLoader.ts b/src/hooks/useJournalsLoader.ts index 9d0e1d4..5207340 100644 --- a/src/hooks/useJournalsLoader.ts +++ b/src/hooks/useJournalsLoader.ts @@ -3,11 +3,8 @@ import { JournalsStore } from "./stores/journals"; import { JournalResponse } from "../preload/client/journals"; import useClient from "./useClient"; -export const JournalsStoreContext = React.createContext( - // This cast combines with the top-level container ensuring journals are loaded, - // so all downstream components that need journals (most of the app) can rely - // on them being preloaded and avoid the null checks - null as any, +export const JournalsStoreContext = React.createContext( + null, ); /** diff --git a/src/views/create/index.tsx b/src/views/create/index.tsx new file mode 100644 index 0000000..8d294ee --- /dev/null +++ b/src/views/create/index.tsx @@ -0,0 +1,57 @@ +import React, { useContext, useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { EditLoadingComponent } from "../edit/loading"; +import { useIsMounted } from "../../hooks/useIsMounted"; +import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; +import { useNavigate } from "react-router-dom"; +import { SearchStoreContext } from "../documents/SearchStore"; +import useClient from "../../hooks/useClient"; + +// Creates a new document and immediately navigates to it +function useCreateDocument() { + const journalsStore = useContext(JournalsStoreContext)!; + + // NOTE: Could move this hook but, but it assumes searchStore is defined, and its setup + // in the root documents view. So better to keep it here for now. + const searchStore = useContext(SearchStoreContext)!; + const navigate = useNavigate(); + const client = useClient(); + const isMounted = useIsMounted(); + const [error, setError] = useState(null); + + useEffect(() => { + (async function () { + if (!isMounted) return; + + try { + const document = await client.documents.save({ + content: "", + journalId: journalsStore.defaultJournal(searchStore.selectedJournals), + tags: [], // todo: tagsStore.defaultTags; + }); + + if (!isMounted) return; + + // Ensure the document is added to the search, so its available when user hits + // back (even when that doesn't make sense!) + searchStore.updateSearch(document, "create"); + navigate(`/documents/edit/${document.id}`, { replace: true }); + } catch (err) { + console.error("Error creating document", err); + if (!isMounted) return; + setError(err as Error); + } + })(); + }, []); + + return { error }; +} + +// Creates a new document and immediately navigates to it +function DocumentCreator() { + const { error } = useCreateDocument(); + + return ; +} + +export default observer(DocumentCreator); diff --git a/src/views/documents/SearchProvider.tsx b/src/views/documents/SearchProvider.tsx index 25eac3a..9a5cdd6 100644 --- a/src/views/documents/SearchProvider.tsx +++ b/src/views/documents/SearchProvider.tsx @@ -6,7 +6,8 @@ import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; import { SearchStore, SearchStoreContext } from "./SearchStore"; import { LayoutDummy } from "../../layout"; -export function SearchProvider(props: any) { +// Sets up document search and its context +export function SearchProvider() { const jstore = useContext(JournalsStoreContext); const client = useClient(); const [params, setParams] = useSearchParams(); diff --git a/src/views/documents/SearchStore.ts b/src/views/documents/SearchStore.ts index 23c9db6..18a30ca 100644 --- a/src/views/documents/SearchStore.ts +++ b/src/views/documents/SearchStore.ts @@ -1,4 +1,4 @@ -import { createContext } from "react"; +import { createContext, useContext } from "react"; import { IClient } from "../../hooks/useClient"; import { observable, IObservableArray, computed, action } from "mobx"; import { JournalsStore } from "../../hooks/stores/journals"; @@ -12,6 +12,20 @@ export interface SearchItem { journalId: string; } +// Accepts any document satisfying the SearchItem interface, and copies properties +// into an actual SearchItem; i.e. I dont want to stuff an EditableDocument or other smart +// object into search results b/c may get weird. +function toSearchItem(doc: SearchItem): SearchItem | null { + if (!doc.id) return null; + + return { + id: doc.id, + createdAt: doc.createdAt, + title: doc.title, + journalId: doc.journalId, + }; +} + interface SearchQuery { journals: string[]; titles?: string[]; @@ -21,7 +35,12 @@ interface SearchQuery { limit?: number; } -export const SearchStoreContext = createContext(null as any); +export const SearchStoreContext = createContext(null); + +export function useSearchStore() { + const searchStore = useContext(SearchStoreContext); + return searchStore; +} export class SearchStore { @observable docs: SearchItem[] = []; @@ -111,13 +130,47 @@ export class SearchStore { return { journals, tags, titles, texts, before }; }; + /** + * If a document is present in search results, and is edited / deleted / etc, + * navigating back to the search without updating will result in stale results being shown. + * This method updates the search results with the latest document data, IF its present + * in the search results. + */ + updateSearch = ( + document: SearchItem, + operation: "edit" | "create" | "del" = "edit", + ) => { + const idx = this.docs.findIndex((d) => d.id === document.id); + const item = toSearchItem(document); + if (!item) return; // shrug + + if (operation === "edit") { + if (idx === -1) return; + // NOTE: One weird case is, if the journal / tags change and don't match the search, it will + // still be in the results (on back) but wont on refresh. Kind of an edge case that's not + // a huge deal. See also create notes below + this.docs[idx] = item; + } else if (operation === "del") { + if (idx === -1) return; + this.docs.splice(idx, 1); + } else if (operation === "create") { + // NOTE: This assumes (usually correctly) that the created document's journal / tag will match + // the current search results; if not it will not be shown. Maybe its reasonable? More of a weird + // UX based on current design; its weird in others (e.g. Apple notes, when creating with a search filter) + this.search({ resetPagination: true }); + } + }; + /** * Execute a search with the current tokens. * * @param resetPagination - By default execute a fresh search. When paginating, * we don't want to reset the pagination state. */ - search = async (limit = 100, resetPagination = true) => { + search = async (opts: { limit?: number; resetPagination?: boolean } = {}) => { + const limit = opts.limit || 100; + const resetPagination = opts.resetPagination || true; + this.loading = true; this.error = null; @@ -159,7 +212,7 @@ export class SearchStore { if (token) { this.setTokens(this.parser.mergeToken(this.tokens, token as SearchToken)); this.setTokensUrl({ search: this.searchTokens }, { replace: true }); - this.search(100, resetPagination); + this.search({ resetPagination }); } }; @@ -167,7 +220,7 @@ export class SearchStore { removeToken = (token: string, resetPagination = true) => { this.setTokens(this.parser.removeToken(this.tokens.slice(), token)); // slice() from prior implementation this.setTokensUrl({ search: this.searchTokens }, { replace: true }); - this.search(100, resetPagination); + this.search({ resetPagination }); }; /** diff --git a/src/views/documents/Sidebar.tsx b/src/views/documents/Sidebar.tsx index 056514d..7f93f04 100644 --- a/src/views/documents/Sidebar.tsx +++ b/src/views/documents/Sidebar.tsx @@ -19,7 +19,7 @@ import { Icons } from "../../components/icons"; */ export function JournalSelectionSidebar(props: SidebarProps) { const { isShown, setIsShown } = props; - const jstore = useContext(JournalsStoreContext); + const jstore = useContext(JournalsStoreContext)!; const searchStore = props.search; function search(journal: string) { diff --git a/src/views/documents/index.tsx b/src/views/documents/index.tsx index 96c8a8b..9c69bc5 100644 --- a/src/views/documents/index.tsx +++ b/src/views/documents/index.tsx @@ -3,14 +3,14 @@ import { observer } from "mobx-react-lite"; import { Heading, Paragraph, Pane } from "evergreen-ui"; import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; -import { SearchStoreContext, SearchStore } from "./SearchStore"; +import { SearchStore, useSearchStore } from "./SearchStore"; import { DocumentItem } from "./DocumentItem"; import { useNavigate } from "react-router-dom"; import { Layout } from "./Layout"; function DocumentsContainer() { - const journalsStore = useContext(JournalsStoreContext); - const searchStore = useContext(SearchStoreContext); + const journalsStore = useContext(JournalsStoreContext)!; + const searchStore = useSearchStore()!; const navigate = useNavigate(); function edit(docId: string) { diff --git a/src/views/edit/EditableDocument.ts b/src/views/edit/EditableDocument.ts index eb0bbd0..1a557bd 100644 --- a/src/views/edit/EditableDocument.ts +++ b/src/views/edit/EditableDocument.ts @@ -7,14 +7,8 @@ import { Node as SlateNode } from "slate"; import { SlateTransformer } from "./SlateTransformer"; import { debounce } from "lodash"; -interface NewDocument { - journalId: string; - content: string; - title?: string; -} - function isExistingDocument( - doc: NewDocument | GetDocumentResponse, + doc: GetDocumentResponse, ): doc is GetDocumentResponse { return "id" in doc; } @@ -26,9 +20,6 @@ export class EditableDocument { // active model properties: @observable saving: boolean = false; @observable savingError: Error | null = null; - @computed get isNew(): boolean { - return !this.id; - } // todo: Autorun this, or review how mobx-utils/ViewModel works @observable dirty: boolean = false; @@ -55,7 +46,7 @@ export class EditableDocument { // The underlying document properties: @observable title?: string; @observable journalId: string; - @observable id?: string; + @observable id: string; @observable createdAt: string; @observable updatedAt: string; // read-only outside this class @observable tags: string[] = []; @@ -69,26 +60,18 @@ export class EditableDocument { constructor( private client: IClient, - doc: NewDocument | GetDocumentResponse, + doc: GetDocumentResponse, ) { this.title = doc.title; this.journalId = doc.journalId; this.content = doc.content; - - if (isExistingDocument(doc)) { - this.id = doc.id; - this.createdAt = doc.createdAt; - this.updatedAt = doc.updatedAt; - this.tags = doc.tags; - const content = doc.content; - const slateNodes = SlateTransformer.nodify(content); - this.slateContent = slateNodes; - } else { - this.createdAt = new Date().toISOString(); - this.updatedAt = new Date().toISOString(); - this.slateContent = SlateTransformer.createEmptyNodes(); - this.tags = []; - } + this.id = doc.id; + this.createdAt = doc.createdAt; + this.updatedAt = doc.updatedAt; + this.tags = doc.tags; + const content = doc.content; + const slateNodes = SlateTransformer.nodify(content); + this.slateContent = slateNodes; // Auto-save // todo: performance -- investigate putting draft state into storage, @@ -168,14 +151,7 @@ export class EditableDocument { } }, 1000); - @computed get canDelete() { - return !this.isNew && !this.saving; - } - del = async () => { - // redundant id check to satisfy type checker - if (!this.canDelete || !this.id) return; - // overload saving for deleting this.saving = true; await this.client.documents.del(this.id); diff --git a/src/views/edit/index.tsx b/src/views/edit/index.tsx index 489bcc0..02e49b7 100644 --- a/src/views/edit/index.tsx +++ b/src/views/edit/index.tsx @@ -1,43 +1,35 @@ import React, { useContext, useState, useEffect } from "react"; import { observer } from "mobx-react-lite"; import Editor from "./editor"; -import { - Pane, - Button, - Popover, - Menu, - Position, - Tab, - Tablist, - TagInput, -} from "evergreen-ui"; +import { Pane, Button, Popover, Menu, Position, TagInput } from "evergreen-ui"; import { useEditableDocument } from "./useEditableDocument"; import { EditableDocument } from "./EditableDocument"; import { css } from "emotion"; import { JournalResponse } from "../../preload/client/journals"; import { EditLoadingComponent } from "./loading"; import { DayPicker } from "react-day-picker"; -import "react-day-picker/dist/style.css"; import { useIsMounted } from "../../hooks/useIsMounted"; import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; import { useParams, useNavigate } from "react-router-dom"; -import { SearchStoreContext } from "../documents/SearchStore"; +import { useSearchStore } from "../documents/SearchStore"; +import ReadOnlyTextEditor from "./editor/read-only-editor/ReadOnlyTextEditor"; +import { EditorMode } from "./EditorMode"; +import { TagTokenParser } from "../documents/search/parsers/tag"; +import { Icons } from "../../components/icons"; // Loads document, with loading and error placeholders function DocumentLoadingContainer() { - const journalsStore = useContext(JournalsStoreContext); - const searchStore = useContext(SearchStoreContext); + const journalsStore = useContext(JournalsStoreContext)!; const { document: documentId } = useParams(); - const { document, loadingError } = useEditableDocument( - searchStore, - journalsStore, - documentId, - ); + // todo: handle missing or invalid documentId; loadingError may be fine for this, but + // haven't done any UX / design thinking around it. + const { document, loadingError } = useEditableDocument(documentId!); // Filter journals to non-archived ones, but must also add // the current document's journal if its archived const [journals, setJournals] = useState(); + useEffect(() => { if (!document) return; @@ -52,6 +44,7 @@ function DocumentLoadingContainer() { setJournals(journals); }, [document, loadingError]); + // todo: I don't hit this error when going back and forth to a deleted document, why doesn't this happen? if (loadingError) { return ; } @@ -68,11 +61,6 @@ interface DocumentEditProps { journals: JournalResponse[]; } -import ReadOnlyTextEditor from "./editor/read-only-editor/ReadOnlyTextEditor"; -import { EditorMode } from "./EditorMode"; -import { TagTokenParser } from "../documents/search/parsers/tag"; -import { Icons } from "../../components/icons"; - /** * This is the main document editing view, which houses the editor and some controls. */ @@ -80,6 +68,7 @@ const DocumentEditView = observer((props: DocumentEditProps) => { const { document, journals } = props; const isMounted = useIsMounted(); const navigate = useNavigate(); + const searchStore = useSearchStore()!; const [selectedViewMode, setSelectedViewMode] = React.useState( EditorMode.Editor, ); @@ -195,14 +184,17 @@ const DocumentEditView = observer((props: DocumentEditProps) => { "Document is unsaved, exiting will discard document. Stop editing anyways?", ) ) { + // This handles the edit case but hmm... if its new... it should be added to the search... + // but in what order? Well... if we aren't paginated... it should be at the top. + searchStore.updateSearch(document); navigate(-1); } } async function deleteDocument() { - if (!document.canDelete) return; if (confirm("Are you sure?")) { await document.del(); + searchStore.updateSearch(document, "del"); if (isMounted()) navigate(-1); } } @@ -355,12 +347,7 @@ const DocumentEditView = observer((props: DocumentEditProps) => { > {document.saving ? "Saving" : document.dirty ? "Save" : "Saved"} - + diff --git a/src/views/edit/loading.tsx b/src/views/edit/loading.tsx index ae64987..a3dab21 100644 --- a/src/views/edit/loading.tsx +++ b/src/views/edit/loading.tsx @@ -5,7 +5,7 @@ import { css } from "emotion"; import { useNavigate } from "react-router-dom"; export interface LoadingComponentProps { - error?: Error; + error?: Error | null; } export const placeholderDate = new Date().toISOString().slice(0, 10); @@ -38,7 +38,7 @@ export const EditLoadingComponent = observer((props: LoadingComponentProps) => { cursor: pointer; `} > - Unknown + Loading... - selectedJournals.includes(j.name), - )?.id; - - if (selectedId) { - return selectedId; - } else { - // todo: defaulting to first journal, but could use logic such as the last selected - // journal, etc, once that is in place - return jstore.active[0].id; - } -} /** * Load a new or existing document into a view model */ -export function useEditableDocument( - search: SearchStore, - jstore: JournalsStore, - documentId?: string, -) { +export function useEditableDocument(documentId: string) { const [document, setDocument] = React.useState(null); const [loadingError, setLoadingError] = React.useState(null); const client = useClient(); @@ -44,21 +16,21 @@ export function useEditableDocument( async function load() { setLoadingError(null); + if (!documentId) { + // Fail safe; this shuldn't happen. If scenarios come up where it could; maybe toast and navigate + // to documents list instead? + setLoadingError( + new Error( + "Called useEditableDocument without a documentId, unable to load document", + ), + ); + return; + } + try { - // if documentId -> This is an existing document - if (documentId) { - const doc = await client.documents.findById({ id: documentId }); - if (!isEffectMounted) return; - setDocument(new EditableDocument(client, doc)); - } else { - // new documents - setDocument( - new EditableDocument(client, { - content: "", - journalId: defaultJournal(search.selectedJournals, jstore), - }), - ); - } + const doc = await client.documents.findById({ id: documentId }); + if (!isEffectMounted) return; + setDocument(new EditableDocument(client, doc)); } catch (err) { if (!isEffectMounted) return; setLoadingError(err as Error); diff --git a/src/views/journals/index.tsx b/src/views/journals/index.tsx index d26e4a1..03f96e7 100644 --- a/src/views/journals/index.tsx +++ b/src/views/journals/index.tsx @@ -22,7 +22,7 @@ import { RouteProps } from "react-router-dom"; // journal added after successful save // journals load and are displayed function Journals(props: RouteProps) { - const store = useContext(JournalsStoreContext); + const store = useContext(JournalsStoreContext)!; const [name, setName] = useState(""); function save() { @@ -153,7 +153,7 @@ function Journals(props: RouteProps) { * UI and hook for toggling archive state on a journal */ function JournalArchiveButton(props: { journal: JournalResponse }) { - const store = useContext(JournalsStoreContext); + const store = useContext(JournalsStoreContext)!; async function toggleArchive(journal: JournalResponse) { const isArchiving = !!journal.archivedAt;