Skip to content

Commit ee3d7b2

Browse files
committed
fix(propose): move ProposalDetailsHeader out of nested FormProvider + resolver typing
ProposalDetailsHeader was rendering inside TemplateDetailsForm's nested FormProvider, so its useFormContext resolved to the template form (no title/bannerImage fields) and silently broke hydration. Moved the header above the nested provider so it reads the parent ProposalFormValues. Also added a runtime-safe cast on zodResolver input so the slug-indexed template validator satisfies the form's typed resolver. Reformatted touched files.
1 parent cebb234 commit ee3d7b2

6 files changed

Lines changed: 155 additions & 153 deletions

File tree

src/components/proposals/ProposalDetailsForm.tsx

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ import { Label } from "@/components/ui/label";
1010
import { Textarea } from "@/components/ui/textarea";
1111
import { getTemplateSchema } from "@/lib/proposal-template-schemas";
1212
import { ProposalDetailsHeader } from "./ProposalDetailsHeader";
13-
import {
14-
TemplateDetailsForm,
15-
type TemplateDetailsFormHandle,
16-
} from "./TemplateDetailsForm";
1713
import type { ProposalFormValues } from "./schema";
14+
import { TemplateDetailsForm, type TemplateDetailsFormHandle } from "./TemplateDetailsForm";
1815

1916
export interface ProposalDetailsFormHandle {
2017
/** Validate the current details step. Branches on whether a template is active. */
@@ -25,42 +22,41 @@ export interface ProposalDetailsFormProps {
2522
templateSlug?: string | null;
2623
}
2724

28-
export const ProposalDetailsForm = forwardRef<
29-
ProposalDetailsFormHandle,
30-
ProposalDetailsFormProps
31-
>(function ProposalDetailsForm({ templateSlug }, ref) {
32-
const { trigger } = useFormContext<ProposalFormValues>();
33-
const templateHandleRef = useRef<TemplateDetailsFormHandle | null>(null);
34-
const hasTemplate = Boolean(templateSlug && getTemplateSchema(templateSlug));
25+
export const ProposalDetailsForm = forwardRef<ProposalDetailsFormHandle, ProposalDetailsFormProps>(
26+
function ProposalDetailsForm({ templateSlug }, ref) {
27+
const { trigger } = useFormContext<ProposalFormValues>();
28+
const templateHandleRef = useRef<TemplateDetailsFormHandle | null>(null);
29+
const hasTemplate = Boolean(templateSlug && getTemplateSchema(templateSlug));
3530

36-
useImperativeHandle(
37-
ref,
38-
() => ({
39-
validate: async () => {
40-
if (hasTemplate) {
41-
const templateOk = await templateHandleRef.current?.validate();
42-
if (!templateOk) return false;
43-
return trigger(["title"]);
44-
}
45-
return trigger(["title", "description"]);
46-
},
47-
}),
48-
[hasTemplate, trigger],
49-
);
50-
51-
if (hasTemplate && templateSlug) {
52-
return (
53-
<TemplateDetailsForm
54-
ref={(handle) => {
55-
templateHandleRef.current = handle;
56-
}}
57-
slug={templateSlug}
58-
/>
31+
useImperativeHandle(
32+
ref,
33+
() => ({
34+
validate: async () => {
35+
if (hasTemplate) {
36+
const templateOk = await templateHandleRef.current?.validate();
37+
if (!templateOk) return false;
38+
return trigger(["title"]);
39+
}
40+
return trigger(["title", "description"]);
41+
},
42+
}),
43+
[hasTemplate, trigger],
5944
);
60-
}
6145

62-
return <MarkdownDetailsForm />;
63-
});
46+
if (hasTemplate && templateSlug) {
47+
return (
48+
<TemplateDetailsForm
49+
ref={(handle) => {
50+
templateHandleRef.current = handle;
51+
}}
52+
slug={templateSlug}
53+
/>
54+
);
55+
}
56+
57+
return <MarkdownDetailsForm />;
58+
},
59+
);
6460

6561
function MarkdownDetailsForm() {
6662
const {

src/components/proposals/ProposalWizard.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
"use client";
2-
/* eslint-disable react-hooks/incompatible-library -- react-hook-form watch()/useFormContext pattern is known-incompatible with React Compiler */
32

3+
/* eslint-disable react-hooks/incompatible-library -- react-hook-form watch()/useFormContext pattern is known-incompatible with React Compiler */
44
import { useEffect, useRef, useState } from "react";
55
import { useSearchParams } from "next/navigation";
66
import { zodResolver } from "@hookform/resolvers/zod";
77
import { Loader2 } from "lucide-react";
88
import { FormProvider, useForm } from "react-hook-form";
9-
import { useUserAddress } from "@/hooks/use-user-address";
109
import { TransactionBuilder } from "@/components/proposals/builder/TransactionBuilder";
1110
import {
1211
ProposalDetailsForm,
1312
type ProposalDetailsFormHandle,
1413
} from "@/components/proposals/ProposalDetailsForm";
14+
import { ProposalEligibilityProvider } from "@/components/proposals/ProposalEligibilityContext";
1515
import { ProposalGatingBanner } from "@/components/proposals/ProposalGatingBanner";
1616
import { ProposalPreview } from "@/components/proposals/ProposalPreview";
1717
import { Button } from "@/components/ui/button";
1818
import { Card, CardContent } from "@/components/ui/card";
1919
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
20-
import { ProposalEligibilityProvider } from "@/components/proposals/ProposalEligibilityContext";
20+
import { useUserAddress } from "@/hooks/use-user-address";
2121
import { useProposalEligibility } from "@/hooks/useProposalEligibility";
2222
import { CHAIN, DAO_ADDRESSES } from "@/lib/config";
2323
import { getProposalTemplate } from "@/lib/proposal-templates";
@@ -70,9 +70,7 @@ export function ProposalWizard() {
7070

7171
const handleNextToTransactions = async () => {
7272
const handle = detailsFormRef.current;
73-
const isValid = handle
74-
? await handle.validate()
75-
: await trigger(["title", "description"]);
73+
const isValid = handle ? await handle.validate() : await trigger(["title", "description"]);
7674
if (isValid) {
7775
setCurrentTab("transactions");
7876
}

src/components/proposals/TemplateDetailsForm.tsx

Lines changed: 93 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { forwardRef, useEffect, useImperativeHandle } from "react";
44
import { zodResolver } from "@hookform/resolvers/zod";
5-
import { FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
5+
import { FormProvider, useForm, useFormContext, useWatch, type Resolver } from "react-hook-form";
66
import { Label } from "@/components/ui/label";
77
import { Textarea } from "@/components/ui/textarea";
88
import {
@@ -25,50 +25,50 @@ export interface TemplateDetailsFormProps {
2525
slug: string;
2626
}
2727

28-
export const TemplateDetailsForm = forwardRef<
29-
TemplateDetailsFormHandle,
30-
TemplateDetailsFormProps
31-
>(function TemplateDetailsForm({ slug }, ref) {
32-
const schema = getTemplateSchema(slug);
33-
const parent = useFormContext<ProposalFormValues>();
28+
export const TemplateDetailsForm = forwardRef<TemplateDetailsFormHandle, TemplateDetailsFormProps>(
29+
function TemplateDetailsForm({ slug }, ref) {
30+
const schema = getTemplateSchema(slug);
31+
const parent = useFormContext<ProposalFormValues>();
3432

35-
const initialValues = (parent.getValues("templateFields") as TemplateValues | undefined) ?? {};
36-
const templateForm = useForm<TemplateValues>({
37-
resolver: zodResolver(buildTemplateValidator(slug)),
38-
defaultValues: buildDefaults(slug, initialValues),
39-
mode: "onChange",
40-
});
33+
const initialValues = (parent.getValues("templateFields") as TemplateValues | undefined) ?? {};
34+
// zodResolver's type expects a schema with specific input generics; our builder
35+
// is slug-indexed so we bridge via a runtime-safe cast.
36+
const validator = buildTemplateValidator(slug) as unknown as Parameters<typeof zodResolver>[0];
37+
const templateForm = useForm<TemplateValues>({
38+
resolver: zodResolver(validator) as unknown as Resolver<TemplateValues>,
39+
defaultValues: buildDefaults(slug, initialValues),
40+
mode: "onChange",
41+
});
4142

42-
const watchedValues = useWatch({ control: templateForm.control }) as TemplateValues;
43+
const watchedValues = useWatch({ control: templateForm.control }) as TemplateValues;
4344

44-
useEffect(() => {
45-
if (!schema) return;
46-
parent.setValue("templateFields", watchedValues, { shouldDirty: true });
47-
const compiled = compileTemplate(slug, watchedValues);
48-
parent.setValue("description", compiled, { shouldDirty: true, shouldValidate: false });
49-
}, [watchedValues, slug, schema, parent]);
45+
useEffect(() => {
46+
if (!schema) return;
47+
parent.setValue("templateFields", watchedValues, { shouldDirty: true });
48+
const compiled = compileTemplate(slug, watchedValues);
49+
parent.setValue("description", compiled, { shouldDirty: true, shouldValidate: false });
50+
}, [watchedValues, slug, schema, parent]);
5051

51-
useImperativeHandle(
52-
ref,
53-
() => ({
54-
validate: async () => {
55-
const ok = await templateForm.trigger();
56-
if (!ok) {
57-
const firstError = Object.keys(templateForm.formState.errors)[0];
58-
if (firstError) {
59-
templateForm.setFocus(firstError as keyof TemplateValues);
52+
useImperativeHandle(
53+
ref,
54+
() => ({
55+
validate: async () => {
56+
const ok = await templateForm.trigger();
57+
if (!ok) {
58+
const firstError = Object.keys(templateForm.formState.errors)[0];
59+
if (firstError) {
60+
templateForm.setFocus(firstError as keyof TemplateValues);
61+
}
6062
}
61-
}
62-
return ok;
63-
},
64-
}),
65-
[templateForm],
66-
);
63+
return ok;
64+
},
65+
}),
66+
[templateForm],
67+
);
6768

68-
if (!schema) return null;
69+
if (!schema) return null;
6970

70-
return (
71-
<FormProvider {...templateForm}>
71+
return (
7272
<div className="space-y-6">
7373
<div>
7474
<h2 className="text-2xl font-bold mb-1">{schema.title}</h2>
@@ -77,61 +77,64 @@ export const TemplateDetailsForm = forwardRef<
7777
</p>
7878
</div>
7979

80+
{/* Header uses the parent form (title + banner live on ProposalFormValues). */}
8081
<ProposalDetailsHeader />
8182

82-
<div className="space-y-5">
83-
{schema.fields.map((field) => {
84-
const error = templateForm.formState.errors[field.id] as
85-
| { message?: string }
86-
| undefined;
87-
return (
88-
<div key={field.id}>
89-
<Label htmlFor={field.id}>
90-
{field.label}
91-
{field.required ? " *" : ""}
92-
</Label>
93-
<p className="text-xs text-muted-foreground mt-1 mb-2">{field.helper}</p>
94-
{field.type === "textarea" ? (
95-
<Textarea
96-
id={field.id}
97-
rows={field.rows ?? 4}
98-
className="resize-y"
99-
{...templateForm.register(field.id)}
100-
/>
101-
) : (
102-
<BudgetRepeater
103-
name={field.id}
104-
topLevelError={error?.message}
105-
getRowError={(index) => {
106-
const arrErrors = templateForm.formState.errors[field.id] as
107-
| Record<
108-
number,
109-
{
110-
label?: { message?: string };
111-
amount?: { message?: string };
112-
}
113-
>
114-
| undefined;
115-
const rowErr = arrErrors?.[index];
116-
if (!rowErr) return undefined;
117-
return {
118-
label: rowErr.label?.message,
119-
amount: rowErr.amount?.message,
120-
};
121-
}}
122-
/>
123-
)}
124-
{field.type === "textarea" && error?.message ? (
125-
<p className="text-xs text-red-500 mt-1">{error.message}</p>
126-
) : null}
127-
</div>
128-
);
129-
})}
130-
</div>
83+
<FormProvider {...templateForm}>
84+
<div className="space-y-5">
85+
{schema.fields.map((field) => {
86+
const error = templateForm.formState.errors[field.id] as
87+
| { message?: string }
88+
| undefined;
89+
return (
90+
<div key={field.id}>
91+
<Label htmlFor={field.id}>
92+
{field.label}
93+
{field.required ? " *" : ""}
94+
</Label>
95+
<p className="text-xs text-muted-foreground mt-1 mb-2">{field.helper}</p>
96+
{field.type === "textarea" ? (
97+
<Textarea
98+
id={field.id}
99+
rows={field.rows ?? 4}
100+
className="resize-y"
101+
{...templateForm.register(field.id)}
102+
/>
103+
) : (
104+
<BudgetRepeater
105+
name={field.id}
106+
topLevelError={error?.message}
107+
getRowError={(index) => {
108+
const arrErrors = templateForm.formState.errors[field.id] as
109+
| Record<
110+
number,
111+
{
112+
label?: { message?: string };
113+
amount?: { message?: string };
114+
}
115+
>
116+
| undefined;
117+
const rowErr = arrErrors?.[index];
118+
if (!rowErr) return undefined;
119+
return {
120+
label: rowErr.label?.message,
121+
amount: rowErr.amount?.message,
122+
};
123+
}}
124+
/>
125+
)}
126+
{field.type === "textarea" && error?.message ? (
127+
<p className="text-xs text-red-500 mt-1">{error.message}</p>
128+
) : null}
129+
</div>
130+
);
131+
})}
132+
</div>
133+
</FormProvider>
131134
</div>
132-
</FormProvider>
133-
);
134-
});
135+
);
136+
},
137+
);
135138

136139
function buildDefaults(slug: string, existing: TemplateValues): TemplateValues {
137140
const schema = getTemplateSchema(slug);

src/components/proposals/schema.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ const addressSchema = z.string().refine((val) => isAddress(val), {
77
});
88

99
// Optional address that can be empty
10-
const optionalAddressSchema = z.string().optional().refine(
11-
(val) => !val || isAddress(val),
12-
{ message: "Invalid Ethereum address" }
13-
);
10+
const optionalAddressSchema = z
11+
.string()
12+
.optional()
13+
.refine((val) => !val || isAddress(val), { message: "Invalid Ethereum address" });
1414

1515
// Reusable numeric string validation
1616
const numericString = <T extends z.ZodType<string | undefined>>(schema: T) => {
@@ -140,10 +140,14 @@ const droposalTransactionSchema = baseTransactionSchema.extend({
140140
coverType: z.string().optional(),
141141
// Revenue split configuration (optional)
142142
useSplit: z.boolean().optional(),
143-
splitRecipients: z.array(z.object({
144-
address: z.string(),
145-
percentAllocation: z.number(),
146-
})).optional(),
143+
splitRecipients: z
144+
.array(
145+
z.object({
146+
address: z.string(),
147+
percentAllocation: z.number(),
148+
}),
149+
)
150+
.optional(),
147151
splitDistributorFee: z.number().optional(),
148152
createdSplitAddress: z.string().optional(), // Stores the split address after creation
149153
});
@@ -161,9 +165,7 @@ const buyCoinTransactionSchema = baseTransactionSchema.extend({
161165
type: z.literal("buy-coin"),
162166
coinAddress: addressSchema,
163167
ethAmount: positiveNumericString(z.string().min(1, "ETH amount is required")),
164-
slippage: nonNegativeNumericString(
165-
z.string().min(1, "Slippage is required"),
166-
).refine(
168+
slippage: nonNegativeNumericString(z.string().min(1, "Slippage is required")).refine(
167169
(val) => {
168170
if (!val) return true;
169171
return parseFloat(val) <= 100;

src/lib/proposal-template-schemas.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,13 @@ describe("TEMPLATE_SCHEMAS", () => {
9090
});
9191

9292
it("funding templates have a budget field (droposal uses revenue split instead)", () => {
93-
const withBudget = ["athlete-sponsorship", "event-activation", "physical-installation", "content-media", "development"] as const;
93+
const withBudget = [
94+
"athlete-sponsorship",
95+
"event-activation",
96+
"physical-installation",
97+
"content-media",
98+
"development",
99+
] as const;
94100
for (const slug of withBudget) {
95101
const schema = TEMPLATE_SCHEMAS[slug];
96102
expect(schema.fields.some((f) => f.type === "budget")).toBe(true);

0 commit comments

Comments
 (0)