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

@@ -223,6 +223,98 @@ async function main() {
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" },
]);
// Create Forms & Templates
console.log("📝 Creating forms and templates...");
// Templates (system-wide templates)
const [consentTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "consent",
title: "Standard Informed Consent",
description: "A comprehensive informed consent document template for HRI research studies.",
isTemplate: true,
templateName: "Informed Consent",
fields: [
{ id: "1", type: "text", label: "Study Title", required: true },
{ id: "2", type: "text", label: "Principal Investigator Name", required: true },
{ id: "3", type: "text", label: "Institution", required: true },
{ id: "4", type: "textarea", label: "Purpose of the Study", required: true },
{ id: "5", type: "textarea", label: "Procedures", required: true },
{ id: "6", type: "textarea", label: "Risks and Benefits", required: true },
{ id: "7", type: "textarea", label: "Confidentiality", required: true },
{ id: "8", type: "yes_no", label: "I consent to participate in this study", required: true },
{ id: "9", type: "signature", label: "Participant Signature", required: true },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
const [surveyTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "survey",
title: "Post-Session Questionnaire",
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
isTemplate: true,
templateName: "Post-Session Survey",
fields: [
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
{ id: "2", type: "rating", label: "How understandable was the robot's speech?", required: true, settings: { scale: 5 } },
{ id: "3", type: "rating", label: "How natural did the interaction feel?", required: true, settings: { scale: 5 } },
{ id: "4", type: "multiple_choice", label: "Did the robot respond appropriately to your actions?", required: true, options: ["Yes, always", "Yes, mostly", "Sometimes", "Rarely", "No"] },
{ id: "5", type: "textarea", label: "What did you like most about the interaction?", required: false },
{ id: "6", type: "textarea", label: "What could be improved?", required: false },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
const [questionnaireTemplate] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "questionnaire",
title: "Demographics Form",
description: "Basic demographic information collection form.",
isTemplate: true,
templateName: "Demographics",
fields: [
{ id: "1", type: "text", label: "Age", required: true },
{ id: "2", type: "multiple_choice", label: "Gender", required: true, options: ["Male", "Female", "Non-binary", "Prefer not to say"] },
{ id: "3", type: "multiple_choice", label: "Experience with robots", required: true, options: ["None", "A little", "Moderate", "Extensive"] },
{ id: "4", type: "multiple_choice", label: "Experience with HRI research", required: true, options: ["Never participated", "Participated once", "Participated several times"] },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
// Study-specific form (not a template)
const [consentForm] = await db
.insert(schema.forms)
.values({
studyId: study!.id,
type: "consent",
title: "Interactive Storyteller Consent",
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
active: true,
fields: [
{ id: "1", type: "text", label: "Participant Name", required: true },
{ id: "2", type: "date", label: "Date", required: true },
{ id: "3", type: "textarea", label: "I understand that I will interact with a robot storyteller and may be asked to respond to questions.", required: true },
{ id: "4", type: "yes_no", label: "I consent to participate in this study", required: true },
{ id: "5", type: "signature", label: "Signature", required: true },
],
settings: {},
createdBy: adminUser.id,
})
.returning();
// Insert System Plugins
const [corePlugin] = await db
.insert(schema.plugins)

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")