diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx index b8cb467..ced612f 100644 --- a/src/components/RelationshipList.tsx +++ b/src/components/RelationshipList.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from "react"; import { Loader } from "./Loader"; import { DocumentRow } from "./documents/DocumentRow"; import { NewRelatedDocumentForm } from "./documents/NewRelatedDocumentForm"; +import { useDocument } from "@/context/document/DocumentContext"; interface RelationshipListProps { root: AnyDocument; @@ -26,64 +27,145 @@ export function RelationshipList({ root, relationshipType, }: RelationshipListProps) { - const [items, setItems] = useState([]); - const [relationshipId, setRelationshipId] = useState(null); + // const [items, setItems] = useState([]); + // const [relationshipId, setRelationshipId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const queryClient = useQueryClient(); + // const queryClient = useQueryClient(); - useEffect(() => { - async function fetchItems() { - const { relationship } = await queryClient.fetchQuery({ - queryKey: ["relationship", relationshipType, root.id], - staleTime: 5 * 60 * 1000, // 5 mintues - queryFn: async () => { - setLoading(true); - const relationship: Relationship = await pb - .collection("relationships") - .getFirstListItem( - `primary = "${root.id}" && type = "${relationshipType}"`, - { - expand: "secondary", - }, - ); - - setLoading(false); - - return { relationship }; - }, - }); - setRelationshipId(relationship.id); - setItems(relationship.expand?.secondary ?? []); - } - - fetchItems(); - }, [root, relationshipType]); + // useEffect(() => { + // async function fetchItems() { + // const { relationship } = await queryClient.fetchQuery({ + // queryKey: ["relationship", relationshipType, root.id], + // staleTime: 5 * 60 * 1000, // 5 mintues + // queryFn: async () => { + // setLoading(true); + // const relationship: Relationship = await pb + // .collection("relationships") + // .getFirstListItem( + // `primary = "${root.id}" && type = "${relationshipType}"`, + // { + // expand: "secondary", + // }, + // ); + // + // setLoading(false); + // + // return { relationship }; + // }, + // }); + // setRelationshipId(relationship.id); + // setItems(relationship.expand?.secondary ?? []); + // } + // + // fetchItems(); + // }, [root, relationshipType]); // Handles creation of a new document and adds it to the relationship + // const handleCreate = async (doc: AnyDocument) => { + // setLoading(true); + // setError(null); + // try { + // // Check for existing relationship + // if (relationshipId) { + // console.debug("Adding to existing relationship", relationshipId); + // await pb.collection("relationships").update(relationshipId, { + // "+secondary": doc.id, + // }); + // } else { + // console.debug("Creating new relationship"); + // const relationship = await pb.collection("relationships").create({ + // primary: root.id, + // secondary: [doc.id], + // type: relationshipType, + // }); + // setRelationshipId(relationship.id); + // } + // queryClient.invalidateQueries({ + // queryKey: ["relationship", relationshipType, root.id], + // }); + // setItems((prev) => [doc, ...prev]); + // } catch (e: any) { + // setError(e?.message || "Failed to add document to relationship."); + // } finally { + // setLoading(false); + // } + // }; + // + // const handleRemove = async (documentId: DocumentId) => { + // setLoading(true); + // setError(null); + // + // try { + // if (relationshipId) { + // console.debug("Removing from existing relationship", relationshipId); + // await pb.collection("relationships").update(relationshipId, { + // "secondary-": documentId, + // }); + // } + // queryClient.invalidateQueries({ + // queryKey: ["relationship", relationshipType, root.id], + // }); + // setItems((prev) => prev.filter((item) => item.id != documentId)); + // } catch (e: any) { + // setError( + // e?.message || `Failed to remove document from ${relationshipType}.`, + // ); + // } finally { + // setLoading(false); + // } + // }; + // + // if (loading) { + // ; + // } + + const { state, dispatch } = useDocument(); + + if (state.status === "loading") { + return ; + } + + 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 handleCreate = async (doc: AnyDocument) => { setLoading(true); setError(null); try { // Check for existing relationship - if (relationshipId) { - console.debug("Adding to existing relationship", relationshipId); - await pb.collection("relationships").update(relationshipId, { - "+secondary": doc.id, + if (relationship) { + console.debug("Adding to existing relationship", relationship.id); + const updatedRelationship: Relationship = await pb + .collection("relationships") + .update(relationship.id, { + "+secondary": doc.id, + }); + dispatch({ + type: "setRelationship", + relationship: updatedRelationship, }); } else { console.debug("Creating new relationship"); - const relationship = await pb.collection("relationships").create({ - primary: root.id, - secondary: [doc.id], - type: relationshipType, + const updatedRelationship: Relationship = await pb + .collection("relationships") + .create({ + primary: root.id, + secondary: [doc.id], + type: relationshipType, + }); + dispatch({ + type: "setRelationship", + relationship: updatedRelationship, }); - setRelationshipId(relationship.id); } - queryClient.invalidateQueries({ - queryKey: ["relationship", relationshipType, root.id], + dispatch({ + type: "setRelatedDocument", + doc, }); - setItems((prev) => [doc, ...prev]); } catch (e: any) { setError(e?.message || "Failed to add document to relationship."); } finally { @@ -96,16 +178,18 @@ export function RelationshipList({ setError(null); try { - if (relationshipId) { - console.debug("Removing from existing relationship", relationshipId); - await pb.collection("relationships").update(relationshipId, { - "secondary-": documentId, + if (relationship) { + console.debug("Removing from existing relationship", relationship.id); + const updatedRelationship: Relationship = await pb + .collection("relationships") + .update(relationship.id, { + "secondary-": documentId, + }); + dispatch({ + type: "setRelationship", + relationship: updatedRelationship, }); } - queryClient.invalidateQueries({ - queryKey: ["relationship", relationshipType, root.id], - }); - setItems((prev) => prev.filter((item) => item.id != documentId)); } catch (e: any) { setError( e?.message || `Failed to remove document from ${relationshipType}.`, @@ -115,10 +199,6 @@ export function RelationshipList({ } }; - if (loading) { - ; - } - return ( ; + } + + const doc = state.doc; + + const relationshipList = relationshipsForDocument(doc); + + return ( +
+ + Print + + + + + {relationshipList.map((relationshipType) => ( + + {displayName(relationshipType)} + + ))} + + + {relationshipList.map((relationshipType) => ( + + + + ))} + + +
+ ); +} diff --git a/src/components/documents/secret/NewSecretForm.tsx b/src/components/documents/secret/NewSecretForm.tsx index 234182d..e56ccdb 100644 --- a/src/components/documents/secret/NewSecretForm.tsx +++ b/src/components/documents/secret/NewSecretForm.tsx @@ -45,7 +45,7 @@ export const NewSecretForm = ({ return ( } /> diff --git a/src/components/documents/session/SessionEditForm.tsx b/src/components/documents/session/SessionEditForm.tsx index 9705db3..ff19809 100644 --- a/src/components/documents/session/SessionEditForm.tsx +++ b/src/components/documents/session/SessionEditForm.tsx @@ -1,10 +1,8 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; -import { useCache } from "@/context/cache/CacheContext"; import { pb } from "@/lib/pocketbase"; import type { Session } from "@/lib/types"; export const SessionEditForm = ({ session }: { session: Session }) => { - const cache = useCache(); async function saveStrongStart(strongStart: string) { const freshRecord: Session = await pb .collection("documents") @@ -14,7 +12,6 @@ export const SessionEditForm = ({ session }: { session: Session }) => { strongStart, }, }); - cache.set(freshRecord); } return ( diff --git a/src/context/cache/CacheContext.tsx b/src/context/cache/CacheContext.tsx deleted file mode 100644 index 27389a8..0000000 --- a/src/context/cache/CacheContext.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createContext, use, useContext, useMemo, useRef } from "react"; -import type { ReactNode } from "react"; -import { RecordCache } from "@/lib/recordCache"; -import { pb } from "@/lib/pocketbase"; -import { type DocumentId, type AnyDocument, CollectionIds } from "@/lib/types"; -import { useQueryClient } from "@tanstack/react-query"; - -/** - * Context value for the record cache singleton. - */ -export type CacheContextValue = RecordCache; - -const CacheContext = createContext(undefined); - -/** - * Provider for the record cache context. Provides a singleton RecordCache instance to children. - */ -export function CacheProvider({ children }: { children: ReactNode }) { - const cacheRef = useRef(undefined); - if (!cacheRef.current) { - cacheRef.current = new RecordCache(); - } - return ( - - {children} - - ); -} - -/** - * Hook to access the record cache context. Throws if used outside of CacheProvider. - */ -export function useCache(): CacheContextValue { - const ctx = useContext(CacheContext); - if (!ctx) throw new Error("useCache must be used within a CacheProvider"); - return ctx; -} - -export function useDocument(documentId: DocumentId): AnyDocument { - const cache = useCache(); - const queryClient = useQueryClient(); - - async function fetchItems() { - const cacheValue = cache.getDocument(documentId); - - if (cacheValue) { - console.info(`Serving ${documentId} from cache.`); - return cacheValue; - } - - const { doc } = await queryClient.fetchQuery({ - queryKey: [CollectionIds.Documents, documentId], - queryFn: async () => { - const doc: AnyDocument = await pb - .collection("documents") - .getOne(documentId, { - expand: - "relationships_via_primary,relationships_via_primary.secondary", - }); - - console.info(`Saving ${documentId} to cache.`); - cache.set(doc); - - return { doc }; - }, - }); - - return doc; - } - - const items = useMemo(fetchItems, [documentId, cache, queryClient]); - - return use(items); -} diff --git a/src/context/document/DocumentContext.tsx b/src/context/document/DocumentContext.tsx new file mode 100644 index 0000000..18f89fd --- /dev/null +++ b/src/context/document/DocumentContext.tsx @@ -0,0 +1,75 @@ +import { pb } from "@/lib/pocketbase"; +import { type AnyDocument, type DocumentId } from "@/lib/types"; +import type { ReactNode } from "react"; +import { createContext, useContext, useEffect, useReducer } from "react"; +import type { DocumentAction } from "./actions"; +import { reducer } from "./reducer"; +import { loading, type DocumentState } from "./state"; +import { useQueryClient } from "@tanstack/react-query"; +import type { RecordModel } from "pocketbase"; + +type DocumentContextValue = { + state: DocumentState; + dispatch: (action: DocumentAction) => void; +}; + +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 queryClient = useQueryClient(); + const [state, dispatch] = useReducer(reducer, loading()); + + useEffect(() => { + async function fetchDocumentAndRelations() { + const doc: AnyDocument = await queryClient.fetchQuery({ + queryKey: ["document", documentId], + staleTime: 5 * 60 * 1000, // 5 mintues + queryFn: () => + 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 ( + + {children} + + ); +} + +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/actions.ts b/src/context/document/actions.ts new file mode 100644 index 0000000..650144a --- /dev/null +++ b/src/context/document/actions.ts @@ -0,0 +1,24 @@ +import type { AnyDocument, Relationship } from "@/lib/types"; + +export type DocumentAction = + | { + type: "loading"; + } + | { + type: "ready"; + doc: D; + relationships: Relationship[]; + relatedDocuments: AnyDocument[]; + } + | { + type: "update"; + data: D["data"]; + } + | { + type: "setRelationship"; + relationship: Relationship; + } + | { + type: "setRelatedDocument"; + doc: AnyDocument; + }; diff --git a/src/context/document/reducer.ts b/src/context/document/reducer.ts new file mode 100644 index 0000000..3e3022b --- /dev/null +++ b/src/context/document/reducer.ts @@ -0,0 +1,75 @@ +import _ from "lodash"; +import type { + AnyDocument, + DocumentId, + Relationship, + RelationshipId, + RelationshipType, +} from "@/lib/types"; +import type { DocumentAction } from "./actions"; +import type { DocumentState } from "./state"; + +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; +} + +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 + >, + }; + + case "update": + if (state.status === "ready") { + return { + ...state, + doc: { + ...state.doc, + data: action.data, + }, + }; + } else { + return state; + } + case "setRelationship": + return ifStatus("ready", state, (state) => ({ + ...state, + relationships: { + ...state.relationships, + [action.relationship.type]: action.relationship, + }, + })); + case "setRelatedDocument": + return ifStatus("ready", state, (state) => ({ + ...state, + relatedDocs: { + ...state.relatedDocs, + [action.doc.id]: action.doc, + }, + })); + } +} diff --git a/src/context/document/state.ts b/src/context/document/state.ts new file mode 100644 index 0000000..24449c7 --- /dev/null +++ b/src/context/document/state.ts @@ -0,0 +1,21 @@ +import type { + AnyDocument, + DocumentId, + Relationship, + RelationshipType, +} from "@/lib/types"; + +export type DocumentState = + | { + status: "loading"; + } + | { + status: "ready"; + doc: D; + relationships: Record; + relatedDocs: Record; + }; + +export const loading = (): DocumentState => ({ + status: "loading", +}); diff --git a/src/lib/recordCache.ts b/src/lib/recordCache.ts index cc7efd6..0673946 100644 --- a/src/lib/recordCache.ts +++ b/src/lib/recordCache.ts @@ -5,42 +5,45 @@ import { type Relationship, type DocumentId, type RelationshipId, + type DbRecord, } from "./types"; -type CacheKey = `${C}:${string}`; +export type CacheKey = `${C}:${string}`; +export type CacheValue = { + record: Promise>; + subscriptions: ((record: DbRecord | null) => void)[]; +}; type DocumentKey = CacheKey; type RelationshipKey = CacheKey; export class RecordCache { - private cache: Record< - `${CollectionId}:${string}`, - { - record: AnyDocument | Relationship; - subscriptions: ((record: AnyDocument | Relationship | null) => void)[]; - } - > = {}; + private cache: Record<`${CollectionId}:${string}`, CacheValue> = {}; - private makeKey( + static makeKey( collectionId: C, id: string, ): CacheKey { return `${collectionId}:${id}`; } - private docKey = (id: DocumentId): DocumentKey => - this.makeKey(CollectionIds.Documents, id); + static docKey = (id: DocumentId): DocumentKey => + RecordCache.makeKey(CollectionIds.Documents, id); - private relationKey = (id: RelationshipId): RelationshipKey => - this.makeKey(CollectionIds.Relationships, id); + static relationKey = (id: RelationshipId): RelationshipKey => + RecordCache.makeKey(CollectionIds.Relationships, id); - get = (key: CacheKey): AnyDocument | Relationship | undefined => - this.cache[key]?.record; + get = ( + key: CacheKey, + ): Promise> | undefined => + this.cache[key]?.record as Promise> | undefined; - set = (record: AnyDocument | Relationship) => { - const key = this.makeKey(record.collectionName, record.id); + pending = ( + key: CacheKey, + record: Promise>, + ) => { if (this.cache[key] === undefined) { this.cache[key] = { - record, + record: record, subscriptions: [], }; } @@ -48,6 +51,34 @@ export class RecordCache { entry.record = record; + record.then((record) => { + for (const subscription of entry.subscriptions) { + subscription(record); + } + for (const doc of record.expand?.secondary ?? []) { + this.set(doc); + } + for (const rel of record.expand?.relationships_via_primary ?? []) { + this.set(rel); + } + }); + }; + + set = (record: DbRecord) => { + const key = RecordCache.makeKey( + record.collectionName as CollectionId, + record.id, + ); + if (this.cache[key] === undefined) { + this.cache[key] = { + record: Promise.resolve(record), + subscriptions: [], + }; + } + const entry = this.cache[key]; + + entry.record = Promise.resolve(record); + for (const subscription of entry.subscriptions) { subscription(record); } @@ -69,13 +100,13 @@ export class RecordCache { }; getDocument = (id: DocumentId): AnyDocument | undefined => - this.get(this.docKey(id)) as AnyDocument | undefined; + this.get(RecordCache.docKey(id)) as AnyDocument | undefined; getRelationship = (id: RelationshipId): Relationship | undefined => - this.get(this.relationKey(id)) as Relationship | undefined; + this.get(RecordCache.relationKey(id)) as Relationship | undefined; - removeDocument = (id: DocumentId) => this.remove(this.docKey(id)); + removeDocument = (id: DocumentId) => this.remove(RecordCache.docKey(id)); removeRelationship = (id: RelationshipId) => - this.remove(this.relationKey(id)); + this.remove(RecordCache.relationKey(id)); } diff --git a/src/lib/types.ts b/src/lib/types.ts index cf866e9..b1c1d84 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,15 +1,7 @@ import type { RecordModel } from "pocketbase"; -export type Id = string & { __type: T }; - -export type UserId = Id<"User">; -export type CampaignId = Id<"Campaign">; -export type DocumentId = Id<"Document">; -export type RelationshipId = Id<"Relationship">; - -export type ISO8601Date = string & { __type: "iso8601date" }; - export const CollectionIds = { + Users: "users", Campaigns: "campaigns", Documents: "documents", Relationships: "relationships", @@ -17,6 +9,26 @@ export const CollectionIds = { export type CollectionId = (typeof CollectionIds)[keyof typeof CollectionIds]; +export type Id = string & { __type: T }; + +export type UserId = Id; +export type CampaignId = Id; +export type DocumentId = Id; +export type RelationshipId = Id; + +export type ISO8601Date = string & { __type: "iso8601date" }; + +export type DbRecord = { + [CollectionIds.Campaigns]: Campaign; + [CollectionIds.Documents]: AnyDocument; + [CollectionIds.Relationships]: Relationship; + [CollectionIds.Users]: RecordModel; +}[C]; + +/****************************************** + * Campaigns + ******************************************/ + export type Campaign = RecordModel & { id: CampaignId; name: string; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a0f02ea..6fd10a0 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,5 +1,4 @@ import { AuthProvider } from "@/context/auth/AuthContext"; -import { CacheProvider } from "@/context/cache/CacheContext"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { Outlet, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; @@ -8,9 +7,7 @@ export const Route = createRootRoute({ component: () => ( <> - - - + diff --git a/src/routes/_app/_authenticated/document.$documentId.tsx b/src/routes/_app/_authenticated/document.$documentId.tsx index 0edfbc0..9849a22 100644 --- a/src/routes/_app/_authenticated/document.$documentId.tsx +++ b/src/routes/_app/_authenticated/document.$documentId.tsx @@ -1,10 +1,7 @@ -import { RelationshipList } from "@/components/RelationshipList"; -import { DocumentEditForm } from "@/components/documents/DocumentEditForm"; -import { displayName, relationshipsForDocument } from "@/lib/relationships"; -import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { useDocument } from "@/context/cache/CacheContext"; +import { DocumentView } from "@/components/documents/DocumentView"; +import { DocumentProvider } from "@/context/document/DocumentContext"; import type { DocumentId } from "@/lib/types"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute( "/_app/_authenticated/document/$documentId", @@ -14,44 +11,11 @@ export const Route = createFileRoute( function RouteComponent() { const { documentId } = Route.useParams(); - - const doc = useDocument(documentId as DocumentId); - - const relationshipList = relationshipsForDocument(doc); + console.info("Rendering document route: ", documentId); return ( -
- - Print - - - - - {relationshipList.map((relationshipType) => ( - - {displayName(relationshipType)} - - ))} - - - {relationshipList.map((relationshipType) => ( - - - - ))} - - -
+ + + ); }