Support adding existing documents to a session
This commit is contained in:
parent
d1432d048f
commit
907df26395
100
package-lock.json
generated
100
package-lock.json
generated
@ -23,7 +23,6 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
@ -71,52 +70,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@astrojs/compiler": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz",
|
||||
"integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@astrojs/ts-plugin": {
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/ts-plugin/-/ts-plugin-1.10.4.tgz",
|
||||
"integrity": "sha512-rapryQINgv5VLZF884R/wmgX3mM9eH1PC/I3kkPV9rP6lEWrRN1YClF3bGcDHFrf8EtTLc0Wqxne1Uetpevozg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.10.3",
|
||||
"@astrojs/yaml2ts": "^0.2.2",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@volar/language-core": "~2.4.7",
|
||||
"@volar/typescript": "~2.4.7",
|
||||
"semver": "^7.3.8",
|
||||
"vscode-languageserver-textdocument": "^1.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/ts-plugin/node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/yaml2ts": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz",
|
||||
"integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yaml": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@atlaskit/pragmatic-drag-and-drop": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz",
|
||||
@ -2325,35 +2278,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.16.tgz",
|
||||
"integrity": "sha512-mcoAFkYVQV4iiLYjTlbolbsm9hhDLtz4D4wTG+rwzSCUbEnxEec+KBlneLMlfdVNjkVEh8lUUSsCGNEQR+hFdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/source-map": "2.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/source-map": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.16.tgz",
|
||||
"integrity": "sha512-4rBiAhOw4MfFTpkvweDnjbDkixpmWNgBws95rpu2oFdMprkTtqFEb8pUOxQ/ruru8/zXSYLwRNXNozznjW9Vtw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@volar/typescript": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.16.tgz",
|
||||
"integrity": "sha512-CrRuG20euPerYc4H0kvDWSSLTBo6qgSI1/0BjXL9ogjm5j6l0gIffvNzEvfmVjr8TAuoMPD0NxuEkteIapfZQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.16",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
@ -3531,13 +3455,6 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@ -4389,20 +4306,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@ -4542,8 +4445,9 @@
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@ -30,7 +30,6 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.10.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/lodash": "^4.17.17",
|
||||
|
||||
@ -84,6 +84,7 @@ export const GenericNewDocumentForm = <T extends DocumentType>({
|
||||
// The type checker seems to lose the types when using Object.entries here.
|
||||
fields.map((field) => (
|
||||
<GenericNewFormField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={field.getter(docData)}
|
||||
isLoading={isLoading}
|
||||
|
||||
@ -5,6 +5,9 @@ import {
|
||||
} from "@/lib/types";
|
||||
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
|
||||
import { docTypeForRelationshipType } from "@/lib/relationships";
|
||||
import { useState } from "react";
|
||||
import { DocumentSearchForm } from "../form/DocumentSearchForm";
|
||||
import { identifierForDocType } from "@/lib/documents";
|
||||
|
||||
/**
|
||||
* Renders a form for any document type depending on the relationship.
|
||||
@ -18,11 +21,42 @@ export const NewRelatedDocumentForm = ({
|
||||
relationshipType: RelationshipType;
|
||||
onCreate: (doc: AnyDocument) => Promise<void>;
|
||||
}) => {
|
||||
const [newOrExisting, setNewOrExisting] = useState<"new" | "existing">("new");
|
||||
|
||||
const docType = docTypeForRelationshipType(relationshipType);
|
||||
|
||||
return (
|
||||
<GenericNewDocumentForm
|
||||
docType={docTypeForRelationshipType(relationshipType)}
|
||||
campaignId={campaignId}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex row gap-4">
|
||||
<button
|
||||
className={`${newOrExisting === "new" ? "font-bold" : "text-gray-400"}`}
|
||||
onClick={() => setNewOrExisting("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
className={`${newOrExisting === "existing" ? "font-bold" : "text-gray-400"}`}
|
||||
onClick={() => setNewOrExisting("existing")}
|
||||
>
|
||||
Existing
|
||||
</button>
|
||||
</div>
|
||||
{newOrExisting === "new" && (
|
||||
<GenericNewDocumentForm
|
||||
docType={docType}
|
||||
campaignId={campaignId}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
)}
|
||||
{newOrExisting === "existing" && (
|
||||
// TODO: Make this into a form with a "Add" button so it's not instant
|
||||
<DocumentSearchForm
|
||||
campaignId={campaignId}
|
||||
onSubmit={onCreate}
|
||||
docType={docType}
|
||||
searchField={identifierForDocType(docType)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
106
src/components/form/DocumentSearchForm.tsx
Normal file
106
src/components/form/DocumentSearchForm.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { DocumentTypeLoader } from "@/context/document/DocumentTypeLoader";
|
||||
import { useDocumentCache } from "@/context/document/hooks";
|
||||
import type { AnyDocument, CampaignId, DocumentType } from "@/lib/types";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BaseForm } from "./BaseForm";
|
||||
import { DocumentTypeLabel } from "@/lib/documents";
|
||||
|
||||
export type Props = {
|
||||
campaignId: CampaignId;
|
||||
docType: DocumentType;
|
||||
searchField: string;
|
||||
onSubmit: (doc: AnyDocument) => void;
|
||||
};
|
||||
|
||||
export const DocumentSearchForm = (props: Props) => (
|
||||
<DocumentTypeLoader
|
||||
documentType={props.docType}
|
||||
campaignId={props.campaignId}
|
||||
>
|
||||
<DocumentSearchInput {...props} />
|
||||
</DocumentTypeLoader>
|
||||
);
|
||||
|
||||
/** Utility to help with typing */
|
||||
function getField(doc: AnyDocument, field: string): string | undefined {
|
||||
return (doc.data as Record<string, string>)[field];
|
||||
}
|
||||
|
||||
export const DocumentSearchInput = ({
|
||||
docType,
|
||||
searchField,
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const { cache } = useDocumentCache();
|
||||
const [allOptions, setAllOptions] = useState<AnyDocument[]>([]);
|
||||
useEffect(() => {
|
||||
setAllOptions(
|
||||
Object.values(cache.documents).flatMap((docResult) => {
|
||||
if (docResult.type !== "ready") {
|
||||
return [];
|
||||
}
|
||||
if (docResult.value.doc.type !== docType) {
|
||||
return [];
|
||||
}
|
||||
return [docResult.value.doc];
|
||||
}),
|
||||
);
|
||||
}, [cache, setAllOptions]);
|
||||
|
||||
const [queryValue, setQueryValue] = useState("");
|
||||
const [selectedDoc, setSelectedDoc] = useState<AnyDocument | null>(null);
|
||||
|
||||
const options = allOptions.filter((doc) =>
|
||||
getField(doc, searchField)
|
||||
?.toLowerCase()
|
||||
?.includes(queryValue.toLowerCase()),
|
||||
);
|
||||
|
||||
// TODO: Better form formatting
|
||||
return (
|
||||
<BaseForm
|
||||
title={`Find ${DocumentTypeLabel[docType]}`}
|
||||
buttonText="Add"
|
||||
error={null}
|
||||
onSubmit={() => selectedDoc && onSubmit(selectedDoc)}
|
||||
content={
|
||||
<Combobox
|
||||
name={searchField}
|
||||
value={selectedDoc}
|
||||
onChange={(doc) => {
|
||||
console.log("Selected", doc);
|
||||
setSelectedDoc(doc);
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
displayValue={(doc: AnyDocument) =>
|
||||
doc && getField(doc, searchField)
|
||||
}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
className={`w-full p-2 rounded border bg-slate-800 text-slate-100 border-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors`}
|
||||
/>
|
||||
<ComboboxOptions
|
||||
anchor="bottom start"
|
||||
className="border empty:invisible z-50 px-4 bg-black"
|
||||
>
|
||||
{options.map((doc) => (
|
||||
<ComboboxOption
|
||||
key={doc.id}
|
||||
value={doc}
|
||||
className="data-selected:font-bold data-focus:font-bold"
|
||||
>
|
||||
{getField(doc, searchField)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
</Combobox>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
41
src/context/document/DocumentTypeLoader.tsx
Normal file
41
src/context/document/DocumentTypeLoader.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import {
|
||||
type AnyDocument,
|
||||
type CampaignId,
|
||||
type DocumentType,
|
||||
} from "@/lib/types";
|
||||
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 DocumentTypeLoader({
|
||||
campaignId,
|
||||
documentType,
|
||||
children,
|
||||
}: {
|
||||
campaignId: CampaignId;
|
||||
documentType: DocumentType;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { dispatch } = useDocumentCache();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDocuments() {
|
||||
const docs: AnyDocument[] = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${campaignId}" && type = "${documentType}"`,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "setDocuments",
|
||||
docs: docs,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDocuments();
|
||||
}, [campaignId, documentType]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@ -9,6 +9,10 @@ export type DocumentAction =
|
||||
type: "setDocument";
|
||||
doc: AnyDocument;
|
||||
}
|
||||
| {
|
||||
type: "setDocuments";
|
||||
docs: AnyDocument[];
|
||||
}
|
||||
| {
|
||||
type: "setRelationship";
|
||||
docId: DocumentId;
|
||||
|
||||
@ -134,21 +134,23 @@ function removeDocument(
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: DocumentState,
|
||||
initialState: DocumentState,
|
||||
action: DocumentAction,
|
||||
): DocumentState {
|
||||
console.debug("Processing action", action);
|
||||
switch (action.type) {
|
||||
case "loadingDocument":
|
||||
return setLoadingDocument(action.docId, state);
|
||||
return setLoadingDocument(action.docId, initialState);
|
||||
case "setDocument":
|
||||
return setDocument(state, action.doc);
|
||||
return setDocument(initialState, action.doc);
|
||||
case "setDocuments":
|
||||
return action.docs.reduce(setDocument, initialState);
|
||||
case "setRelationship":
|
||||
return setRelationship(action.docId, state, action.relationship);
|
||||
return setRelationship(action.docId, initialState, action.relationship);
|
||||
case "setDocumentTree":
|
||||
const updatedDocumentState = setAllRelationshipsEmpty(
|
||||
action.doc.id,
|
||||
setDocument(state, action.doc),
|
||||
setDocument(initialState, action.doc),
|
||||
);
|
||||
|
||||
const updatedRelationshipsState = action.relationships.reduce(
|
||||
@ -166,6 +168,6 @@ export function reducer(
|
||||
emptyRemainingRelationships,
|
||||
);
|
||||
case "removeDocument":
|
||||
return removeDocument(action.docId, state);
|
||||
return removeDocument(action.docId, initialState);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,3 +23,15 @@ export const DocumentTypeLabePlural: Record<DocumentType, string> = {
|
||||
scene: "Scenes",
|
||||
treasure: "Treasures",
|
||||
};
|
||||
|
||||
export function identifierForDocType(docType: DocumentType): string {
|
||||
switch (docType) {
|
||||
case "scene":
|
||||
case "secret":
|
||||
case "thread":
|
||||
case "treasure":
|
||||
return "text";
|
||||
default:
|
||||
return "name";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { type DocumentData, type DocumentType } from "./types";
|
||||
|
||||
export type FieldType = "shortText" | "longText" | "toggle";
|
||||
export type FieldType = "identifier" | "shortText" | "longText" | "toggle";
|
||||
|
||||
export type ValueForFieldType<F extends FieldType> = {
|
||||
identifier: string;
|
||||
shortText: string;
|
||||
longText: string;
|
||||
toggle: boolean;
|
||||
@ -10,6 +11,7 @@ export type ValueForFieldType<F extends FieldType> = {
|
||||
|
||||
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
|
||||
switch (fieldType) {
|
||||
case "identifier":
|
||||
case "shortText":
|
||||
case "longText":
|
||||
return "" as ValueForFieldType<F>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user