Skip to content

Commit

Permalink
feat: Link uploaded forms to quests (#226)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Nov 27, 2024
1 parent 45ea715 commit eebd9b6
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 48 deletions.
5 changes: 5 additions & 0 deletions .changeset/olive-lemons-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Link uploaded forms to quests
26 changes: 24 additions & 2 deletions convex/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
},
Expand Down
6 changes: 4 additions & 2 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export function Badge({ icon: Icon, className, ...props }: BadgeProps) {
return (
<div
{...props}
className={badge({ variant: props.variant, size: props.size, className })}
className={badge({
variant: props.variant,
size: props.size,
className,
})}
>
{Icon && <Icon className={icon({ variant: props.variant })} />}
{props.children}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions src/components/DocumentCard/DocumentCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col w-48 h-60 shrink-0 p-4 bg-gray-1 dark:bg-graydark-3 shadow-md rounded">
{formCode && <p className="text-gray-dim text-sm mb-1">{formCode}</p>}
<header className="font-medium text-pretty leading-tight">{title}</header>
<div className="mt-auto -mb-2 -mr-2 flex justify-end">
{downloadUrl && (
<TooltipTrigger>
<Link
href={downloadUrl}
button={{ variant: "icon" }}
aria-label="Download"
className="mt-auto self-end"
download={fileTitle}
>
<CircleArrowDown size={16} className="text-gray-dim" />
</Link>
<Tooltip>Download</Tooltip>
</TooltipTrigger>
)}
</div>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/DocumentCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./DocumentCard";
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
31 changes: 31 additions & 0 deletions src/routes/_authenticated/_home/quests.$questId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Badge,
Button,
DialogTrigger,
DocumentCard,
Empty,
Link,
Menu,
Expand Down Expand Up @@ -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 (
<div className="p-4 rounded-lg border border-gray-dim mb-8">
<header className="flex gap-1 items-center pb-4">
<h3 className="text-gray-dim text-sm">Forms</h3>
<Badge size="xs" className="rounded-full">
{forms.length}
</Badge>
</header>
<div className="flex gap-4 overflow-x-auto p-4 -m-4">
{forms.map((form) => (
<DocumentCard
key={form._id}
title={form.title}
formCode={form.formCode}
downloadUrl={form.url ?? undefined}
/>
))}
</div>
</div>
);
};

function QuestDetailRoute() {
const { questId } = Route.useParams();
const navigate = useNavigate();
Expand Down Expand Up @@ -207,6 +237,7 @@ function QuestDetailRoute() {
<QuestTimeRequired timeRequired={quest.timeRequired as TimeRequired} />
</div>
<QuestUrls urls={quest.urls} />
<QuestForms questId={quest._id} />
{quest.content ? (
<Markdown className="prose lg:prose-lg dark:prose-invert max-w-full">
{quest.content}
Expand Down
14 changes: 12 additions & 2 deletions src/routes/_authenticated/admin/forms/$formId.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,7 +30,17 @@ function AdminFormDetailRoute() {
<Badge size="lg">{form.formCode}</Badge>
</>
}
/>
>
<Link
href={{
to: "/admin/quests/$questId",
params: { questId: form.questId },
}}
button={{ variant: "secondary" }}
>
Go to quest
</Link>
</PageHeader>
{fileUrl && (
<object
className="w-full aspect-square max-h-full rounded-lg"
Expand Down
98 changes: 59 additions & 39 deletions src/routes/_authenticated/admin/forms/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Badge,
Button,
ComboBox,
Empty,
FileTrigger,
Form,
Expand All @@ -20,7 +21,7 @@ import {
TextField,
} from "@/components";
import { api } from "@convex/_generated/api";
import type { DataModel } from "@convex/_generated/dataModel";
import type { DataModel, Id } from "@convex/_generated/dataModel";
import { JURISDICTIONS, type Jurisdiction } from "@convex/constants";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
Expand All @@ -43,27 +44,31 @@ const NewFormModal = ({
const generateUploadUrl = useMutation(api.forms.generateUploadUrl);
const uploadPDF = useMutation(api.forms.uploadPDF);
const createForm = useMutation(api.forms.createForm);
const quests = useQuery(api.quests.getAllActiveQuests);
const [isSubmitting, setIsSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [formCode, setFormCode] = useState("");
const [jurisdiction, setJurisdiction] = useState<Jurisdiction | null>(null);
const [questId, setQuestId] = useState<Id<"quests"> | null>(null);

const clearForm = () => {
setFile(null);
setTitle("");
setFormCode("");
setJurisdiction(null);
setQuestId(null);
};

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

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, {
Expand All @@ -80,58 +85,73 @@ const NewFormModal = ({
};

return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<h2 className="text-xl">Upload new form</h2>
<Form className="w-full" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<FileTrigger
acceptedFileTypes={["application/pdf"]}
onSelect={(e) => {
if (e === null) return;
const files = Array.from(e);
setFile(files[0]);
}}
>
<Button variant="secondary">Select a PDF</Button>
</FileTrigger>
{file && <p>{file.name}</p>}
</div>
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
className="w-full max-w-xl"
>
<Form onSubmit={handleSubmit} className="w-full">
<TextField
label="Title"
name="title"
isRequired
value={title}
onChange={(value) => setTitle(value)}
description="Use title case."
/>
<TextField
label="Form Code"
name="formCode"
value={formCode}
onChange={(value) => setFormCode(value)}
description="Legal reference codes like “CJP 27”. Optional."
onChange={setTitle}
autoFocus
isRequired
/>
<TextField label="Form code" value={formCode} onChange={setFormCode} />
<Select
label="Jurisdiction"
name="jurisdiction"
label="State"
selectedKey={jurisdiction}
onSelectionChange={(key) => setJurisdiction(key as Jurisdiction)}
placeholder="Select a jurisdiction"
isRequired
>
{Object.entries(JURISDICTIONS).map(([value, label]) => (
<SelectItem key={value} id={value}>
{label}
{Object.entries(JURISDICTIONS).map(([key, value]) => (
<SelectItem key={key} id={key}>
{value}
</SelectItem>
))}
</Select>
<ComboBox
label="Quest"
selectedKey={questId}
onSelectionChange={(key) => setQuestId(key as Id<"quests">)}
isRequired
>
{quests?.map((quest) => {
const textValue = `${quest.title}${
quest.jurisdiction ? ` (${quest.jurisdiction})` : ""
}`;

<div className="flex gap-2 justify-end">
<Button type="button" onPress={() => onOpenChange(false)}>
return (
<SelectItem key={quest._id} id={quest._id} textValue={textValue}>
{quest.title}{" "}
{quest.jurisdiction && <Badge>{quest.jurisdiction}</Badge>}
</SelectItem>
);
})}
</ComboBox>
<FileTrigger
acceptedFileTypes={["application/pdf"]}
onSelect={(e) => {
if (e === null) return;
const files = Array.from(e);
setFile(files[0]);
}}
>
<Button type="button" variant="secondary">
{file ? file.name : "Select PDF"}
</Button>
</FileTrigger>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="secondary"
onPress={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" isDisabled={isSubmitting} variant="primary">
Upload Form
<Button type="submit" variant="primary" isDisabled={isSubmitting}>
Create
</Button>
</div>
</Form>
Expand Down

0 comments on commit eebd9b6

Please sign in to comment.