Skip to content

Feat/form handling with unified wrapper - #158

Merged
Benjtalkshow merged 3 commits into
boundlessfi:mainfrom
Ekene001:feat/Form-Handling-with-Unified-Wrapper
Mar 30, 2026
Merged

Feat/form handling with unified wrapper#158
Benjtalkshow merged 3 commits into
boundlessfi:mainfrom
Ekene001:feat/Form-Handling-with-Unified-Wrapper

Conversation

@Ekene001

@Ekene001 Ekene001 commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

closes #128

This PR standardizes form handling across the bounty module by introducing a reusable FormFieldWrapper component built with react-hook-form and zod.

It eliminates duplicated form logic, unifies validation and error handling, and ensures a consistent UI pattern for all form fields.

What Was Implemented

🔹 Reusable Form Wrapper

  • Added components/ui/form-field-wrapper.tsx
  • Built on top of react-hook-form (Controller)
  • Compatible with zodResolver

Supports:

  • name, control, label, description
  • render prop and children (including function children)

Standardized UI structure:

Label

  • Input control
  • Description/help text
  • Validation error message

🔹 Refactored Forms

  1. milestone-builder.tsx
  • Replaced manual field composition with FormFieldWrapper
  • Removed duplicated UI and validation handling
  • Preserved existing logic (e.g., percentage parsing and progress behavior)
  1. application-dialog.tsx
  • Migrated to react-hook-form + zod + zodResolver
  • Removed local state-based form handling
  • Implemented schema-driven validation
  • Standardized all fields using FormFieldWrapper
  • Ensured proper form reset on dialog close and successful submission
  • Normalized optional portfolioUrl to undefined when empty

🎯 Goals Achieved

  • ✅ Standardized validation using Zod
  • ✅ Unified form UI (labels, errors, descriptions)
  • ✅ Reduced duplicated form logic
  • ✅ Improved developer experience and maintainability

🧪 Testing

  • Manually tested all updated forms with:
  • Valid and invalid inputs
  • Required field validation
  • Edge cases (empty optional fields, invalid URLs)
  • Verified consistent error display across all inputs
  • Confirmed no regression in form behavior or UX
image image

Summary by CodeRabbit

  • New Features

    • Application form now enforces cover letter minimum length and validates optional portfolio URL; trims inputs and resets on successful submit or when dialog closes.
    • Milestone inputs improved for more reliable add/edit behavior and clearer labels.
  • Bug Fixes

    • Submission errors are displayed in the dialog footer when apply fails.

@drips-wave

drips-wave Bot commented Mar 27, 2026

Copy link
Copy Markdown

@Ekene001 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Mar 27, 2026

Copy link
Copy Markdown

@Ekene001 is attempting to deploy a commit to the Threadflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Mar 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduced a reusable FormFieldWrapper and refactored two bounty forms to use react-hook-form with Zod validation: application-dialog.tsx now uses a validated form schema and controlled submit flow; milestone-builder.tsx became a generic, type-safe field-array helper using the wrapper.

Changes

Cohort / File(s) Summary
New Form Field Wrapper
components/ui/form-field-wrapper.tsx
Adds FormFieldWrapper component and FormFieldWrapperProps types to standardize react-hook-form field rendering (label, control, description, error) and support render prop or children.
Application dialog refactor
components/bounty/application-dialog.tsx
Replaced local state and raw form submit with react-hook-form + Zod (applicationFormSchema); added field validations (trimmed coverLetter >=10 chars, optional trimmed portfolioUrl validated as URL), uses Form + FormFieldWrapper, handles form.reset on dialog close and sets root error on apply failure; updated prop typing (trigger: ReactNode).
Milestone builder refactor
components/bounty/forms/milestone-builder.tsx
Made MilestoneBuilder generic (TFieldValues, TName) for type-safe field arrays, switched internal fields to FormFieldWrapper, adjusted input value handling (value={field.value ?? ""}) and typing/casts for name and append usage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through fields both wide and narrow,
Wrapped labels, errors, inputs in a neat little barrow,
Zod trimmed my words, hooks kept them true,
Milestones counted, applications flew—hooroo! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/form handling with unified wrapper' accurately summarizes the main change: introducing a unified form handling approach with a reusable wrapper component.
Linked Issues check ✅ Passed The PR successfully addresses all coding requirements from issue #128: created FormFieldWrapper component with react-hook-form and Zod integration, refactored both milestone-builder.tsx and application-dialog.tsx to use the wrapper, implemented consistent validation via zodResolver, and removed duplicated form logic.
Out of Scope Changes check ✅ Passed All changes in the PR are directly scoped to the linked issue #128. The three modified/created files (form-field-wrapper.tsx, milestone-builder.tsx, application-dialog.tsx) are explicitly targeted by the issue requirements.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
components/bounty/application-dialog.tsx (2)

76-76: Redundant trim - schema already trims the value.In Zod 4, .trim() is implemented using .overwrite(), which means it transforms the value during parsing. The schema already trims portfolioUrl via .trim(), making the manual .trim() call in handleSubmit redundant.

✨ Remove redundant trim
 const handleSubmit = async (values: ApplicationFormValues) => {
   setLoading(true);

   try {
-    const portfolioUrl = values.portfolioUrl.trim();
+    const portfolioUrl = values.portfolioUrl;
     const success = await onApply({
       coverLetter: values.coverLetter,
       portfolioUrl: portfolioUrl.length > 0 ? portfolioUrl : undefined,
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/application-dialog.tsx` at line 76, The manual call const
portfolioUrl = values.portfolioUrl.trim(); is redundant because the Zod schema
for portfolioUrl already performs trimming during parse; remove this extra
.trim() in the handleSubmit flow and use values.portfolioUrl (or the parsed
value) directly so you don't double-transform the input (locate the code in the
handleSubmit handler in application-dialog.tsx where portfolioUrl is declared
and remove the .trim()).

27-35: URL validation pattern is functional but slightly verbose.

The current approach works correctly. An alternative using Zod's union could be more declarative, though the refine approach is equally valid.

✨ Alternative: Use union for optional URL
-portfolioUrl: z
-  .string()
-  .trim()
-  .refine(
-    (value) =>
-      value.length === 0 || z.string().url().safeParse(value).success,
-    "Please enter a valid URL",
-  ),
+portfolioUrl: z.union([
+  z.literal(""),
+  z.string().trim().url("Please enter a valid URL"),
+]),

Note: With this approach, you'd want to ensure trim happens before the union check, which may require adjusting the order.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/application-dialog.tsx` around lines 27 - 35, The
portfolioUrl schema currently uses .string().trim().refine(...) which works but
is verbose; change it to apply .trim() first and then validate using a Zod union
of a URL string and an empty-string literal (e.g., z.union([z.string().url(),
z.literal('')])) so the schema is more declarative—update the portfolioUrl
definition to call .trim() before the union and replace the .refine(...) call
with the union-based validation.
components/bounty/forms/milestone-builder.tsx (2)

140-151: Consider explicit string conversion for the percentage input value.

The field.value is a number, but the Input component expects a string. While React handles this coercion implicitly, making it explicit improves clarity.

✨ Make the number-to-string conversion explicit
 <Input
   {...field}
-  value={field.value ?? ""}
+  value={field.value != null ? String(field.value) : ""}
   type="number"
   min={1}
   max={100}
   placeholder="25"
   onChange={(e) => {
     const parsed = parseInt(e.target.value);
     field.onChange(Number.isNaN(parsed) ? undefined : parsed);
   }}
 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/forms/milestone-builder.tsx` around lines 140 - 151, The
percentage Input uses a numeric field.value but passes it directly to the Input
which expects a string; explicitly convert the value to a string and mirror the
onChange parsing logic: in the JSX for the Input (component name Input) change
value={field.value ?? ""} to value={field.value !== undefined ?
String(field.value) : ""} and keep the onChange handler (which parses with
parseInt and calls field.onChange) unchanged so the component consistently
receives strings while form state remains numeric.

56-61: Type cast as never bypasses type safety.

The as never cast is a common workaround for useFieldArray's append typing limitations, but it silently accepts any object shape. If MilestoneDraft fields change, this won't produce a compile error.

♻️ Consider using FieldArray type assertion for better safety

You can create a type-safe helper or use FieldArrayWithId to maintain some type checking:

+import type { FieldArray } from "react-hook-form";
+
+// Helper type for the milestone array element
+type MilestoneFieldValue = FieldArray<TFieldValues, TName>;

 const handleAddMilestone = () => {
   const defaultPercentage = Math.max(0, Math.min(remainingPercentage, 25));
-  append({
-    title: "",
-    description: "",
-    percentage: defaultPercentage,
-  } as never);
+  // Type assertion with explicit shape matching MilestoneDraft
+  const newMilestone: MilestoneDraft = {
+    title: "",
+    description: "",
+    percentage: defaultPercentage,
+  };
+  append(newMilestone as FieldArray<TFieldValues, TName>);
 };

This keeps the assertion but makes the intended type explicit, so changes to MilestoneDraft will flag mismatches at the newMilestone assignment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/forms/milestone-builder.tsx` around lines 56 - 61, The
append call currently uses an unsafe "as never" cast; instead construct a
properly typed MilestoneDraft object (e.g., const newMilestone: MilestoneDraft =
{ title: "", description: "", percentage: defaultPercentage }) and pass that to
append without using as never, or create a small helper (e.g.,
appendMilestone(newMilestone: MilestoneDraft)) that calls append; if your
useFieldArray types require the field-id shape, cast explicitly to
FieldArrayWithId<MilestoneDraft, 'id'> at the call site so the intended
MilestoneDraft shape is enforced (references: append, MilestoneDraft,
defaultPercentage, useFieldArray, FieldArrayWithId).
components/ui/form-field-wrapper.tsx (1)

58-64: Consider handling the case when no input is provided.

If neither render, function children, nor static children is provided, renderedInput will be undefined, which may cause issues with FormControl expecting a valid React element.

🛡️ Optional: Add runtime validation or type enforcement

You could add a runtime warning in development or use TypeScript overloads to enforce that at least one of render or children is provided:

+// Add development-only warning
 const renderedInput = render
   ? render(controllerRenderProps)
   : typeof children === "function"
     ? (children as ControllerRenderFn<TFieldValues, TName>)(
         controllerRenderProps,
       )
     : children;
+
+if (process.env.NODE_ENV === "development" && renderedInput === undefined) {
+  console.warn(`FormFieldWrapper: No input provided for field "${name}"`);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/ui/form-field-wrapper.tsx` around lines 58 - 64, The current logic
that computes renderedInput (using render, function children, or static
children) can produce undefined if none are provided; update the
FormFieldWrapper component (the renderedInput assignment) to handle the
missing-input case by returning a safe fallback (e.g., null or a simple
placeholder element) or emitting a dev-only console warning and returning null
so FormControl always receives a valid React node; ensure you reference the
existing symbols render, children, controllerRenderProps and renderedInput when
adding the guard so downstream usage of renderedInput/FormControl never gets
undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/bounty/application-dialog.tsx`:
- Around line 86-90: The catch block around the onApply call only logs errors
and leaves the form open with no user feedback; update the catch to surface the
error to the user by either calling form.setError("root", { message: "Failed to
submit application" }) or triggering the app's toast/error notification util and
include the caught error message, and ensure setLoading(false) remains in
finally; locate the try/catch surrounding onApply in application-dialog.tsx and
replace the console.error-only handling with a user-facing error
set/notification while preserving existing loading state handling.

---

Nitpick comments:
In `@components/bounty/application-dialog.tsx`:
- Line 76: The manual call const portfolioUrl = values.portfolioUrl.trim(); is
redundant because the Zod schema for portfolioUrl already performs trimming
during parse; remove this extra .trim() in the handleSubmit flow and use
values.portfolioUrl (or the parsed value) directly so you don't double-transform
the input (locate the code in the handleSubmit handler in application-dialog.tsx
where portfolioUrl is declared and remove the .trim()).
- Around line 27-35: The portfolioUrl schema currently uses
.string().trim().refine(...) which works but is verbose; change it to apply
.trim() first and then validate using a Zod union of a URL string and an
empty-string literal (e.g., z.union([z.string().url(), z.literal('')])) so the
schema is more declarative—update the portfolioUrl definition to call .trim()
before the union and replace the .refine(...) call with the union-based
validation.

In `@components/bounty/forms/milestone-builder.tsx`:
- Around line 140-151: The percentage Input uses a numeric field.value but
passes it directly to the Input which expects a string; explicitly convert the
value to a string and mirror the onChange parsing logic: in the JSX for the
Input (component name Input) change value={field.value ?? ""} to
value={field.value !== undefined ? String(field.value) : ""} and keep the
onChange handler (which parses with parseInt and calls field.onChange) unchanged
so the component consistently receives strings while form state remains numeric.
- Around line 56-61: The append call currently uses an unsafe "as never" cast;
instead construct a properly typed MilestoneDraft object (e.g., const
newMilestone: MilestoneDraft = { title: "", description: "", percentage:
defaultPercentage }) and pass that to append without using as never, or create a
small helper (e.g., appendMilestone(newMilestone: MilestoneDraft)) that calls
append; if your useFieldArray types require the field-id shape, cast explicitly
to FieldArrayWithId<MilestoneDraft, 'id'> at the call site so the intended
MilestoneDraft shape is enforced (references: append, MilestoneDraft,
defaultPercentage, useFieldArray, FieldArrayWithId).

In `@components/ui/form-field-wrapper.tsx`:
- Around line 58-64: The current logic that computes renderedInput (using
render, function children, or static children) can produce undefined if none are
provided; update the FormFieldWrapper component (the renderedInput assignment)
to handle the missing-input case by returning a safe fallback (e.g., null or a
simple placeholder element) or emitting a dev-only console warning and returning
null so FormControl always receives a valid React node; ensure you reference the
existing symbols render, children, controllerRenderProps and renderedInput when
adding the guard so downstream usage of renderedInput/FormControl never gets
undefined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1f9b72cd-c788-457a-9494-937138e4481f

📥 Commits

Reviewing files that changed from the base of the PR and between 3aad250 and 22f8873.

📒 Files selected for processing (3)
  • components/bounty/application-dialog.tsx
  • components/bounty/forms/milestone-builder.tsx
  • components/ui/form-field-wrapper.tsx

Comment thread components/bounty/application-dialog.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
components/bounty/application-dialog.tsx (1)

77-77: Redundant .trim() call.

The Zod schema already applies .trim() as a transformation on portfolioUrl, so values.portfolioUrl is guaranteed to be trimmed when handleSubmit receives it.

🔧 Suggested simplification
-      const portfolioUrl = values.portfolioUrl.trim();
+      const portfolioUrl = values.portfolioUrl;
       const success = await onApply({
         coverLetter: values.coverLetter,
         portfolioUrl: portfolioUrl.length > 0 ? portfolioUrl : undefined,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/application-dialog.tsx` at line 77, The line const
portfolioUrl = values.portfolioUrl.trim(); is redundant because the Zod schema
already transforms portfolioUrl with .trim(), so remove the extra .trim() and
just use values.portfolioUrl directly (e.g., assign const portfolioUrl =
values.portfolioUrl or use values.portfolioUrl inline) in the handleSubmit logic
to avoid unnecessary string operations; locate this in the handleSubmit block
that references portfolioUrl/values.portfolioUrl and update any subsequent usage
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@components/bounty/application-dialog.tsx`:
- Line 77: The line const portfolioUrl = values.portfolioUrl.trim(); is
redundant because the Zod schema already transforms portfolioUrl with .trim(),
so remove the extra .trim() and just use values.portfolioUrl directly (e.g.,
assign const portfolioUrl = values.portfolioUrl or use values.portfolioUrl
inline) in the handleSubmit logic to avoid unnecessary string operations; locate
this in the handleSubmit block that references portfolioUrl/values.portfolioUrl
and update any subsequent usage accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dd14019d-bdc9-4cb2-9d1f-071353596dec

📥 Commits

Reviewing files that changed from the base of the PR and between 22f8873 and b89dccb.

📒 Files selected for processing (1)
  • components/bounty/application-dialog.tsx

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@Benjtalkshow
Benjtalkshow merged commit bdc37bb into boundlessfi:main Mar 30, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Standardize Form Handling with Unified Wrapper

2 participants