Skip to content

Commit

Permalink
sync document crud with search
Browse files Browse the repository at this point in the history
Re-work document create, update, and delete operations to be (mostly) synchronized with search, such that navigating back to the documents list after create / edit / delete reflects the changes in search results.

- When creatnig a document, auto-save it and navigate to edit view, adding to current search
- When editing, update the document (title, etc) so its reflected in search
- When deleting, remove the document from the search results

Also re-work document creation to be fully separate from EditableDocument, and remove  hack that hid the fact that (technically) stores could be null when pulled from context; updated all calls to append ! so its documented what is happening; hoepfully this protects against future refactors where consumers are yanked out of the provider that sets up the context.
  • Loading branch information
cloverich committed Jun 30, 2024
1 parent 37c17d4 commit eb41e99
Show file tree
Hide file tree
Showing 13 changed files with 194 additions and 128 deletions.
4 changes: 3 additions & 1 deletion src/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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,
JournalsStoreContext,
} 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();
Expand Down Expand Up @@ -42,7 +44,7 @@ export default observer(function Container() {
<Route path="preferences" element={<Preferences />} />
<Route path="documents" element={<SearchProvider />}>
<Route index element={<Documents />} />
<Route path="edit/new" element={<Editor />} />
<Route path="edit/new" element={<DocumentCreator />} />
<Route path="edit/:document" element={<Editor />} />
</Route>
<Route path="*" element={<Navigate to="documents" replace />} />
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/stores/journals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
7 changes: 2 additions & 5 deletions src/hooks/useJournalsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import { JournalsStore } from "./stores/journals";
import { JournalResponse } from "../preload/client/journals";
import useClient from "./useClient";

export const JournalsStoreContext = React.createContext<JournalsStore>(
// 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<JournalsStore | null>(
null,
);

/**
Expand Down
57 changes: 57 additions & 0 deletions src/views/create/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Error | null>(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 <EditLoadingComponent error={error} />;
}

export default observer(DocumentCreator);
3 changes: 2 additions & 1 deletion src/views/documents/SearchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
63 changes: 58 additions & 5 deletions src/views/documents/SearchStore.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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[];
Expand All @@ -21,7 +35,12 @@ interface SearchQuery {
limit?: number;
}

export const SearchStoreContext = createContext<SearchStore>(null as any);
export const SearchStoreContext = createContext<SearchStore | null>(null);

export function useSearchStore() {
const searchStore = useContext(SearchStoreContext);
return searchStore;
}

export class SearchStore {
@observable docs: SearchItem[] = [];
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -159,15 +212,15 @@ 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 });
}
};

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

/**
Expand Down
2 changes: 1 addition & 1 deletion src/views/documents/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/views/documents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 10 additions & 34 deletions src/views/edit/EditableDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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[] = [];
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit eb41e99

Please sign in to comment.