diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index c925b24..b82de5f 100755 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -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) diff --git a/src/app/(dashboard)/studies/[id]/forms/new/page.tsx b/src/app/(dashboard)/studies/[id]/forms/new/page.tsx index f2fc645..116ab7c 100644 --- a/src/app/(dashboard)/studies/[id]/forms/new/page.tsx +++ b/src/app/(dashboard)/studies/[id]/forms/new/page.tsx @@ -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() {

Design a consent form, survey, or questionnaire

+ {templates && templates.length > 0 && ( + + + + + Start from Template + + + +
+ {templates.map((template) => { + const TypeIcon = formTypes.find(t => t.value === template.type)?.icon || FileText; + return ( + + ); + })} +
+
+ Or design from scratch below +
+
+
+ )} +
diff --git a/src/server/api/routers/forms.ts b/src/server/api/routers/forms.ts index d06f5fb..d9bb13d 100644 --- a/src/server/api/routers/forms.ts +++ b/src/server/api/routers/forms.ts @@ -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; + }), }); \ No newline at end of file diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index cf6c35b..5c57e1e 100755 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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")