diff --git a/apps/blade/src/app/admin/forms/[slug]/client.tsx b/apps/blade/src/app/admin/forms/[slug]/client.tsx index 502c301e..b36d088b 100644 --- a/apps/blade/src/app/admin/forms/[slug]/client.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/client.tsx @@ -188,6 +188,7 @@ export function EditorClient({ const [instructions, setInstructions] = useState([]); const [duesOnly, setDuesOnly] = useState(false); const [allowResubmission, setAllowResubmission] = useState(true); + const [allowEdit, setAllowEdit] = useState(true); const [responseRoleIds, setResponseRoleIds] = useState([]); const [responseRolesDialogOpen, setResponseRolesDialogOpen] = useState(false); const [activeItemId, setActiveItemId] = useState(null); @@ -257,6 +258,7 @@ export function EditorClient({ }, duesOnly, allowResubmission, + allowEdit, responseRoleIds, } as any); }, [ @@ -267,6 +269,7 @@ export function EditorClient({ instructions, duesOnly, allowResubmission, + allowEdit, formData, isLoading, isFetching, @@ -296,6 +299,7 @@ export function EditorClient({ setFormBanner(formData.formData.banner || ""); setDuesOnly(formData.duesOnly); setAllowResubmission(formData.allowResubmission); + setAllowEdit(formData.allowEdit); setResponseRoleIds((formData as any).responseRoleIds || []); const loadedQuestions: UIQuestion[] = formData.formData.questions.map( @@ -546,7 +550,7 @@ export function EditorClient({ -
+
+
+ + +
- - + + +
))} @@ -54,17 +56,3 @@ export async function FormResponses() { ); } - -function ViewFormResponseButton({ - responseIdSlug, - formNameSlug, -}: { - responseIdSlug: string; - formNameSlug: string; -}) { - return ( - - - - ); -} diff --git a/apps/blade/src/app/forms/[formName]/[responseId]/page.tsx b/apps/blade/src/app/forms/[formName]/[responseId]/page.tsx index bba2adef..682bc43b 100644 --- a/apps/blade/src/app/forms/[formName]/[responseId]/page.tsx +++ b/apps/blade/src/app/forms/[formName]/[responseId]/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; +import { XCircle } from "lucide-react"; import { auth } from "@forge/auth"; +import { Card } from "@forge/ui/card"; import { SIGN_IN_PATH } from "~/consts"; import { api, HydrateClient } from "~/trpc/server"; @@ -16,8 +18,26 @@ export default async function FormResponderPage({ redirect(SIGN_IN_PATH); } + if (!params.formName) { + return ( +
+ + +

Form not found

+
+
+ ); + } + if (!params.responseId) { - return
Submission not found
; + return ( +
+ + +

Response not found

+
+
+ ); } // handle url encode form names to allow spacing and special characters diff --git a/apps/blade/src/app/forms/[formName]/_components/form-view-edit-client.tsx b/apps/blade/src/app/forms/[formName]/_components/form-view-edit-client.tsx index 3ed74fa1..4913d076 100644 --- a/apps/blade/src/app/forms/[formName]/_components/form-view-edit-client.tsx +++ b/apps/blade/src/app/forms/[formName]/_components/form-view-edit-client.tsx @@ -3,28 +3,36 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { z } from "zod"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; +import { InstructionResponseCard } from "~/app/forms/[formName]/_components/instruction-response-card"; import { QuestionResponseCard } from "~/app/forms/[formName]/_components/question-response-card"; import { api } from "~/trpc/react"; +const emailSchema = z.string().email("Invalid email address"); +const phoneSchema = z.string().regex(/^\+?\d{7,15}$/, "Invalid phone number"); +const linkSchema = z.string().url("Please enter a valid URL"); + interface FormReviewClientProps { formName: string; userName: string; - responseId?: string; + responseId: string; } export function FormReviewClient({ formName, userName, + // eslint-disable-next-line @typescript-eslint/no-unused-vars responseId, }: FormReviewClientProps) { const router = useRouter(); const [responses, setResponses] = useState< - Record + Record >({}); + const [touchedFields, setTouchedFields] = useState>(new Set()); const [isSubmitted, setIsSubmitted] = useState(false); const [showCheckmark, setShowCheckmark] = useState(false); const [showText, setShowText] = useState(false); @@ -36,29 +44,42 @@ export function FormReviewClient({ }); // use responseId to query singular response to view - const responseQuery = api.forms.getUserResponse.useQuery({ - responseId, - }); + const responseQuery = api.forms.getUserResponse.useQuery( + { + responseId, + }, + // ensure edited responses are not stale + { + enabled: !!responseId, + staleTime: 0, + }, + ); - // TODO: WILL USE FOR EDIT - const submitResponse = api.forms.createResponse.useMutation({ + const editResponse = api.forms.editResponse.useMutation({ onSuccess: () => { setSubmitError(null); setIsSubmitted(true); }, onError: (error) => { setSubmitError( - error.message || "Failed to submit response. Please try again.", + error.message || "Failed to submit response edit. Please try again.", ); }, }); - useEffect(() => { - const data = responseQuery.data ? responseQuery.data[0] : null; - if (!data?.responseData) return; + if (!responseQuery.data) return; - const hydrated: Record = - {}; + const data = responseQuery.data[0]; + + if (!data?.responseData) { + setResponses({}); + return; + } + + const hydrated: Record< + string, + string | string[] | number | Date | boolean | null + > = {}; for (const [questionText, raw] of Object.entries(data.responseData)) { if (raw === null) hydrated[questionText] = null; @@ -71,15 +92,12 @@ export function FormReviewClient({ } else if (typeof raw === "number") hydrated[questionText] = raw; else if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) hydrated[questionText] = raw; + else if (typeof raw === "boolean") hydrated[questionText] = raw; else hydrated[questionText] = null; } setResponses(hydrated); - }, [responseId, responseQuery.data]); - - useEffect(() => { - setResponses({}); - }, [responseId]); + }, [responseQuery.data]); // Staggered animation for success screen useEffect(() => { @@ -106,11 +124,7 @@ export function FormReviewClient({ }, [isSubmitted, router]); // wait for all queries to load - if ( - formQuery.isLoading || - responseQuery.isLoading || - Object.keys(responses).length === 0 - ) + if (formQuery.isLoading || responseQuery.isLoading) return (
@@ -128,10 +142,7 @@ export function FormReviewClient({ const form = formQuery.data.formData; - // TODO: Implement editing - const allowEdit = false; - - const formDisabled = !allowEdit; + const allowEdit = formQuery.data.allowEdit; // SUCESSSSS if (isSubmitted) { @@ -169,8 +180,10 @@ export function FormReviewClient({ })); }; - // TODO: WILL USE FOR EDIT - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleFieldBlur = (questionText: string) => { + setTouchedFields((prev) => new Set(prev).add(questionText)); + }; + const handleSubmit = () => { // Build response data object const responseData: Record = {}; @@ -195,44 +208,142 @@ export function FormReviewClient({ .slice(0, 5); } } else { - responseData[question.question] = response; + // Convert boolean strings to actual booleans for BOOLEAN question type + if (question.type === "BOOLEAN" && typeof response === "string") { + responseData[question.question] = response === "true"; + } else { + responseData[question.question] = response; + } } } }); - submitResponse.mutate({ - form: formQuery.data.id, + editResponse.mutate({ + id: responseId, responseData, }); }; - // TODO: WILL USE FOR EDIT - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const getValidationError = (question: (typeof form.questions)[number]) => { + if (!touchedFields.has(question.question)) { + return null; + } + + const response = responses[question.question]; + + if (question.optional) { + if ( + !response || + response === "" || + (Array.isArray(response) && response.length === 0) + ) { + return null; + } + } else { + if (response === null || response === undefined || response === "") { + return "This field is required."; + } + if (Array.isArray(response) && response.length === 0) { + return "This field is required."; + } + // For required BOOLEAN questions, must be checked (true) + if (question.type === "BOOLEAN") { + const isChecked = + (typeof response === "string" && response === "true") || + (typeof response === "boolean" && response === true); + if (!isChecked) { + return "You must accept this to continue."; + } + } + } + + if (question.type === "EMAIL" && typeof response === "string") { + const result = emailSchema.safeParse(response); + if (!result.success) { + return "Please enter a valid email address"; + } + } + if (question.type === "PHONE" && typeof response === "string") { + const result = phoneSchema.safeParse(response); + if (!result.success) { + return "Please enter a valid phone number (7-15 digits, optional + prefix)"; + } + } + if (question.type === "LINK" && typeof response === "string") { + const result = linkSchema.safeParse(response); + if (!result.success) { + return "Please enter a valid URL"; + } + } + + return null; + }; + const isFormValid = () => { // Check if all required questions have responses return form.questions.every((question) => { - if (question.optional) return true; // Optional questions don't need validation + if (question.optional) { + const response = responses[question.question]; + if ( + !response || + response === "" || + (Array.isArray(response) && response.length === 0) + ) { + return true; + } + + if (question.type === "EMAIL" && typeof response === "string") { + return emailSchema.safeParse(response).success; + } + if (question.type === "PHONE" && typeof response === "string") { + return phoneSchema.safeParse(response).success; + } + if (question.type === "LINK" && typeof response === "string") { + return linkSchema.safeParse(response).success; + } + return true; + } const response = responses[question.question]; if (response === null || response === undefined || response === "") return false; if (Array.isArray(response) && response.length === 0) return false; + + // For required BOOLEAN questions, must be checked (true), not false + if (question.type === "BOOLEAN") { + if (typeof response === "string") { + return response === "true"; // Must be "true" string + } + if (typeof response === "boolean") { + return response === true; // Must be true boolean + } + return false; // Missing or invalid + } + + if (question.type === "EMAIL" && typeof response === "string") { + return emailSchema.safeParse(response).success; + } + if (question.type === "PHONE" && typeof response === "string") { + return phoneSchema.safeParse(response).success; + } + if (question.type === "LINK" && typeof response === "string") { + return linkSchema.safeParse(response).success; + } + return true; }); }; return (
-
+
{/* Banner */} {form.banner &&
} {/* Header */}
- {/* Implement View/Edit Title */} - {/*

{`${allowEdit ? "Edit" : "View"} - ${form.name}`}

*/} -

{`${"View"} - ${form.name}`}

+

{`${allowEdit ? "Edit" : "View"} - ${form.name}`}

{form.description && (

{form.description}

@@ -240,39 +351,76 @@ export function FormReviewClient({
- {/* Questions */} + {/* Questions and Instructions */}
- {form.questions.map((q, index) => { - const questionText = q.question; - const responseValue: - | string - | string[] - | number - | Date - | null - | undefined = responses[questionText]; - return ( -
- { - handleResponseChange(questionText, value); - }} - disabled={formDisabled} - /> -
+ {(() => { + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ + // Combine questions and instructions, sort by order + type QuestionWithOrder = (typeof form.questions)[number] & { + itemType: "question"; + }; + interface InstructionWithOrder { + itemType: "instruction"; + title: string; + content?: string; + imageUrl?: string; + videoUrl?: string; + order?: number; + } + + const questionsWithType: QuestionWithOrder[] = form.questions.map( + (q) => ({ + ...q, + itemType: "question" as const, + }), ); - })} + + const instructionsWithType: InstructionWithOrder[] = ( + (form as any).instructions || [] + ).map((inst: any) => ({ + ...inst, + itemType: "instruction" as const, + })); + + const allItems = [ + ...questionsWithType, + ...instructionsWithType, + ].sort((a, b) => (a.order ?? 999) - (b.order ?? 999)); + + return allItems.map((item, index) => { + const isInstruction = item.itemType === "instruction"; + + return ( +
+ {isInstruction ? ( + + ) : ( + { + handleResponseChange(item.question, value); + }} + onBlur={() => handleFieldBlur(item.question)} + formId={formQuery.data.id} + error={getValidationError(item)} + disabled={!allowEdit} + /> + )} +
+ ); + }); + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ + })()}
{submitError && ( @@ -283,23 +431,15 @@ export function FormReviewClient({ {/* Action Buttons */}
- {/* Implement disabling form */} - {/* {!formDisabled && ( + {allowEdit && ( - )} */} - + )}
diff --git a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx index 631dc891..6c1b4e1e 100644 --- a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx +++ b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx @@ -34,7 +34,7 @@ type FormQuestion = z.infer; interface QuestionResponseCardProps { question: FormQuestion; - value?: string | string[] | number | Date | null; + value?: string | string[] | number | Date | boolean | null; onChange: (value: string | string[] | number | Date | null) => void; onBlur?: () => void; disabled?: boolean; @@ -102,7 +102,7 @@ function QuestionBody({ formId, }: { question: FormQuestion; - value?: string | string[] | number | Date | null; + value?: string | string[] | number | Date | boolean | null; onChange: (value: string | string[] | number | Date | null) => void; onBlur?: () => void; disabled?: boolean; @@ -753,6 +753,11 @@ function FileUploadInput({ const getUploadUrlMutation = api.forms.getUploadUrl.useMutation(); + // used to sync with responseData for view/edit, otherwise value will be null + React.useEffect(() => { + setFileName(value ? (value.split("/").pop() ?? null) : null); + }, [value]); + const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index beed367f..1727d6c3 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -326,6 +326,18 @@ export const formsRouter = { ) .mutation(async ({ input, ctx }) => { controlPerms.or(["EDIT_FORMS"], ctx); + + const form = await db.query.FormsSchemas.findFirst({ + where: (t, { eq }) => eq(t.id, input.form), + }); + + if (form?.allowEdit) { + throw new TRPCError({ + message: "Cannot add connection to form with allowEdit", + code: "BAD_REQUEST", + }); + } + try { await db.insert(TrpcFormConnection).values({ ...input }); } catch { @@ -477,6 +489,32 @@ export const formsRouter = { }); }), + editResponse: protectedProcedure + .input( + InsertFormResponseSchema.omit({ userId: true, form: true }).extend({ + id: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user.id; + + const updated = await db + .update(FormResponse) + .set({ responseData: input.responseData, editedAt: new Date() }) + .where( + and(eq(FormResponse.id, input.id), eq(FormResponse.userId, userId)), + ) + .returning({ id: FormResponse.id, editedAt: FormResponse.editedAt }); + + if (updated.length === 0) { + throw new TRPCError({ + message: "Form response edit failed", + code: "BAD_REQUEST", + }); + } + return updated[0]; + }), + getResponses: permProcedure .input(z.object({ form: z.string() })) .query(async ({ input, ctx }) => { @@ -534,12 +572,13 @@ export const formsRouter = { if (responseId) { return await db .select({ - submittedAt: FormResponse.createdAt, + submittedAt: FormResponse.editedAt, responseData: FormResponse.responseData, formName: FormsSchemas.name, formSlug: FormsSchemas.slugName, id: FormResponse.id, hasSubmitted: sql`true`, + allowEdit: sql`false`, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) @@ -551,40 +590,42 @@ export const formsRouter = { ); } - // return all responses all forms + // return all responses of form const form = input.form; - if (!form) { + if (form) { return await db .select({ - submittedAt: FormResponse.createdAt, + submittedAt: FormResponse.editedAt, responseData: FormResponse.responseData, formName: FormsSchemas.name, formSlug: FormsSchemas.slugName, id: FormResponse.id, hasSubmitted: sql`true`, + allowEdit: sql`false`, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) - .where(eq(FormResponse.userId, userId)) - .orderBy(desc(FormResponse.createdAt)); + .where( + and(eq(FormResponse.userId, userId), eq(FormsSchemas.id, form)), + ) + .orderBy(desc(FormResponse.editedAt)); } - // return all responses of form + // return all responses all forms return await db .select({ - submittedAt: FormResponse.createdAt, + submittedAt: FormResponse.editedAt, responseData: FormResponse.responseData, formName: FormsSchemas.name, formSlug: FormsSchemas.slugName, id: FormResponse.id, hasSubmitted: sql`true`, + allowEdit: FormsSchemas.allowEdit, }) .from(FormResponse) .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id)) - .where( - and(eq(FormResponse.userId, userId), eq(FormsSchemas.name, form)), - ) - .orderBy(desc(FormResponse.createdAt)); + .where(eq(FormResponse.userId, userId)) + .orderBy(desc(FormResponse.editedAt)); }), // Generate presigned upload URL for direct MinIO upload diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index e6c46289..212e5a54 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -570,6 +570,7 @@ export const FormsSchemas = createTable("form_schemas", (t) => ({ createdAt: t.timestamp().notNull().defaultNow(), duesOnly: t.boolean().notNull().default(false), allowResubmission: t.boolean().notNull().default(false), + allowEdit: t.boolean().notNull().default(false), formData: t.jsonb().notNull(), formValidatorJson: t.jsonb().notNull(), section: t.varchar({ length: 255 }).notNull().default("General"), @@ -610,6 +611,7 @@ export const FormResponse = createTable("form_response", (t) => ({ .references(() => User.id, { onDelete: "cascade" }), responseData: t.jsonb().notNull(), createdAt: t.timestamp().notNull().defaultNow(), + editedAt: t.timestamp().notNull().defaultNow(), })); export const InsertFormResponseSchema = createInsertSchema(FormResponse);