Skip to content

[#342] Allow editing responses in Blade forms#315

Merged
DVidal1205 merged 15 commits intomainfrom
form-response-edit
Feb 8, 2026
Merged

[#342] Allow editing responses in Blade forms#315
DVidal1205 merged 15 commits intomainfrom
form-response-edit

Conversation

@aidenletourneau
Copy link
Contributor

@aidenletourneau aidenletourneau commented Jan 31, 2026

Why

allow users to edit their form response if allowed by that form

What

  • toggle for response editing in form creation
  • form-view-edit-client allows for editing form responses
  • procedure for editing responses
  • db flag allowEdit

Test Plan

tested with all input types and with multiple submissions

Summary by CodeRabbit

  • New Features

    • Option to allow editing of form responses after submission.
    • Edit-response API and edited timestamp so edits are tracked.
    • Enhanced submission success experience with animated checkmark and auto-redirect countdown.
    • Dedicated "Form Not Found" and "Response Not Found" pages.
  • Improvements

    • Response view updated to show context-aware "Edit" or "View" actions.
    • Unified form runner and validation flow for more consistent input handling.

@aidenletourneau aidenletourneau marked this pull request as ready for review February 4, 2026 20:38
@aidenletourneau aidenletourneau force-pushed the form-response-edit branch 3 times, most recently from be00a54 to e14456a Compare February 7, 2026 17:05
@aidenletourneau aidenletourneau marked this pull request as draft February 7, 2026 18:41
@aidenletourneau aidenletourneau force-pushed the form-response-edit branch 3 times, most recently from f0a2818 to 9e4e85b Compare February 7, 2026 19:57
@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

Adds an "allow edit" flow across DB, API, admin UI, and client UI: new Form.allowEdit and FormResponse.editedAt, an editResponse mutation and guards, admin toggle to persist allowEdit, and refactors responder/reviewer UIs to a shared FormRunner with submission/edit handling and success UX.

Changes

Cohort / File(s) Summary
Database Schema
packages/db/src/schemas/knight-hacks.ts
Added allowEdit: boolean to forms (default false) and editedAt: timestamp to form responses (defaultNow()).
API Router & Response Handling
packages/api/src/routers/forms.ts
Added editResponse mutation; prevent enabling allowEdit when TRPC connections exist and prevent adding connections when allowEdit is enabled; responses now expose allowEdit and use editedAt for timestamps.
Type Exports
packages/consts/src/knight-hacks.ts
Exported QuestionValidatorType type.
Admin Form Editor
apps/blade/src/app/admin/forms/[slug]/client.tsx
Added allowEdit state, admin toggle UI, persisted in updateForm payload, and included in save effects.
Dashboard Response Links
apps/blade/src/app/dashboard/_components/member-dashboard/forms/form-responses.tsx
Replaced ViewFormResponseButton with inline Button+Link; label now shows "Edit" when allowEdit is true, otherwise "View".
Top-level Pages
apps/blade/src/app/forms/[formName]/page.tsx, apps/blade/src/app/forms/[formName]/[responseId]/page.tsx
Swapped responder/review clients for wrapper components and return dedicated FormNotFound / ResponseNotFound components on missing params.
Responder / Reviewer Refactor
apps/blade/src/app/forms/[formName]/_components/form-responder-client.tsx, .../form-view-edit-client.tsx
Renamed wrappers to FormResponderWrapper / FormReviewWrapper; moved data flow to formQuery/responseQuery, centralized payload-to-UI conversion, and switched to FormRunner + editResponse for edits.
New Unified Runner & UX
apps/blade/src/app/forms/[formName]/_components/form-runner.tsx, form-submitted-success.tsx
Added FormRunner component (rendering questions/instructions, validation, submit handling) and SubmissionSuccessCard for post-submit animation/countdown.
Not-found Components
apps/blade/src/app/forms/[formName]/_components/form-not-found.tsx, response-not-found.tsx
Added dedicated UI components for missing form and missing response pages.
Validation Utilities
apps/blade/src/app/forms/[formName]/_components/utils.ts
New utilities: FormResponseUI/FormResponsePayload, normalizeResponses, getValidatorResponse, getValidationError, isFormValid (Zod-based validation and normalization incl. date/time/boolean handling).
Submission Hook
apps/blade/src/app/forms/[formName]/_hooks/useSubmissionSuccess.ts
New useSubmissionSuccess hook for staged checkmark/text display, redirect countdown, and automatic redirect.
Question Input Updates
apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx
Extended value and onChange types to include boolean; FileUploadInput now syncs displayed filename when value prop changes.

Sequence Diagram

sequenceDiagram
    participant User
    participant Browser
    participant Server as Next.js
    participant API as TRPC
    participant DB

    User->>Browser: Open form page
    Browser->>Server: Request page (getForm)
    Server->>API: getForm(slug)
    API->>DB: SELECT form (includes allowEdit)
    DB-->>API: form + allowEdit
    API-->>Server: form data
    Server-->>Browser: Render FormRunner (respond mode)

    User->>Browser: Submit response
    Browser->>Browser: normalizeResponses + Zod validation
    Browser->>API: createResponse(payload)
    API->>DB: INSERT response (submittedAt, editedAt)
    DB-->>API: inserted row
    API-->>Browser: success (responseId)
    Browser->>Browser: show SubmissionSuccessCard, redirect countdown

    alt allowEdit = true
        User->>Browser: Click "Edit" on response
        Browser->>Server: Request response view
        Server->>API: getUserResponse(responseId)
        API->>DB: SELECT response + form (allowEdit)
        DB-->>API: response + allowEdit
        API-->>Server: payload
        Server-->>Browser: Render FormRunner (isReview, editable)
        User->>Browser: Submit edits
        Browser->>API: editResponse(id, payload)
        API->>DB: UPDATE response (editedAt)
        DB-->>API: update result
        API-->>Browser: updated timestamp
    else
        User->>Browser: View response (read-only FormRunner)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested labels

Database, UI, Feature

🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title follows all requirements: starts with [#342], provides a concise description of the main feature (allowing response editing), and is 45 characters (under 72 char limit).
No Hardcoded Secrets ✅ Passed All changed files analyzed for hardcoded secrets using pattern matching and regex searches. No hardcoded credentials, API keys, passwords, tokens, or sensitive data found. Code properly uses imported environment constants.
Validated Env Access ✅ Passed Pull request does not introduce direct process.env usage outside of env.ts config files; pre-existing references in knight-hacks.ts are unrelated to PR changes.
No Typescript Escape Hatches ✅ Passed No TypeScript escape hatches (any, @ts-ignore, @ts-expect-error, non-null assertions) detected in new code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch form-response-edit

Comment @coderabbitai help to get the list of available commands and usage tips.

@DVidal1205 DVidal1205 added Blade Change modifies code in Blade app API Change modifies code in the global API/tRPC package Major Big change - 2+ reviewers required labels Feb 7, 2026
@aidenletourneau aidenletourneau changed the title Form response edit [342] Form response edit Feb 8, 2026
@aidenletourneau aidenletourneau changed the title [342] Form response edit [#342] Form response edit Feb 8, 2026
@aidenletourneau aidenletourneau linked an issue Feb 8, 2026 that may be closed by this pull request
@aidenletourneau aidenletourneau changed the title [#342] Form response edit [#342] Feb 8, 2026
@aidenletourneau aidenletourneau changed the title [#342] [#342] Allow editing responses in Blade forms Feb 8, 2026
@aidenletourneau aidenletourneau marked this pull request as ready for review February 8, 2026 01:24
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/blade/src/app/admin/forms/[slug]/client.tsx (1)

329-331: ⚠️ Potential issue | 🟡 Minor

Missing allowEdit in auto-save trigger.

The other toggle switches (duesOnly, allowResubmission) auto-save when changed, but allowEdit is missing from this effect's dependency array. This creates inconsistent UX—users must manually save or wait for periodic auto-save.

Proposed fix
   // auto save trigger when toggle switches are changed
   useEffect(() => {
     if (!isLoading) handleSaveForm();
-  }, [duesOnly, allowResubmission, responseRoleIds, isLoading]); // removed handleSaveForm to prevent save-on-every-render
+  }, [duesOnly, allowResubmission, allowEdit, responseRoleIds, isLoading]); // removed handleSaveForm to prevent save-on-every-render
packages/api/src/routers/forms.ts (1)

586-605: ⚠️ Potential issue | 🟠 Major

Inconsistent allowEdit value — always returns false for single-response queries.

When fetching by responseId, allowEdit is hardcoded to false (line 595). However, the "all responses" path (line 637) correctly returns FormsSchemas.allowEdit. This inconsistency could cause the UI to incorrectly hide the edit button when viewing a single response.

🐛 Proposed fix: Use actual form's allowEdit value
        return await db
          .select({
            submittedAt: FormResponse.editedAt,
            responseData: FormResponse.responseData,
            formName: FormsSchemas.name,
            formSlug: FormsSchemas.slugName,
            id: FormResponse.id,
            hasSubmitted: sql<boolean>`true`,
-           allowEdit: sql<boolean>`false`,
+           allowEdit: FormsSchemas.allowEdit,
          })
          .from(FormResponse)
          .leftJoin(FormsSchemas, eq(FormResponse.form, FormsSchemas.id))
          .where(
            and(
              eq(FormResponse.id, responseId),
              eq(FormResponse.userId, userId),
            ),
          );
🤖 Fix all issues with AI agents
In `@apps/blade/src/app/admin/forms/`[slug]/client.tsx:
- Around line 580-592: The Switch and Label for the "Allow Response Edit" toggle
have mismatched identifiers (Switch id="allow-resubmit" vs Label
htmlFor="allow-edit"), breaking the label-input association; update one so both
match (e.g., set the Switch id to "allow-edit" or the Label htmlFor to
"allow-resubmit") so the Label correctly targets the Switch used by the
allowEdit state and setAllowEdit handler (look for the Switch and Label
components around the allowEdit/setAllowEdit usage).

In
`@apps/blade/src/app/dashboard/_components/member-dashboard/forms/form-responses.tsx`:
- Around line 38-44: The Link wrapping a Button creates nested interactive
elements (Link > Button) which is invalid; update the FormResponses row to
render a single interactive element: either make Button render the anchor by
using Button's asChild prop (if supported) so Button acts as the Link, passing
href={`/forms/${formResponse.formSlug}/${formResponse.id}`}, or remove Button
and apply the button styling/ARIA to the Link itself so the Link becomes the
sole interactive element that shows "Edit" or "View" based on
formResponse.allowEdit; ensure focus/role/aria-labels remain correct.

In `@apps/blade/src/app/forms/`[formName]/_components/form-runner.tsx:
- Around line 124-128: The conditional {form.banner && <div
className="overflow-hidden rounded-lg"></div>} renders an empty container;
update the FormRunner rendering so that if form.banner is present it actually
displays content (e.g., if form.banner is a URL render an <img> with
src=form.banner and appropriate alt/className, or if form.banner can be a
ReactNode render it directly), or remove the conditional entirely if you do not
intend to show a banner; locate the banner usage in the form-runner.tsx
component and replace the empty div with the appropriate rendering logic for
form.banner.
- Around line 104-187: The code uses any casts and eslint-disable around the
instructions processing and when rendering InstructionResponseCard; replace the
casts by reading the typed instructions from form.instructions (which exists on
FormType) and map them into InstructionWithOrder (same as questions mapping)
inside the useMemo for allItems (function: useMemo that builds questionsWithType
and instructionsWithType), remove the eslint-disable comments and the (form as
any).instructions casting as well as the cast when passing to
InstructionResponseCard, and pass the properly-typed item (InstructionWithOrder)
directly to InstructionResponseCard so TypeScript and ESLint no longer require
escape hatches.

In `@apps/blade/src/app/forms/`[formName]/_components/form-view-edit-client.tsx:
- Around line 40-95: The editResponse mutation currently only checks ownership
and doesn't respect the form's allowEdit flag, so update the editResponse
protectedProcedure (the mutation handler that queries FormResponse) to fetch the
related form.allowEdit when finding the response (e.g., include form: { columns:
{ allowEdit: true } } or join the form) and after fetching, reject the request
if either no response is found or response.form?.allowEdit is false by throwing
a TRPCError (BAD_REQUEST or FORBIDDEN) before performing the update; ensure the
subsequent update logic (update(FormResponse).set(...)) only runs when the
allowEdit check passes and set editedAt when updating.

In `@packages/api/src/routers/forms.ts`:
- Around line 607-626: The query branch for when input.form is present currently
hardcodes allowEdit to false; update the select projection so allowEdit reflects
the form's actual column value (same logic used in the responseId branch) by
selecting the FormsSchemas.allowEdit column (or a boolean expression checking
eq(FormsSchemas.allowEdit, true)) instead of sql<boolean>`false`, keeping the
rest of the query using FormResponse, FormsSchemas, and input.form as-is.
- Around line 506-530: editResponse currently skips checking the form's
allowEdit flag and does not validate responseData against the form's JSON
schema; fetch the existing FormResponse together with its Form (e.g., join
FormResponse with Form to get formId, allowEdit, and jsonSchema), verify
form.allowEdit is true (throw TRPCError with code "FORBIDDEN" or "BAD_REQUEST"
if false), validate input.responseData against the form's jsonSchema using the
same validator used by createResponse (or the shared validate function), and
only then perform the db.update(FormResponse)...set(...) operation; return the
updated record as before and keep the ownership check eq(FormResponse.userId,
userId).

In `@packages/db/src/schemas/knight-hacks.ts`:
- Around line 503-505: The schema change adds allowEdit to the form_response
table; instead of using drizzle-kit push, create a SQL migration file that ALTER
TABLE form_response ADD COLUMN allowEdit boolean NOT NULL DEFAULT false and
include a DOWN migration to DROP COLUMN allowEdit; for editedAt avoid backdating
existing rows — either (A) add editedAt nullable, backfill existing rows SET
editedAt = createdAt for rows that should be considered edited and then ALTER
COLUMN editedAt SET NOT NULL for future enforcement, or (B) add editedAt NOT
NULL only for new inserts by keeping it NULLABLE initially and adding
application-level logic to populate it going forward, then create a follow-up
migration to make it NOT NULL; follow Drizzle migrations format and include
descriptive up/down SQL and transaction safety.
🧹 Nitpick comments (5)
packages/api/src/routers/forms.ts (1)

113-113: Remove debug console.log statement.

This logs the entire input object (including form data) to stdout. Remove it or use the structured log utility if logging is intentional.

🧹 Proposed fix
-     console.log(input);
packages/db/src/schemas/knight-hacks.ts (1)

543-546: Consider a clearer pattern for tracking edits: make editedAt nullable.

The schema defaults editedAt to now() on all inserts, which semantically conflates "new response" with "edited response." The update logic correctly sets editedAt: new Date() on actual edits, but the default creates ambiguity: you can't distinguish whether a timestamp is from creation or editing.

Recommend making editedAt nullable (omit the default), then explicitly set it only during updates. This makes the intent clear: null = never edited, otherwise = last edit time.

-  editedAt: t.timestamp().notNull().defaultNow(),
+  editedAt: t.timestamp(),

Existing responses will have null, matching their actual state (created once, never edited). Future edits will populate it as they do now.

apps/blade/src/app/forms/[formName]/_hooks/useSubmissionSuccess.ts (1)

28-40: Clamp/reset the redirect countdown to avoid stale or negative values.
If isSubmitted toggles or delays change, the countdown can retain old values or dip below zero. Reset it on submit and clamp at 0.

💡 Suggested tweak
  useEffect(() => {
-   if (!isSubmitted) return;
+   if (!isSubmitted) {
+     setShowCheckmark(false);
+     setShowText(false);
+     setRedirectCountdown(Math.ceil(redirectDelayMs / 1000));
+     return;
+   }
+
+   setRedirectCountdown(Math.ceil(redirectDelayMs / 1000));
    const checkTimer = setTimeout(
      () => setShowCheckmark(true),
      checkmarkDelayMs,
    );
    const textTimer = setTimeout(() => setShowText(true), textDelayMs);

    const countdownInterval = setInterval(() => {
-     setRedirectCountdown((prev) => prev - 1);
+     setRedirectCountdown((prev) => Math.max(prev - 1, 0));
    }, 1000);
apps/blade/src/app/forms/[formName]/_components/utils.ts (1)

15-24: Consider moving validators to a keyed registry for defense-in-depth.

The current approach uses new Function to evaluate zodValidator strings from the form database. While the risk is lower than typical code execution (validators come from your server, not user input), a registry pattern eliminates the eval entirely and protects against database compromise:

Safer pattern (registry lookup)
// validators.ts
export const VALIDATOR_REGISTRY = {
  questionnaire_v1: z.object({ /* ... */ }),
  survey_v2: z.object({ /* ... */ }),
  // ...
} as const;

// utils.ts - updated getValidatorResponse
export const getValidatorResponse = (
  validatorKey: string,
  responses: FormResponseUI,
  form: FormType,
) => {
  const zodSchema = VALIDATOR_REGISTRY[validatorKey as keyof typeof VALIDATOR_REGISTRY];
  if (!zodSchema) {
    return {
      success: false,
      error: new z.ZodError([
        { code: "custom", message: "Unknown validator", path: [] },
      ]),
    };
  }
  const payload = normalizeResponses(responses, form);
  return zodSchema.safeParse(payload);
};

This avoids new Function entirely and makes validators auditable in code. No immediate risk (validators are server-provided), but this is a good refactor for maintainability.

apps/blade/src/app/forms/[formName]/_components/form-submitted-success.tsx (1)

26-48: Add live-region semantics for the redirect countdown (and hide the decorative icon).

The countdown changes dynamically; a polite live region ensures it’s announced, and hiding the icon avoids screen‑reader noise.

💡 Suggested update
-          <CheckCircle2 className="mx-auto h-16 w-16 text-green-500" />
+          <CheckCircle2
+            className="mx-auto h-16 w-16 text-green-500"
+            aria-hidden="true"
+          />
...
-          <p className="mt-4 text-sm text-muted-foreground">
+          <p
+            className="mt-4 text-sm text-muted-foreground"
+            role="status"
+            aria-live="polite"
+            aria-atomic="true"
+          >
             Redirecting in {redirectCountdown}...
           </p>
As per coding guidelines, "Accessibility (alt text, ARIA, semantic HTML)".

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/blade/src/app/forms/`[formName]/_hooks/useSubmissionSuccess.ts:
- Around line 24-26: The countdown initial value uses plain division which can
produce fractional seconds; make it consistent with the reset logic by
initializing redirectCountdown using the same rounding approach (e.g., use
Math.ceil on redirectDelayMs / 1000) so redirectCountdown and
setRedirectCountdown behavior matches; update the initialization expression
where redirectCountdown is declared to mirror the Math.ceil used elsewhere
(referencing redirectCountdown, setRedirectCountdown, and redirectDelayMs).
🧹 Nitpick comments (3)
apps/blade/src/app/admin/forms/[slug]/client.tsx (1)

329-331: Consider addressing the missing handleSaveForm dependency.

The linter flags that handleSaveForm is used inside this effect but isn't in the dependency array. The inline comment explains this was intentional to prevent save-on-every-render, but omitting it creates a stale closure—the effect may call an outdated version of handleSaveForm.

A safer pattern is to use a ref to track "should save" intent, or wrap the save trigger in a stable callback. Since this pattern pre-dates your change and you're just adding allowEdit, this is low-priority—but worth addressing to silence the lint warning and avoid subtle bugs.

♻️ One possible fix using a ref guard
+ const initialLoadComplete = React.useRef(false);
+
  // auto save trigger when toggle switches are changed
  useEffect(() => {
-   if (!isLoading) handleSaveForm();
-  }, [duesOnly, allowResubmission, responseRoleIds, isLoading, allowEdit]); // removed handleSaveForm to prevent save-on-every-render
+   if (!isLoading && initialLoadComplete.current) {
+     handleSaveForm();
+   }
+   initialLoadComplete.current = true;
+  }, [duesOnly, allowResubmission, responseRoleIds, isLoading, allowEdit, handleSaveForm]);
apps/blade/src/app/forms/[formName]/_components/form-runner.tsx (2)

39-39: Remove unused userName prop.

This prop is declared in the component's props interface but never referenced in the component body. Unused props add confusion and increase the component's API surface without benefit.

🧹 Suggested fix
 }: {
   isReview?: boolean;
   form: FormType;

   formId: string;

-  userName?: string;
-
   zodValidator: string;

123-209: Consider wrapping in a semantic <form> element.

Using a <form> element instead of a plain <div> improves accessibility by:

  • Providing proper form semantics to screen readers
  • Enabling native keyboard submission (Enter key)
  • Allowing type="submit" buttons to work naturally

If you adopt this, use event.preventDefault() in the handler to maintain your custom submit logic:

💡 Example approach
   return (
-    <div className="min-h-screen overflow-x-visible bg-primary/5 p-6">
+    <form
+      className="min-h-screen overflow-x-visible bg-primary/5 p-6"
+      onSubmit={(e) => {
+        e.preventDefault();
+        if (canSubmit) handleSubmit();
+      }}
+    >
       <div className="mx-auto max-w-3xl space-y-4">
         {/* ... content ... */}
-        <Button onClick={handleSubmit} disabled={!canSubmit} size="lg">
+        <Button type="submit" disabled={!canSubmit} size="lg">

Copy link
Contributor

@DVidal1205 DVidal1205 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, tysm

@DVidal1205 DVidal1205 added this pull request to the merge queue Feb 8, 2026
Merged via the queue into main with commit 109dfb6 Feb 8, 2026
8 checks passed
@DVidal1205 DVidal1205 deleted the form-response-edit branch February 8, 2026 03:40
alexanderpaolini added a commit that referenced this pull request Feb 8, 2026
commit 109dfb6
Author: Aiden Letourneau <63489431+aidenletourneau@users.noreply.github.com>
Date:   Sat Feb 7 22:40:21 2026 -0500

    [#342] Allow editing responses in Blade forms (#315)
This was referenced Feb 27, 2026
This was referenced Mar 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API Change modifies code in the global API/tRPC package Blade Change modifies code in Blade app Major Big change - 2+ reviewers required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow editing responses in Blade forms

3 participants