From 503c98c895819d2406613214e77b82380f11ee3a Mon Sep 17 00:00:00 2001 From: Drew Haven Date: Thu, 3 Jul 2025 16:24:58 -0700 Subject: [PATCH] I think I have a working document cache solution that's actually pretty good. --- src/components/DocumentList.tsx | 9 +- src/components/RelationshipList.tsx | 28 +++- src/components/documents/DocumentView.tsx | 36 +++-- .../documents/location/LocationEditForm.tsx | 32 ++-- .../documents/location/NewLocationForm.tsx | 6 +- .../documents/monsters/MonsterEditForm.tsx | 18 ++- .../documents/monsters/NewMonsterForm.tsx | 4 +- src/components/documents/npc/NewNpcForm.tsx | 4 +- src/components/documents/npc/NpcEditForm.tsx | 4 +- .../documents/scene/NewSceneForm.tsx | 4 +- .../documents/scene/SceneEditForm.tsx | 6 +- .../documents/secret/NewSecretForm.tsx | 6 +- .../documents/secret/SecretEditForm.tsx | 4 +- .../documents/secret/SecretToggleRow.tsx | 8 +- .../documents/session/SessionEditForm.tsx | 4 +- .../documents/treasure/NewTreasureForm.tsx | 4 +- .../documents/treasure/TreasureEditForm.tsx | 4 +- .../documents/treasure/TreasureToggleRow.tsx | 11 +- src/context/document/DocumentContext.tsx | 56 ++----- src/context/document/DocumentLoader.tsx | 48 ++++++ src/context/document/actions.ts | 20 +-- src/context/document/hooks.ts | 23 +++ src/context/document/reducer.ts | 139 ++++++++++-------- src/context/document/state.ts | 37 +++-- src/routes/__root.tsx | 5 +- .../_authenticated/document.$documentId.tsx | 9 +- 26 files changed, 317 insertions(+), 212 deletions(-) create mode 100644 src/context/document/DocumentLoader.tsx create mode 100644 src/context/document/hooks.ts diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index 18335be..caba9b9 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -1,13 +1,12 @@ +import * as Icons from "@/components/Icons.tsx"; import type { AnyDocument, DocumentId } from "@/lib/types"; import { Dialog, DialogPanel, - DialogTitle, Transition, TransitionChild, } from "@headlessui/react"; import { Fragment, useCallback, useState } from "react"; -import * as Icons from "@/components/Icons.tsx"; type Props = { title: React.ReactNode; @@ -75,13 +74,13 @@ export function DocumentList({ {error && (
{error}
)} -
    +
      {items.map((item) => (
    • -
      {renderRow(item)}
      + {renderRow(item)} {isEditing && (
      diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx index 637ad82..9da2a83 100644 --- a/src/components/RelationshipList.tsx +++ b/src/components/RelationshipList.tsx @@ -1,5 +1,5 @@ import { DocumentList } from "@/components/DocumentList"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache, useDocument } from "@/context/document/hooks"; import { pb } from "@/lib/pocketbase"; import { displayName } from "@/lib/relationships"; import type { @@ -28,17 +28,30 @@ export function RelationshipList({ }: RelationshipListProps) { const [_loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { state, dispatch } = useDocument(); + const { docResult, dispatch } = useDocument(root.id); + const { cache } = useDocumentCache(); - if (state.status === "loading") { + if (docResult.type !== "ready") { return ; } + const doc = docResult.value.doc; + console.info("Rendering relationship list: ", relationshipType); - const relationship = state.relationships[relationshipType]; - const itemIds = relationship?.secondary ?? []; - const items = itemIds.map((id) => state.relatedDocs[id]).filter((d) => !!d); + const relationshipResult = docResult.value.relationships[relationshipType]; + + if (relationshipResult?.type !== "ready") { + return ; + } + + const relationship = relationshipResult.value; + + const itemIds = relationship.secondary ?? []; + const items = itemIds + .map((id) => cache.documents[id]) + .filter((d) => d && d.type === "ready") + .map((d) => d.value.doc); const handleCreate = async (doc: AnyDocument) => { setLoading(true); @@ -54,6 +67,7 @@ export function RelationshipList({ }); dispatch({ type: "setRelationship", + docId: doc.id, relationship: updatedRelationship, }); } else { @@ -67,6 +81,7 @@ export function RelationshipList({ }); dispatch({ type: "setRelationship", + docId: doc.id, relationship: updatedRelationship, }); } @@ -91,6 +106,7 @@ export function RelationshipList({ }); dispatch({ type: "setRelationship", + docId: doc.id, relationship: updatedRelationship, }); } diff --git a/src/components/documents/DocumentView.tsx b/src/components/documents/DocumentView.tsx index ad08550..c13d812 100644 --- a/src/components/documents/DocumentView.tsx +++ b/src/components/documents/DocumentView.tsx @@ -1,31 +1,43 @@ import { RelationshipList } from "@/components/RelationshipList"; import { DocumentEditForm } from "@/components/documents/DocumentEditForm"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocument } from "@/context/document/hooks"; import { displayName, relationshipsForDocument } from "@/lib/relationships"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; import { Link } from "@tanstack/react-router"; import { Loader } from "../Loader"; +import type { DocumentId } from "@/lib/types"; -export function DocumentView() { - const { state } = useDocument(); +export function DocumentView({ documentId }: { documentId: DocumentId }) { + const { docResult } = useDocument(documentId); - if (state.status === "loading") { + console.info(`Rendering document: `, docResult); + + if (docResult?.type !== "ready") { return ; } - const doc = state.doc; + const doc = docResult.value.doc; const relationshipList = relationshipsForDocument(doc); return (
      - - Print - +
      + + Back to campaign + + + Print + +
      diff --git a/src/components/documents/location/LocationEditForm.tsx b/src/components/documents/location/LocationEditForm.tsx index 242bbb3..bc13c82 100644 --- a/src/components/documents/location/LocationEditForm.tsx +++ b/src/components/documents/location/LocationEditForm.tsx @@ -1,30 +1,34 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { pb } from "@/lib/pocketbase"; import type { Location } from "@/lib/types"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders an editable location form */ export const LocationEditForm = ({ location }: { location: Location }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); async function saveLocationName(name: string) { - const updated: Location = await pb.collection("documents").update(location.id, { - data: { - ...location.data, - name, - }, - }); + const updated: Location = await pb + .collection("documents") + .update(location.id, { + data: { + ...location.data, + name, + }, + }); dispatch({ type: "setDocument", doc: updated }); } async function saveLocationDescription(description: string) { - const updated: Location = await pb.collection("documents").update(location.id, { - data: { - ...location.data, - description, - }, - }); + const updated: Location = await pb + .collection("documents") + .update(location.id, { + data: { + ...location.data, + description, + }, + }); dispatch({ type: "setDocument", doc: updated }); } diff --git a/src/components/documents/location/NewLocationForm.tsx b/src/components/documents/location/NewLocationForm.tsx index 72873c3..0860ac9 100644 --- a/src/components/documents/location/NewLocationForm.tsx +++ b/src/components/documents/location/NewLocationForm.tsx @@ -4,7 +4,7 @@ import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { MultiLineInput } from "@/components/form/MultiLineInput"; import { SingleLineInput } from "@/components/form/SingleLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new location. Calls onCreate with the new location document. @@ -16,7 +16,7 @@ export const NewLocationForm = ({ campaign: CampaignId; onCreate: (location: Location) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [adding, setAdding] = useState(false); @@ -38,7 +38,7 @@ export const NewLocationForm = ({ }); setName(""); setDescription(""); - dispatch({ type: "setDocument", doc: locationDoc}); + dispatch({ type: "setDocument", doc: locationDoc }); await onCreate(locationDoc); } catch (e: any) { setError(e?.message || "Failed to add location."); diff --git a/src/components/documents/monsters/MonsterEditForm.tsx b/src/components/documents/monsters/MonsterEditForm.tsx index ed17efa..fccc8b4 100644 --- a/src/components/documents/monsters/MonsterEditForm.tsx +++ b/src/components/documents/monsters/MonsterEditForm.tsx @@ -1,20 +1,22 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { pb } from "@/lib/pocketbase"; import type { Monster } from "@/lib/types"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders an editable monster row */ export const MonsterEditForm = ({ monster }: { monster: Monster }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); async function saveMonsterName(name: string) { - const updated = await pb.collection("documents").update(monster.id, { - data: { - ...monster.data, - name, - }, - }); + const updated: Monster = await pb + .collection("documents") + .update(monster.id, { + data: { + ...monster.data, + name, + }, + }); dispatch({ type: "setDocument", doc: updated }); } diff --git a/src/components/documents/monsters/NewMonsterForm.tsx b/src/components/documents/monsters/NewMonsterForm.tsx index 4c1947e..6f54209 100644 --- a/src/components/documents/monsters/NewMonsterForm.tsx +++ b/src/components/documents/monsters/NewMonsterForm.tsx @@ -3,7 +3,7 @@ import type { CampaignId, Monster } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { SingleLineInput } from "@/components/form/SingleLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new monster. Calls onCreate with the new monster document. @@ -15,7 +15,7 @@ export const NewMonsterForm = ({ campaign: CampaignId; onCreate: (monster: Monster) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [name, setName] = useState(""); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); diff --git a/src/components/documents/npc/NewNpcForm.tsx b/src/components/documents/npc/NewNpcForm.tsx index 5ffc3ae..a6846b9 100644 --- a/src/components/documents/npc/NewNpcForm.tsx +++ b/src/components/documents/npc/NewNpcForm.tsx @@ -4,7 +4,7 @@ import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { SingleLineInput } from "@/components/form/SingleLineInput"; import { MultiLineInput } from "@/components/form/MultiLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new npc. Calls onCreate with the new npc document. @@ -16,7 +16,7 @@ export const NewNpcForm = ({ campaign: CampaignId; onCreate: (npc: Npc) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [adding, setAdding] = useState(false); diff --git a/src/components/documents/npc/NpcEditForm.tsx b/src/components/documents/npc/NpcEditForm.tsx index e49221b..b035514 100644 --- a/src/components/documents/npc/NpcEditForm.tsx +++ b/src/components/documents/npc/NpcEditForm.tsx @@ -1,5 +1,5 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; import { pb } from "@/lib/pocketbase"; import type { Npc } from "@/lib/types"; @@ -7,7 +7,7 @@ import type { Npc } from "@/lib/types"; * Renders an editable npc form */ export const NpcEditForm = ({ npc }: { npc: Npc }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); async function saveNpcName(name: string) { const updated: Npc = await pb.collection("documents").update(npc.id, { data: { diff --git a/src/components/documents/scene/NewSceneForm.tsx b/src/components/documents/scene/NewSceneForm.tsx index b983480..2698bb2 100644 --- a/src/components/documents/scene/NewSceneForm.tsx +++ b/src/components/documents/scene/NewSceneForm.tsx @@ -5,7 +5,7 @@ import type { CampaignId, Scene } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { MultiLineInput } from "@/components/form/MultiLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new scene. Calls onCreate with the new scene document. @@ -17,7 +17,7 @@ export const NewSceneForm = ({ campaign: CampaignId; onCreate: (scene: Scene) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [text, setText] = useState(""); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); diff --git a/src/components/documents/scene/SceneEditForm.tsx b/src/components/documents/scene/SceneEditForm.tsx index 0f03cf2..6a92124 100644 --- a/src/components/documents/scene/SceneEditForm.tsx +++ b/src/components/documents/scene/SceneEditForm.tsx @@ -1,15 +1,13 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { pb } from "@/lib/pocketbase"; import type { Scene } from "@/lib/types"; -import { useDocument } from "@/context/document/DocumentContext"; -import { useQueryClient } from "@tanstack/react-query"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders an editable scene form */ export const SceneEditForm = ({ scene }: { scene: Scene }) => { - const { dispatch } = useDocument(); - const queryClient = useQueryClient(); + const { dispatch } = useDocumentCache(); async function saveScene(text: string) { const updated: Scene = await pb.collection("documents").update(scene.id, { diff --git a/src/components/documents/secret/NewSecretForm.tsx b/src/components/documents/secret/NewSecretForm.tsx index ccba454..d08e389 100644 --- a/src/components/documents/secret/NewSecretForm.tsx +++ b/src/components/documents/secret/NewSecretForm.tsx @@ -5,7 +5,7 @@ import type { CampaignId, Secret } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { SingleLineInput } from "@/components/form/SingleLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new secret. Calls onCreate with the new secret document. @@ -17,7 +17,7 @@ export const NewSecretForm = ({ campaign: CampaignId; onCreate: (secret: Secret) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [newSecret, setNewSecret] = useState(""); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); @@ -37,7 +37,7 @@ export const NewSecretForm = ({ }, }); setNewSecret(""); - dispatch({ type: "setDocument", doc: secretDoc as Secret}); + dispatch({ type: "setDocument", doc: secretDoc as Secret }); await onCreate(secretDoc); } catch (e: any) { setError(e?.message || "Failed to add secret."); diff --git a/src/components/documents/secret/SecretEditForm.tsx b/src/components/documents/secret/SecretEditForm.tsx index 8d9adee..5539a1e 100644 --- a/src/components/documents/secret/SecretEditForm.tsx +++ b/src/components/documents/secret/SecretEditForm.tsx @@ -1,6 +1,6 @@ // Displays a single secret with discovered checkbox and text. import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; import { pb } from "@/lib/pocketbase"; import type { Secret } from "@/lib/types"; import { useState } from "react"; @@ -10,7 +10,7 @@ import { useState } from "react"; * Handles updating the discovered state and discoveredIn relationship. */ export const SecretEditForm = ({ secret }: { secret: Secret }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [checked, setChecked] = useState( !!(secret.data as any)?.secret?.discovered, ); diff --git a/src/components/documents/secret/SecretToggleRow.tsx b/src/components/documents/secret/SecretToggleRow.tsx index ca23fd4..5fa275b 100644 --- a/src/components/documents/secret/SecretToggleRow.tsx +++ b/src/components/documents/secret/SecretToggleRow.tsx @@ -1,7 +1,7 @@ // SecretRow.tsx // Displays a single secret with discovered checkbox and text. -import type { Secret, Session } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; +import type { Secret, Session } from "@/lib/types"; import { useState } from "react"; /** @@ -21,6 +21,8 @@ export const SecretToggleRow = ({ const [loading, setLoading] = useState(false); async function handleChange(e: React.ChangeEvent) { + e.stopPropagation(); + e.preventDefault(); const newChecked = e.target.checked; setLoading(true); setChecked(newChecked); @@ -59,7 +61,7 @@ export const SecretToggleRow = ({ } return ( -
      +
      - {secret.data.text} + {secret.data.text}
      ); }; diff --git a/src/components/documents/session/SessionEditForm.tsx b/src/components/documents/session/SessionEditForm.tsx index 8dd44d7..7334a0a 100644 --- a/src/components/documents/session/SessionEditForm.tsx +++ b/src/components/documents/session/SessionEditForm.tsx @@ -1,10 +1,10 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; import { pb } from "@/lib/pocketbase"; import type { Session } from "@/lib/types"; export const SessionEditForm = ({ session }: { session: Session }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); async function saveStrongStart(strongStart: string) { const doc: Session = await pb.collection("documents").update(session.id, { diff --git a/src/components/documents/treasure/NewTreasureForm.tsx b/src/components/documents/treasure/NewTreasureForm.tsx index 19fd650..c37a6f5 100644 --- a/src/components/documents/treasure/NewTreasureForm.tsx +++ b/src/components/documents/treasure/NewTreasureForm.tsx @@ -5,7 +5,7 @@ import type { CampaignId, Treasure } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; import { BaseForm } from "@/components/form/BaseForm"; import { SingleLineInput } from "@/components/form/SingleLineInput"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; /** * Renders a form to add a new treasure. Calls onCreate with the new treasure document. @@ -17,7 +17,7 @@ export const NewTreasureForm = ({ campaign: CampaignId; onCreate: (treasure: Treasure) => Promise; }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [newTreasure, setNewTreasure] = useState(""); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); diff --git a/src/components/documents/treasure/TreasureEditForm.tsx b/src/components/documents/treasure/TreasureEditForm.tsx index 340afb5..913369b 100644 --- a/src/components/documents/treasure/TreasureEditForm.tsx +++ b/src/components/documents/treasure/TreasureEditForm.tsx @@ -1,6 +1,6 @@ // Displays a single treasure with discovered checkbox and text. import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; -import { useDocument } from "@/context/document/DocumentContext"; +import { useDocumentCache } from "@/context/document/hooks"; import { pb } from "@/lib/pocketbase"; import type { Treasure } from "@/lib/types"; import { useState } from "react"; @@ -10,7 +10,7 @@ import { useState } from "react"; * Handles updating the discovered state and discoveredIn relationship. */ export const TreasureEditForm = ({ treasure }: { treasure: Treasure }) => { - const { dispatch } = useDocument(); + const { dispatch } = useDocumentCache(); const [checked, setChecked] = useState( !!(treasure.data as any)?.treasure?.discovered, ); diff --git a/src/components/documents/treasure/TreasureToggleRow.tsx b/src/components/documents/treasure/TreasureToggleRow.tsx index dea5cd5..3c61967 100644 --- a/src/components/documents/treasure/TreasureToggleRow.tsx +++ b/src/components/documents/treasure/TreasureToggleRow.tsx @@ -1,7 +1,8 @@ // TreasureRow.tsx // Displays a single treasure with discovered checkbox and text. -import type { Treasure, Session } from "@/lib/types"; import { pb } from "@/lib/pocketbase"; +import type { Session, Treasure } from "@/lib/types"; +import { Link } from "@tanstack/react-router"; import { useState } from "react"; /** @@ -68,7 +69,13 @@ export const TreasureToggleRow = ({ aria-label="Discovered" disabled={loading} /> - {treasure.data.text} + + {treasure.data.text} +
      ); }; diff --git a/src/context/document/DocumentContext.tsx b/src/context/document/DocumentContext.tsx index 087f353..5cc94bc 100644 --- a/src/context/document/DocumentContext.tsx +++ b/src/context/document/DocumentContext.tsx @@ -1,59 +1,28 @@ -import { pb } from "@/lib/pocketbase"; -import { type AnyDocument, type DocumentId } from "@/lib/types"; -import type { RecordModel } from "pocketbase"; import type { ReactNode } from "react"; -import { createContext, useContext, useEffect, useReducer } from "react"; +import { createContext, useReducer } from "react"; import type { DocumentAction } from "./actions"; import { reducer } from "./reducer"; -import { loading, type DocumentState } from "./state"; +import { initialState, type DocumentState } from "./state"; -type DocumentContextValue = { - state: DocumentState; - dispatch: (action: DocumentAction) => void; +export type DocumentContextValue = { + cache: DocumentState; + dispatch: (action: DocumentAction) => void; }; -const DocumentContext = createContext( +export const DocumentContext = createContext( undefined, ); /** * Provider for the record cache context. Provides a singleton RecordCache instance to children. */ -export function DocumentProvider({ - documentId, - children, -}: { - documentId: DocumentId; - children: ReactNode; -}) { - const [state, dispatch] = useReducer(reducer, loading()); +export function DocumentProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState()); - useEffect(() => { - async function fetchDocumentAndRelations() { - const doc: AnyDocument = await pb - .collection("documents") - .getOne(documentId, { - expand: - "relationships_via_primary,relationships_via_primary.secondary", - }); - - dispatch({ - type: "ready", - doc, - relationships: doc.expand?.relationships_via_primary || [], - relatedDocuments: - doc.expand?.relationships_via_primary?.flatMap( - (r: RecordModel) => r.expand?.secondary, - ) || [], - }); - } - - fetchDocumentAndRelations(); - }, [documentId]); return ( @@ -61,10 +30,3 @@ export function DocumentProvider({ ); } - -export function useDocument(): DocumentContextValue { - const ctx = useContext(DocumentContext); - if (!ctx) - throw new Error("useDocument must be used within a DocumentProvider"); - return ctx; -} diff --git a/src/context/document/DocumentLoader.tsx b/src/context/document/DocumentLoader.tsx new file mode 100644 index 0000000..046f817 --- /dev/null +++ b/src/context/document/DocumentLoader.tsx @@ -0,0 +1,48 @@ +import { pb } from "@/lib/pocketbase"; +import { type AnyDocument, type DocumentId } from "@/lib/types"; +import type { RecordModel } from "pocketbase"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { useDocumentCache } from "./hooks"; + +/** + * Provider for the record cache context. Provides a singleton RecordCache instance to children. + */ +export function DocumentLoader({ + documentId, + children, +}: { + documentId: DocumentId; + children: ReactNode; +}) { + const { dispatch } = useDocumentCache(); + + useEffect(() => { + async function fetchDocumentAndRelations() { + dispatch({ + type: "loadingDocument", + docId: documentId, + }); + const doc: AnyDocument = await pb + .collection("documents") + .getOne(documentId, { + expand: + "relationships_via_primary,relationships_via_primary.secondary", + }); + + dispatch({ + type: "setDocumentTree", + doc, + relationships: doc.expand?.relationships_via_primary || [], + relatedDocuments: + doc.expand?.relationships_via_primary?.flatMap( + (r: RecordModel) => r.expand?.secondary, + ) || [], + }); + } + + fetchDocumentAndRelations(); + }, [documentId]); + + return children; +} diff --git a/src/context/document/actions.ts b/src/context/document/actions.ts index fe5ebbf..f527c5a 100644 --- a/src/context/document/actions.ts +++ b/src/context/document/actions.ts @@ -1,14 +1,9 @@ -import type { AnyDocument, Relationship } from "@/lib/types"; +import type { AnyDocument, DocumentId, Relationship } from "@/lib/types"; -export type DocumentAction = +export type DocumentAction = | { - type: "loading"; - } - | { - type: "ready"; - doc: D; - relationships: Relationship[]; - relatedDocuments: AnyDocument[]; + type: "loadingDocument"; + docId: DocumentId; } | { type: "setDocument"; @@ -16,5 +11,12 @@ export type DocumentAction = } | { type: "setRelationship"; + docId: DocumentId; relationship: Relationship; + } + | { + type: "setDocumentTree"; + doc: AnyDocument; + relationships: Relationship[]; + relatedDocuments: AnyDocument[]; }; diff --git a/src/context/document/hooks.ts b/src/context/document/hooks.ts new file mode 100644 index 0000000..ce887ce --- /dev/null +++ b/src/context/document/hooks.ts @@ -0,0 +1,23 @@ +import type { DocumentId } from "@/lib/types"; +import { useContext } from "react"; +import { DocumentContext } from "./DocumentContext"; + +export function useDocument(id: DocumentId) { + const ctx = useContext(DocumentContext); + if (!ctx) + throw new Error("useDocument must be used within a DocumentProvider"); + return { + docResult: ctx.cache.documents[id], + dispatch: ctx.dispatch, + }; +} + +export function useDocumentCache() { + const ctx = useContext(DocumentContext); + if (!ctx) + throw new Error("useDocument must be used within a DocumentProvider"); + return { + cache: ctx.cache, + dispatch: ctx.dispatch, + }; +} diff --git a/src/context/document/reducer.ts b/src/context/document/reducer.ts index d705aca..c7a184c 100644 --- a/src/context/document/reducer.ts +++ b/src/context/document/reducer.ts @@ -1,71 +1,88 @@ -import _ from "lodash"; -import type { - AnyDocument, - DocumentId, - Relationship, - RelationshipType, -} from "@/lib/types"; +import type { AnyDocument, DocumentId, Relationship } from "@/lib/types"; import type { DocumentAction } from "./actions"; -import type { DocumentState } from "./state"; +import { ready, loading, unloaded, type DocumentState } from "./state"; +import { relationshipsForDocument } from "@/lib/relationships"; -function ifStatus["status"]>( - status: S, - state: DocumentState, - newState: (state: DocumentState & { status: S }) => DocumentState, -) { - // TODO: Is there a better way to express the type of type narrowing? - return state.status === status - ? newState(state as DocumentState & { status: S }) - : state; +function setLoadingDocument( + docId: DocumentId, + state: DocumentState, +): DocumentState { + return { + ...state, + documents: { + ...state.documents, + [docId]: loading(), + }, + }; } -export function reducer( - state: DocumentState, - action: DocumentAction, -): DocumentState { - switch (action.type) { - case "loading": - return { - status: "loading", - }; - case "ready": - return { - status: "ready", - doc: action.doc, - relationships: _.keyBy(action.relationships, (r) => r.type) as Record< - RelationshipType, - Relationship - >, - relatedDocs: _.keyBy(action.relatedDocuments, (r) => r.id) as Record< - DocumentId, - AnyDocument - >, - }; +function setDocument(state: DocumentState, doc: AnyDocument): DocumentState { + const previous = state.documents[doc.id]; + const relationships = + previous?.type === "ready" + ? previous.value.relationships + : Object.fromEntries( + relationshipsForDocument(doc).map((relationshipType) => [ + relationshipType, + unloaded(), + ]), + ); - case "setDocument": - return ifStatus("ready", state, (state) => { - if (state.doc.id === action.doc.id) { - return { - ...state, - doc: action.doc as D, - }; - } + return { + ...state, + documents: { + ...state.documents, + [doc.id]: ready({ + doc: doc, + relationships, + }), + }, + }; +} - return { - ...state, - relatedDocs: { - ...state.relatedDocs, - [action.doc.id]: action.doc, - }, - }; - }); - case "setRelationship": - return ifStatus("ready", state, (state) => ({ - ...state, +function setRelationship( + docId: DocumentId, + state: DocumentState, + relationship: Relationship, +): DocumentState { + const previousResult = state.documents[docId]; + if (previousResult?.type !== "ready") { + return state; + } + const previousEntry = previousResult.value; + return { + ...state, + documents: { + ...state.documents, + [docId]: ready({ + ...previousEntry, relationships: { - ...state.relationships, - [action.relationship.type]: action.relationship, + ...previousEntry.relationships, + [relationship.type]: ready(relationship), }, - })); + }), + }, + }; +} + +export function reducer( + state: DocumentState, + action: DocumentAction, +): DocumentState { + switch (action.type) { + case "loadingDocument": + return setLoadingDocument(action.docId, state); + case "setDocument": + return setDocument(state, action.doc); + case "setRelationship": + return setRelationship(action.docId, state, action.relationship); + case "setDocumentTree": + return action.relatedDocuments.reduce( + setDocument, + action.relationships.reduce( + setRelationship.bind(null, action.doc.id), + setDocument(state, action.doc), + ), + ); } } diff --git a/src/context/document/state.ts b/src/context/document/state.ts index 24449c7..91c3695 100644 --- a/src/context/document/state.ts +++ b/src/context/document/state.ts @@ -5,17 +5,28 @@ import type { RelationshipType, } from "@/lib/types"; -export type DocumentState = - | { - status: "loading"; - } - | { - status: "ready"; - doc: D; - relationships: Record; - relatedDocs: Record; - }; +export type Result = + | { type: "unloaded" } + | { type: "error"; err: unknown } + | { type: "loading" } + | { type: "ready"; value: V }; -export const loading = (): DocumentState => ({ - status: "loading", -}); +export const unloaded = (): Result => ({ type: "unloaded" }); +export const error = (err: unknown): Result => ({ type: "error", err }); +export const loading = (): Result => ({ type: "loading" }); +export const ready = (value: V): Result => ({ type: "ready", value }); + +export type DocumentState = { + documents: Record< + DocumentId, + Result<{ + doc: AnyDocument; + relationships: Record>; + }> + >; +}; + +export const initialState = (): DocumentState => + ({ + documents: {}, + }) as DocumentState; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 6fd10a0..5815606 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { AuthProvider } from "@/context/auth/AuthContext"; +import { DocumentProvider } from "@/context/document/DocumentContext"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { Outlet, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; @@ -7,7 +8,9 @@ export const Route = createRootRoute({ component: () => ( <> - + + + diff --git a/src/routes/_app/_authenticated/document.$documentId.tsx b/src/routes/_app/_authenticated/document.$documentId.tsx index 9849a22..a7c54f3 100644 --- a/src/routes/_app/_authenticated/document.$documentId.tsx +++ b/src/routes/_app/_authenticated/document.$documentId.tsx @@ -1,5 +1,5 @@ import { DocumentView } from "@/components/documents/DocumentView"; -import { DocumentProvider } from "@/context/document/DocumentContext"; +import { DocumentLoader } from "@/context/document/DocumentLoader"; import type { DocumentId } from "@/lib/types"; import { createFileRoute } from "@tanstack/react-router"; @@ -11,11 +11,10 @@ export const Route = createFileRoute( function RouteComponent() { const { documentId } = Route.useParams(); - console.info("Rendering document route: ", documentId); return ( - - - + + + ); }