feat: add form templates

- Add isTemplate and templateName fields to forms
- Add listTemplates and createFromTemplate API endpoints
- Add template selection to new form page UI
- Add sample templates and forms to seed script:
  - Informed Consent template
  - Post-Session Survey template
  - Demographics questionnaire template
This commit is contained in:
2026-03-22 17:53:16 -04:00
parent 49e0df016a
commit ecf0ab9103
4 changed files with 240 additions and 6 deletions

View File

@@ -15,6 +15,8 @@ import {
ClipboardList,
FileQuestion,
Save,
Copy,
LayoutTemplate,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
@@ -78,6 +80,18 @@ export default function NewFormPage() {
{ enabled: !!studyId },
);
const { data: templates } = api.forms.listTemplates.useQuery();
const createFromTemplate = api.forms.createFromTemplate.useMutation({
onSuccess: (data) => {
toast.success("Form created from template!");
router.push(`/studies/${studyId}/forms/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create from template", { description: error.message });
},
});
const createForm = api.forms.create.useMutation({
onSuccess: (data) => {
toast.success("Form created successfully!");
@@ -155,6 +169,48 @@ export default function NewFormPage() {
<p className="text-muted-foreground">Design a consent form, survey, or questionnaire</p>
</div>
{templates && templates.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5" />
Start from Template
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-3">
{templates.map((template) => {
const TypeIcon = formTypes.find(t => t.value === template.type)?.icon || FileText;
return (
<button
key={template.id}
type="button"
onClick={() => {
createFromTemplate.mutate({
studyId,
templateId: template.id,
});
}}
disabled={createFromTemplate.isPending}
className="flex flex-col items-start rounded-lg border p-4 text-left transition-all hover:bg-muted/50 disabled:opacity-50"
>
<TypeIcon className="mb-2 h-5 w-5 text-muted-foreground" />
<span className="font-medium">{template.templateName}</span>
<span className="text-muted-foreground text-xs capitalize">{template.type}</span>
<span className="text-muted-foreground text-xs mt-1 line-clamp-2">
{template.description}
</span>
</button>
);
})}
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Or design from scratch below
</div>
</CardContent>
</Card>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>

View File

@@ -189,9 +189,20 @@ export const formsRouter = createTRPCRouter({
}),
).default([]),
settings: z.record(z.string(), z.any()).optional(),
isTemplate: z.boolean().optional(),
templateName: z.string().max(100).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { isTemplate, templateName, ...formData } = input;
if (isTemplate && !templateName) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Template name is required when creating a template",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
"owner",
"researcher",
@@ -200,12 +211,14 @@ export const formsRouter = createTRPCRouter({
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: input.studyId,
type: input.type,
title: input.title,
description: input.description,
fields: input.fields,
settings: input.settings ?? {},
studyId: formData.studyId,
type: formData.type,
title: formData.title,
description: formData.description,
fields: formData.fields,
settings: formData.settings ?? {},
isTemplate: isTemplate ?? false,
templateName: templateName,
createdBy: ctx.session.user.id,
})
.returning();
@@ -589,4 +602,75 @@ export const formsRouter = createTRPCRouter({
return formsWithCounts;
}),
listTemplates: protectedProcedure
.query(async ({ ctx }) => {
const templates = await ctx.db.query.forms.findMany({
where: eq(forms.isTemplate, true),
orderBy: [desc(forms.updatedAt)],
});
return templates;
}),
createFromTemplate: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
templateId: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
"owner",
"researcher",
]);
const template = await ctx.db.query.forms.findFirst({
where: and(
eq(forms.id, input.templateId),
eq(forms.isTemplate, true),
),
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
}
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: input.studyId,
type: template.type,
title: input.title ?? `${template.title} (Copy)`,
description: template.description,
fields: template.fields,
settings: template.settings,
isTemplate: false,
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form from template",
});
}
await ctx.db.insert(activityLogs).values({
studyId: input.studyId,
userId: ctx.session.user.id,
action: "form_created_from_template",
description: `Created form "${newForm.title}" from template "${template.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
});

View File

@@ -630,6 +630,8 @@ export const forms = createTable(
description: text("description"),
version: integer("version").default(1).notNull(),
active: boolean("active").default(true).notNull(),
isTemplate: boolean("is_template").default(false).notNull(),
templateName: varchar("template_name", { length: 100 }),
fields: jsonb("fields").notNull().default([]),
settings: jsonb("settings").default({}),
createdBy: text("created_by")