Creates the generic new-document form

This commit is contained in:
2025-09-24 15:24:07 -07:00
parent 43afdc8684
commit c9d27bce75
6 changed files with 216 additions and 45 deletions

View File

@@ -28,14 +28,6 @@ export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
case "treasure":
return <TreasureEditForm treasure={document} />;
case "thread":
return (
<GenericEditForm
doc={document}
fields={{
text: "multiline",
resolved: "checkbox",
}}
/>
);
return <GenericEditForm doc={document} />;
}
};

View File

@@ -8,37 +8,45 @@ export type Props = React.PropsWithChildren<{
}>;
export function DocumentLink({ childDocId, className, children }: Props) {
const docPath = useDocumentPath();
// const docPath = useDocumentPath();
//
// const params = useParams({
// strict: false,
// });
//
// const campaignSearch = useSearch({
// from: "/_app/_authenticated/campaigns/$campaignId",
// shouldThrow: false,
// });
//
// const to = params.campaignId
// ? `/campaigns/${params.campaignId}`
// : docPath
// ? makeDocumentPath(
// docPath.documentId,
// docPath?.relationshipType,
// childDocId,
// )
// : undefined;
//
// const search = campaignSearch
// ? { tab: campaignSearch.tab, docId: childDocId }
// : undefined;
//
// if (to === undefined) {
// throw new Error("Not in a document or campaign context");
// }
//
// return (
// <Link to={to} search={search} className={className}>
// {children}
// </Link>
// );
const params = useParams({
strict: false,
});
const campaignSearch = useSearch({
from: "/_app/_authenticated/campaigns/$campaignId",
shouldThrow: false,
});
const to = params.campaignId
? `/campaigns/${params.campaignId}`
: docPath
? makeDocumentPath(
docPath.documentId,
docPath?.relationshipType,
childDocId,
)
: undefined;
const search = campaignSearch
? { tab: campaignSearch.tab, docId: childDocId }
: undefined;
if (to === undefined) {
throw new Error("Not in a document or campaign context");
}
const to = makeDocumentPath(childDocId);
return (
<Link to={to} search={search} className={className}>
<Link to={to} className={className}>
{children}
</Link>
);

View File

@@ -56,6 +56,7 @@ const GenericEditFormField = <T extends AnyDocument>({
multiline={true}
value={field.getter(data) as string}
onSave={saveField}
id={field.name}
/>
);
case "shortText":
@@ -64,6 +65,7 @@ const GenericEditFormField = <T extends AnyDocument>({
multiline={false}
value={field.getter(data) as string}
onSave={saveField}
id={field.name}
/>
);
case "toggle":
@@ -73,6 +75,7 @@ const GenericEditFormField = <T extends AnyDocument>({
checked={!!field.getter(data)}
onChange={(e) => saveField(!!e.target.value)}
className="accent-emerald-500 w-5 h-5"
id={field.name}
/>
);
}

View File

@@ -0,0 +1,152 @@
import { AutoSaveTextarea } from "@/components/AutoSaveTextarea";
import { useDocumentCache } from "@/context/document/hooks";
import {
getFieldsForType,
type DocumentField,
type FieldType,
type ValueForFieldType,
} from "@/lib/fields";
import { pb } from "@/lib/pocketbase";
import {
type CampaignId,
type DocumentData,
type DocumentsByType,
type DocumentType,
} from "@/lib/types";
import { useCallback, useState } from "react";
export type GenericFieldType = "multiline" | "singleline" | "checkbox";
export type Props<T extends DocumentType> = {
docType: T;
campaignId: CampaignId;
onCreate: (doc: DocumentsByType[T]) => Promise<void>;
};
export const GenericNewDocumentForm = <T extends DocumentType>({
docType,
campaignId,
onCreate,
}: Props<T>) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { dispatch } = useDocumentCache();
const fields = getFieldsForType(docType);
const [docData, setDocData] = useState<DocumentData<T>>(
fields.reduce((d, f) => f.setDefault(d), {} as DocumentData<T>),
);
const updateData =
<F extends FieldType>(field: DocumentField<T, F>) =>
(value: ValueForFieldType<F>) =>
setDocData(field.setter(value, docData));
const saveData = useCallback(async () => {
setIsLoading(true);
console.log(`Creating ${docType}: `, docData);
try {
const newDocument: DocumentsByType[T] = await pb
.collection("documents")
.create({
campaign: campaignId,
type: docType,
data: docData,
});
await onCreate(newDocument);
dispatch({
type: "setDocument",
doc: newDocument,
});
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unknown error occurred while creating the session.");
}
}
setIsLoading(false);
}, [campaignId, setIsLoading, setError, docData]);
return (
<div className="">
{error && (
// TODO: class and style for errors
<div className="error">{error}</div>
)}
{
// The type checker seems to lose the types when using Object.entries here.
fields.map((field) => (
<GenericNewFormField
field={field}
value={field.getter(docData)}
onUpdate={updateData(field)}
/>
))
}
<button disabled={isLoading} onClick={saveData}>
Save
</button>
</div>
);
};
const GenericNewFormField = <T extends DocumentType, F extends FieldType>({
field,
value,
onUpdate,
}: {
field: DocumentField<T, F>;
value: ValueForFieldType<F>;
onUpdate: (value: ValueForFieldType<F>) => void;
}) => {
return (
<div>
<label htmlFor={field.name}>{field.name}</label>
<GenericNewFormInput field={field} value={value} onUpdate={onUpdate} />
</div>
);
};
const GenericNewFormInput = <T extends DocumentType, F extends FieldType>({
field,
value,
onUpdate,
}: {
field: DocumentField<T, F>;
value: ValueForFieldType<F>;
onUpdate: (value: ValueForFieldType<F>) => void;
}) => {
switch (field.fieldType) {
case "longText":
return (
<textarea
value={value as string}
onChange={(e) =>
onUpdate((e.target.value || "") as ValueForFieldType<F>)
}
/>
);
case "shortText":
return (
<input
type="text"
value={value as string}
onChange={(e) =>
onUpdate((e.target.value || "") as ValueForFieldType<F>)
}
/>
);
case "toggle":
return (
<input
type="checkbox"
checked={value as boolean}
onChange={(e) =>
(onUpdate as (value: boolean) => void)(!!e.target.value)
}
/>
);
}
};

View File

@@ -4,6 +4,7 @@ import {
type DocumentType,
} from "@/lib/types";
import { NewSessionForm } from "./session/NewSessionForm";
import { GenericNewDocumentForm } from "./GenericNewDocumentForm";
/**
* Renders a form for any document type depending on the relationship.
@@ -21,7 +22,14 @@ export const NewCampaignDocumentForm = ({
case "session":
return <NewSessionForm campaignId={campaignId} onCreate={onCreate} />;
case "thread":
return <NewThreadForm campaignId={campaignId} onCreate={onCreate} />;
case "location":
return (
<GenericNewDocumentForm
docType={docType}
campaignId={campaignId}
onCreate={onCreate}
/>
);
default:
throw new Error(
`Rendered NewCampaignDocumentForm with unsupported docType: ${docType}`,

View File

@@ -1,16 +1,22 @@
import {
type DocumentData,
type DocumentsByType,
type DocumentType,
} from "./types";
import { type DocumentData, type DocumentType } from "./types";
export type FieldType = "shortText" | "longText" | "toggle";
export type ValueForFieldType<T extends FieldType> = {
export type ValueForFieldType<F extends FieldType> = {
shortText: string;
longText: string;
toggle: boolean;
}[T];
}[F];
function defaultValue<F extends FieldType>(fieldType: F): ValueForFieldType<F> {
switch (fieldType) {
case "shortText":
case "longText":
return "" as ValueForFieldType<F>;
case "toggle":
return false as ValueForFieldType<F>;
}
}
export type DocumentField<D extends DocumentType, F extends FieldType> = {
name: string;
@@ -20,6 +26,7 @@ export type DocumentField<D extends DocumentType, F extends FieldType> = {
value: ValueForFieldType<F>,
doc: DocumentData<D>,
) => DocumentData<D>;
setDefault: (doc: DocumentData<D>) => DocumentData<D>;
};
const simpleField = <D extends DocumentType, F extends FieldType>(
@@ -31,6 +38,7 @@ const simpleField = <D extends DocumentType, F extends FieldType>(
fieldType,
getter: (doc) => doc[key] as unknown as ValueForFieldType<F>,
setter: (value, doc) => ({ ...doc, [key]: value }),
setDefault: (doc) => ({ ...doc, [key]: defaultValue(fieldType) }),
});
const simpleFields = <D extends DocumentType>(