From 5eba132bdac869e414b4e7f2a3042510970ca9e0 Mon Sep 17 00:00:00 2001 From: Drew Haven Date: Sat, 31 May 2025 16:30:18 -0700 Subject: [PATCH] Switches over to the relationship list --- .../1748733166_updated_relationships.js | 42 +++ src/components/DocumentList.tsx | 5 + src/components/RelationshipList.tsx | 116 ++++++++ .../_authenticated/document.$documentId.tsx | 257 +++++++----------- 4 files changed, 266 insertions(+), 154 deletions(-) create mode 100644 pb_migrations/1748733166_updated_relationships.js create mode 100644 src/components/RelationshipList.tsx diff --git a/pb_migrations/1748733166_updated_relationships.js b/pb_migrations/1748733166_updated_relationships.js new file mode 100644 index 0000000..bb0f989 --- /dev/null +++ b/pb_migrations/1748733166_updated_relationships.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_617371094") + + // update field + collection.fields.addAt(3, new Field({ + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "discoveredIn", + "secrets" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_617371094") + + // update field + collection.fields.addAt(3, new Field({ + "hidden": false, + "id": "select2363381545", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "discoveredIn", + "plannedSecrets" + ] + })) + + return app.save(collection) +}) diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index 036ac6d..2f0c2f0 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -10,6 +10,7 @@ import { Fragment, useState } from "react"; type Props = { title: React.ReactNode; + error?: React.ReactNode; items: T[]; renderRow: (item: T) => React.ReactNode; newItemForm: (onSubmit: () => void) => React.ReactNode; @@ -25,6 +26,7 @@ type Props = { */ export function DocumentList({ title, + error, items, renderRow, newItemForm, @@ -62,6 +64,9 @@ export function DocumentList({ + {error && ( +
{error}
+ )}
    {items.map((item) => (
  • diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx new file mode 100644 index 0000000..c00285d --- /dev/null +++ b/src/components/RelationshipList.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { pb } from "@/lib/pocketbase"; +import type { Document } from "@/lib/types"; +import { DocumentList } from "@/components/DocumentList"; +import { Loader } from "./Loader"; + +interface RelationshipListProps { + root: Document; + relationshipType: string; + renderRow: (item: T) => React.ReactNode; + newItemForm: (onCreate: (doc: T) => Promise) => React.ReactNode; +} + +/** + * RelationshipList manages a list of documents related to a root document via a relationship type. + * It handles fetching, creation, and relationship management, and renders a DocumentList. + */ +export function RelationshipList({ + root, + relationshipType, + renderRow, + newItemForm, +}: RelationshipListProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch related documents on mount or when root/relationshipType changes + useEffect(() => { + let cancelled = false; + async function fetchRelated() { + setLoading(true); + setError(null); + try { + const relationships = await pb + .collection("relationships") + .getList(1, 1, { + filter: `primary = "${root.id}" && type = "${relationshipType}"`, + }); + const secondaryIds = + relationships.items.length > 0 + ? relationships.items[0].secondary + : []; + let docs: T[] = []; + if (Array.isArray(secondaryIds) && secondaryIds.length > 0) { + docs = (await pb.collection("documents").getFullList({ + filter: secondaryIds + .map((id: string) => `id = "${id}"`) + .join(" || "), + })) as T[]; + } + if (!cancelled) setItems(docs); + } catch (e: any) { + if (!cancelled) + setError(e?.message || "Failed to load related documents."); + } finally { + if (!cancelled) setLoading(false); + } + } + fetchRelated(); + return () => { + cancelled = true; + }; + }, [root.id, relationshipType]); + + // Handles creation of a new document and adds it to the relationship + const handleCreate = async (doc: T) => { + setLoading(true); + setError(null); + try { + // Check for existing relationship + const existing = await pb.collection("relationships").getFullList({ + filter: `primary = "${root.id}" && type = "${relationshipType}"`, + }); + if (existing.length > 0) { + console.debug("Adding to existing relationship"); + await pb.collection("relationships").update(existing[0].id, { + "+secondary": doc.id, + }); + } else { + console.debug("Creating new relationship"); + await pb.collection("relationships").create({ + primary: root.id, + secondary: [doc.id], + type: relationshipType, + }); + } + setItems((prev) => [...prev, doc]); + } catch (e: any) { + setError(e?.message || "Failed to add document to relationship."); + } finally { + setLoading(false); + } + }; + + if (loading) { + ; + } + + return ( + + newItemForm(async (doc: T) => { + await handleCreate(doc); + onSubmit(); + }) + } + /> + ); +} diff --git a/src/routes/_authenticated/document.$documentId.tsx b/src/routes/_authenticated/document.$documentId.tsx index ffe3a95..e5c4f0e 100644 --- a/src/routes/_authenticated/document.$documentId.tsx +++ b/src/routes/_authenticated/document.$documentId.tsx @@ -3,125 +3,36 @@ import { pb } from "@/lib/pocketbase"; import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; import { useState } from "react"; import { RelationshipType, type Secret } from "@/lib/types"; -import { DocumentList } from "@/components/DocumentList"; +import { RelationshipList } from "@/components/RelationshipList"; export const Route = createFileRoute("/_authenticated/document/$documentId")({ loader: async ({ params }) => { const doc = await pb.collection("documents").getOne(params.documentId); - // Fetch the unique relationship where this document is the primary and type is "plannedSecrets" - const relationships = await pb.collection("relationships").getList(1, 1, { - filter: `primary = "${params.documentId}" && type = "${RelationshipType.Secrets}"`, - }); - // Get all related secret document IDs from the secondary field - const secretIds = - relationships.items.length > 0 ? relationships.items[0].secondary : []; - // Fetch all related secret documents - let secrets: any[] = []; - if (Array.isArray(secretIds) && secretIds.length > 0) { - secrets = await pb.collection("documents").getFullList({ - filter: secretIds.map((id) => `id = "${id}"`).join(" || "), - }); - } - return { document: doc, secrets }; + return { document: doc }; }, component: RouteComponent, }); function RouteComponent() { - const { document: session, secrets } = Route.useLoaderData(); - const strongStart = session?.data?.session?.strongStart || ""; + const { document: session } = Route.useLoaderData(); + const doc = session as import("@/lib/types").Document; + const strongStart = (doc.data as any)?.session?.strongStart || ""; const [newSecret, setNewSecret] = useState(""); const [adding, setAdding] = useState(false); - const [secretList, setSecretList] = useState(secrets); const [error, setError] = useState(null); async function handleSaveStrongStart(newValue: string) { - await pb.collection("documents").update(session.id, { + await pb.collection("documents").update(doc.id, { data: { - ...session.data, + ...doc.data, session: { - ...session.data.session, + ...(doc.data as any).session, strongStart: newValue, }, }, }); } - async function handleAddSecret() { - if (!newSecret.trim()) return; - setAdding(true); - setError(null); - try { - // 1. Create the secret document - const secretDoc = await pb.collection("documents").create({ - campaign: session.campaign, // assuming campaign is an id or object - data: { - secret: { - text: newSecret, - discoveredOn: null, - }, - }, - }); - // 2. Check for existing relationship - const existing = await pb.collection("relationships").getFullList({ - filter: `primary = "${session.id}" && type = "${RelationshipType.Secrets}"`, - }); - if (existing.length > 0) { - // Update existing relationship to add new secret to secondary array - await pb.collection("relationships").update(existing[0].id, { - "+secondary": secretDoc.id, - }); - } else { - // Create new relationship - await pb.collection("relationships").create({ - primary: session.id, - secondary: [secretDoc.id], - type: RelationshipType.Secrets, - }); - } - setSecretList([...secretList, secretDoc]); - setNewSecret(""); - } catch (e: any) { - setError(e?.message || "Failed to add secret."); - } finally { - setAdding(false); - } - } - - async function handleToggleDiscovered(secret: Secret, checked: boolean) { - // 1. Update the discovered field in the secret document - const updatedSecret: Secret = await pb - .collection("documents") - .update(secret.id, { - data: { - ...secret.data, - secret: { - text: secret.data.secret.text, - discovered: checked, - }, - }, - }); - // 2. Remove any existing discoveredIn relationship - const rels = await pb.collection("relationships").getList(1, 1, { - filter: `primary = "${secret.id}" && type = "discoveredIn"`, - }); - if (rels.items.length > 0) { - await pb.collection("relationships").delete(rels.items[0].id); - } - // 3. If marking as discovered, add a new discoveredIn relationship - if (checked) { - await pb.collection("relationships").create({ - primary: secret.id, - secondary: [session.id], - type: "discoveredIn", - }); - } - // 4. Update local state - setSecretList( - secretList.map((s: any) => (s.id === secret.id ? updatedSecret : s)), - ); - } - return (

    @@ -133,64 +44,102 @@ function RouteComponent() { placeholder="Enter a strong start for this session..." aria-label="Strong Start" /> - {secretList && ( - ( -
    { - e.preventDefault(); - handleAddSecret(); - onSubmit(); - }} - > - setNewSecret(e.target.value)} - disabled={adding} - /> - {error && ( -
    {error}
    - )} - -
    - )} - renderRow={(secret) => ( -
  • - - handleToggleDiscovered(secret, e.target.checked) + ( +
    + { + const checked = e.target.checked; + await pb.collection("documents").update(secret.id, { + data: { + ...secret.data, + secret: { + ...(secret.data as any).secret, + discovered: checked, + }, + }, + }); + // Remove any existing discoveredIn relationship + const rels = await pb + .collection("relationships") + .getList(1, 1, { + filter: `primary = "${secret.id}" && type = "discoveredIn"`, + }); + if (rels.items.length > 0) { + await pb.collection("relationships").delete(rels.items[0].id); } - className="accent-emerald-500 w-5 h-5" - aria-label="Discovered" - /> - - {secret.data?.secret?.text || ( - - (No secret text) - - )} - -
  • - )} - /> - )} + if (checked) { + await pb.collection("relationships").create({ + primary: secret.id, + secondary: [doc.id], + type: "discoveredIn", + }); + } + }} + className="accent-emerald-500 w-5 h-5" + aria-label="Discovered" + /> + + {(secret.data as any)?.secret?.text || ( + (No secret text) + )} + +

    + )} + newItemForm={(onCreate) => ( +
    { + e.preventDefault(); + if (!newSecret.trim()) return; + setAdding(true); + setError(null); + try { + console.debug("Creating new secret"); + // Create the secret document + const secretDoc: Secret = await pb + .collection("documents") + .create({ + campaign: doc.campaign, + data: { + secret: { + text: newSecret, + discovered: false, + }, + }, + }); + setNewSecret(""); + await onCreate(secretDoc); + } catch (e: any) { + setError(e?.message || "Failed to add secret."); + } finally { + setAdding(false); + } + }} + > + setNewSecret(e.target.value)} + disabled={adding} + /> + {error &&
    {error}
    } + +
    + )} + /> ); }