Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sync document crud with search #205

Merged
merged 1 commit into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading