diff --git a/.changeset/olive-lemons-turn.md b/.changeset/olive-lemons-turn.md new file mode 100644 index 0000000..d500ad1 --- /dev/null +++ b/.changeset/olive-lemons-turn.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Link uploaded forms to quests diff --git a/convex/forms.ts b/convex/forms.ts index c0e12cd..e172cb1 100644 --- a/convex/forms.ts +++ b/convex/forms.ts @@ -60,17 +60,39 @@ export const uploadPDF = userMutation({ }, }); +export const getFormsForQuest = query({ + args: { questId: v.id("quests") }, + handler: async (ctx, args) => { + const forms = await ctx.db + .query("forms") + .withIndex("quest", (q) => q.eq("questId", args.questId)) + .filter((q) => q.eq(q.field("deletionTime"), undefined)) + .collect(); + + return await Promise.all( + forms.map(async (form) => ({ + ...form, + url: form.file ? await ctx.storage.getUrl(form.file) : null, + })), + ); + }, +}); + export const createForm = userMutation({ args: { title: v.string(), - jurisdiction: jurisdiction, formCode: v.optional(v.string()), + jurisdiction: jurisdiction, + file: v.optional(v.id("_storage")), + questId: v.id("quests"), }, handler: async (ctx, args) => { return await ctx.db.insert("forms", { title: args.title, - jurisdiction: args.jurisdiction, formCode: args.formCode, + jurisdiction: args.jurisdiction, + file: args.file, + questId: args.questId, creationUser: ctx.userId, }); }, diff --git a/convex/schema.ts b/convex/schema.ts index 989b3e6..401ae8d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -68,21 +68,23 @@ const questFields = defineTable({ /** * Represents a PDF form that can be filled out by users. + * @param questId - The ID of the quest this form belongs to. * @param title - The title of the form. (e.g. "Petition to Change Name of Adult") * @param formCode - The legal code for the form. (e.g. "CJP 27") * @param creationUser - The user who created the form. * @param file - The storageId for the PDF file. - * @param state - The US State the form applies to. (e.g. "MA") + * @param jurisdiction - The US State the form applies to. (e.g. "MA") * @param deletionTime - Time in ms since epoch when the form was deleted. */ const forms = defineTable({ + questId: v.id("quests"), title: v.string(), formCode: v.optional(v.string()), creationUser: v.id("users"), file: v.optional(v.id("_storage")), jurisdiction: jurisdiction, deletionTime: v.optional(v.number()), -}); +}).index("quest", ["questId"]); /** * Represents a user of Namesake's identity. diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 1fa1dfa..1ec1fbe 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -47,7 +47,11 @@ export function Badge({ icon: Icon, className, ...props }: BadgeProps) { return (
{Icon && } {props.children} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index e8e1c72..ca71977 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -22,9 +22,9 @@ export const buttonStyles = tv({ primary: "bg-purple-solid text-white", secondary: "bg-gray-ghost text-gray-normal", destructive: "bg-red-solid", - icon: "bg-transparent hover:bg-gray-3 dark:hover:bg-graydark-3 text-gray-dim hover:text-gray-normal border-0 flex items-center justify-center rounded-full", + icon: "bg-transparent hover:bg-graya-3 dark:hover:bg-graydarka-3 text-gray-dim hover:text-gray-normal border-0 flex items-center justify-center rounded-full", ghost: - "bg-transparent hover:bg-gray-3 dark:hover:bg-graydark-3 text-gray-dim hover:text-gray-normal border-0", + "bg-transparent hover:bg-graya-3 dark:hover:bg-graydarka-3 text-gray-dim hover:text-gray-normal border-0", }, size: { small: "h-8 px-2", diff --git a/src/components/DocumentCard/DocumentCard.tsx b/src/components/DocumentCard/DocumentCard.tsx new file mode 100644 index 0000000..328b69b --- /dev/null +++ b/src/components/DocumentCard/DocumentCard.tsx @@ -0,0 +1,40 @@ +import { CircleArrowDown } from "lucide-react"; +import { Link } from "../Link"; +import { Tooltip, TooltipTrigger } from "../Tooltip"; + +export type DocumentCardProps = { + title: string; + formCode?: string; + downloadUrl?: string; +}; + +export const DocumentCard = ({ + title, + formCode, + downloadUrl, +}: DocumentCardProps) => { + const fileTitle = formCode ? `${formCode} ${title}` : title; + + return ( +
+ {formCode &&

{formCode}

} +
{title}
+
+ {downloadUrl && ( + + + + + Download + + )} +
+
+ ); +}; diff --git a/src/components/DocumentCard/index.ts b/src/components/DocumentCard/index.ts new file mode 100644 index 0000000..759ffc9 --- /dev/null +++ b/src/components/DocumentCard/index.ts @@ -0,0 +1 @@ +export * from "./DocumentCard"; diff --git a/src/components/index.ts b/src/components/index.ts index 9089fc6..5cc0643 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,6 +16,7 @@ export * from "./DatePicker"; export * from "./DateRangePicker"; export * from "./Dialog"; export * from "./Disclosure"; +export * from "./DocumentCard"; export * from "./Empty"; export * from "./Field"; export * from "./FileTrigger"; diff --git a/src/routes/_authenticated/_home/quests.$questId.tsx b/src/routes/_authenticated/_home/quests.$questId.tsx index 7d5d3d9..fd3b4ed 100644 --- a/src/routes/_authenticated/_home/quests.$questId.tsx +++ b/src/routes/_authenticated/_home/quests.$questId.tsx @@ -3,6 +3,7 @@ import { Badge, Button, DialogTrigger, + DocumentCard, Empty, Link, Menu, @@ -145,6 +146,35 @@ const QuestUrls = ({ urls }: { urls?: string[] }) => { ); }; +const QuestForms = ({ questId }: { questId: Id<"quests"> }) => { + const forms = useQuery(api.forms.getFormsForQuest, { + questId, + }); + + if (!forms || forms.length === 0) return null; + + return ( +
+
+

Forms

+ + {forms.length} + +
+
+ {forms.map((form) => ( + + ))} +
+
+ ); +}; + function QuestDetailRoute() { const { questId } = Route.useParams(); const navigate = useNavigate(); @@ -207,6 +237,7 @@ function QuestDetailRoute() {
+ {quest.content ? ( {quest.content} diff --git a/src/routes/_authenticated/admin/forms/$formId.tsx b/src/routes/_authenticated/admin/forms/$formId.tsx index 5830afc..695364d 100644 --- a/src/routes/_authenticated/admin/forms/$formId.tsx +++ b/src/routes/_authenticated/admin/forms/$formId.tsx @@ -1,4 +1,4 @@ -import { Badge, PageHeader } from "@/components"; +import { Badge, Link, PageHeader } from "@/components"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; import { createFileRoute } from "@tanstack/react-router"; @@ -30,7 +30,17 @@ function AdminFormDetailRoute() { {form.formCode} } - /> + > + + Go to quest + + {fileUrl && ( (null); const [title, setTitle] = useState(""); const [formCode, setFormCode] = useState(""); const [jurisdiction, setJurisdiction] = useState(null); + const [questId, setQuestId] = useState | null>(null); const clearForm = () => { setFile(null); setTitle(""); setFormCode(""); setJurisdiction(null); + setQuestId(null); }; const handleSubmit = async (e: React.FormEvent) => { @@ -61,9 +65,10 @@ const NewFormModal = ({ if (jurisdiction === null) throw new Error("Jurisdiction is required"); if (file === null) throw new Error("File is required"); + if (questId === null) throw new Error("Quest is required"); setIsSubmitting(true); - const formId = await createForm({ title, jurisdiction, formCode }); + const formId = await createForm({ title, jurisdiction, formCode, questId }); const postUrl = await generateUploadUrl(); const result = await fetch(postUrl, { @@ -80,58 +85,73 @@ const NewFormModal = ({ }; return ( - -

Upload new form

-
-
- { - if (e === null) return; - const files = Array.from(e); - setFile(files[0]); - }} - > - - - {file &&

{file.name}

} -
+ + setTitle(value)} - description="Use title case." - /> - setFormCode(value)} - description="Legal reference codes like “CJP 27”. Optional." + onChange={setTitle} + autoFocus + isRequired /> + + setQuestId(key as Id<"quests">)} + isRequired + > + {quests?.map((quest) => { + const textValue = `${quest.title}${ + quest.jurisdiction ? ` (${quest.jurisdiction})` : "" + }`; -
- + +
+ -