Skip to content
45 changes: 43 additions & 2 deletions apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { L1_CHAINS, PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains'
import {
CreateProposalHeading,
MobileProposalActionBar,
PROPOSAL_SUMMARY_REQUIRED_ERROR,
PROPOSAL_TITLE_FORMAT_ERROR,
PROPOSAL_TITLE_MAX_ERROR,
PROPOSAL_TITLE_MAX_LENGTH,
PROPOSAL_TITLE_REGEX,
PROPOSAL_TITLE_REQUIRED_ERROR,
ProposalDraftForm,
ProposalStageIndicator,
Queue,
Expand Down Expand Up @@ -180,17 +186,50 @@ const CreateProposalPage: NextPageWithLayout = () => {
const missingDraftRequirements = useMemo(() => {
const requirements: string[] = []

if (!title?.trim()) {
const normalizedTitle = title?.trim() || ''
const normalizedSummary = summary?.trim() || ''

if (!normalizedTitle) {
requirements.push('add a proposal title')
} else if (!PROPOSAL_TITLE_REGEX.test(normalizedTitle)) {
requirements.push('fix the proposal title format')
} else if (normalizedTitle.length > PROPOSAL_TITLE_MAX_LENGTH) {
requirements.push('shorten the proposal title')
}

if (!summary?.trim()) {
if (!normalizedSummary) {
requirements.push('add a proposal summary')
}

return requirements
}, [title, summary])

const titleError = useMemo(() => {
const normalizedTitle = title?.trim() || ''

if (!normalizedTitle) {
return PROPOSAL_TITLE_REQUIRED_ERROR
}

if (!PROPOSAL_TITLE_REGEX.test(normalizedTitle)) {
return PROPOSAL_TITLE_FORMAT_ERROR
}

if (normalizedTitle.length > PROPOSAL_TITLE_MAX_LENGTH) {
return PROPOSAL_TITLE_MAX_ERROR
}

return undefined
}, [title])

const summaryError = useMemo(() => {
const normalizedSummary = summary?.trim() || ''
if (!normalizedSummary) {
return PROPOSAL_SUMMARY_REQUIRED_ERROR
}
return undefined
}, [summary])

const missingReviewRequirements = useMemo(() => {
const requirements = [...missingDraftRequirements]

Expand Down Expand Up @@ -406,6 +445,8 @@ const CreateProposalPage: NextPageWithLayout = () => {
summary={summary || ''}
onTitleChange={setTitle}
onSummaryChange={setSummary}
titleError={titleError}
summaryError={summaryError}
/>
</Stack>
) : (
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ const ReviewProposalPage: NextPageWithLayout = () => {
})

const { transactions, disabled, title, summary, clearProposal } = useProposalStore()
const [proposalHydrated, setProposalHydrated] = useState(false)

useEffect(() => {
if (useProposalStore.persist.hasHydrated()) {
setProposalHydrated(true)
return
}

const unsubscribe = useProposalStore.persist.onFinishHydration(() => {
setProposalHydrated(true)
})

return unsubscribe
}, [])

const onOpenCreatePage = useCallback(async () => {
await push({
Expand Down Expand Up @@ -145,6 +159,7 @@ const ReviewProposalPage: NextPageWithLayout = () => {
}, [handleCloseSuccessModal, proposalIdCreated])

useEffect(() => {
if (!proposalHydrated) return
if (proposalIdCreated !== undefined) return
if (transactions.length > 0) return
if (title?.trim() || summary?.trim()) return
Expand All @@ -158,6 +173,7 @@ const ReviewProposalPage: NextPageWithLayout = () => {
},
})
}, [
proposalHydrated,
proposalIdCreated,
transactions.length,
title,
Expand Down
1 change: 1 addition & 0 deletions packages/constants/src/swrKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const SWR_KEYS = {
SABLIER_LOCKUP_ADDRESS: 'sablier-lockup-address',
SABLIER_STREAM_IDS: 'sablier-stream-ids',
SABLIER_LIVE_STREAMS: 'sablier-live-streams',
SABLIER_AIRDROP_CAMPAIGNS: 'sablier-airdrop-campaigns',
CLANKER_TOKENS: 'clanker-tokens',
CLANKER_TOKENS_FULL: 'clanker-tokens-full',
ZORA_COINS: 'zora-coins',
Expand Down
1 change: 1 addition & 0 deletions packages/create-proposal-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "0.2.2",
"dependencies": {
"@ethereum-attestation-service/eas-sdk": "^2.7.0",
"@openzeppelin/merkle-tree": "^1.0.8",
"@smartinvoicexyz/types": "^0.1.27",
"@vanilla-extract/css": "^1.17.2",
"@walletconnect/core": "2.11.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const ReviewProposalForm = ({
const [proposing, setProposing] = useState<boolean>(false)
const [skipSimulation, setSkipSimulation] = useState<boolean>(SKIP_SIMULATION)
const [isEditingMetadata, setIsEditingMetadata] = useState<boolean>(false)
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState<boolean>(false)

const { votes, hasThreshold, proposalVotesRequired, isLoading } = useVotes({
chainId: chain.id,
Expand Down Expand Up @@ -424,14 +425,48 @@ export const ReviewProposalForm = ({
</Flex>
)}

{hasAttemptedSubmit && Object.keys(formik.errors).length > 0 && (
<Stack mb={'x4'} gap={'x1'}>
{Object.entries(formik.errors).map(([key, value]) => (
<Text key={key} color={'negative'}>
- {String(value)}
</Text>
))}
</Stack>
)}

<ContractButton
chainId={chain.id}
mt={'x3'}
width={'100%'}
borderRadius={'curved'}
loading={simulating}
disabled={simulating || proposing}
handleClick={formik.handleSubmit}
disabled={
simulating ||
proposing ||
!formik.values.title?.trim() ||
!formik.values.summary?.trim() ||
!formik.values.transactions?.length
}
handleClick={async () => {
setHasAttemptedSubmit(true)
const errors = await formik.validateForm()

if (Object.keys(errors).length > 0) {
formik.setTouched(
{
title: true,
summary: true,
},
true
)
setError(undefined)
return
}

setError(undefined)
await formik.submitForm()
}}
h={'x15'}
display={{ '@initial': 'none', '@768': 'flex' }}
>
Expand Down Expand Up @@ -460,9 +495,32 @@ export const ReviewProposalForm = ({
onReset={onResetMobile}
showContinue
onContinue={() => {
void formik.handleSubmit()
setHasAttemptedSubmit(true)
void (async () => {
const errors = await formik.validateForm()
if (Object.keys(errors).length > 0) {
formik.setTouched(
{
title: true,
summary: true,
},
true
)
setError(undefined)
return
}

setError(undefined)
await formik.submitForm()
})()
}}
continueDisabled={simulating || proposing}
continueDisabled={
simulating ||
proposing ||
!formik.values.title?.trim() ||
!formik.values.summary?.trim() ||
!formik.values.transactions?.length
}
continueLoading={simulating}
continueLabel={'Submit Proposal'}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { BuilderTransaction } from '@buildeross/types'
import * as Yup from 'yup'

import {
PROPOSAL_SUMMARY_REQUIRED_ERROR,
PROPOSAL_TITLE_FORMAT_ERROR,
PROPOSAL_TITLE_MAX_ERROR,
PROPOSAL_TITLE_MAX_LENGTH,
PROPOSAL_TITLE_REGEX,
PROPOSAL_TITLE_REQUIRED_ERROR,
} from '../../constants'

export const ERROR_CODE: Record<string, string> = {
GENERIC: `Oops. Looks like there was a problem submitting this proposal, please try again..`,
WRONG_NETWORK: `Oops. Looks like you're on the wrong network. Please switch and try again.`,
Expand All @@ -16,9 +25,9 @@ export interface FormValues {

export const validationSchema = Yup.object().shape({
title: Yup.string()
.required('Proposal title is required')
.matches(/^[A-Za-z0-9 _.-]*[A-Za-z0-9][A-Za-z0-9 _.-]*$/, 'only numbers or letters')
.max(5000, '< 256 characters'),
summary: Yup.string().optional().required('Summary is required'),
.required(PROPOSAL_TITLE_REQUIRED_ERROR)
.matches(PROPOSAL_TITLE_REGEX, PROPOSAL_TITLE_FORMAT_ERROR)
.max(PROPOSAL_TITLE_MAX_LENGTH, PROPOSAL_TITLE_MAX_ERROR),
summary: Yup.string().optional().required(PROPOSAL_SUMMARY_REQUIRED_ERROR),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove contradictory .optional() from summary validation.

Chaining .optional().required() is contradictory—.required() overrides .optional(), making the .optional() call redundant and the intent unclear. Since the error constant indicates summary is required, remove .optional().

Proposed fix
-  summary: Yup.string().optional().required(PROPOSAL_SUMMARY_REQUIRED_ERROR),
+  summary: Yup.string().required(PROPOSAL_SUMMARY_REQUIRED_ERROR),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
summary: Yup.string().optional().required(PROPOSAL_SUMMARY_REQUIRED_ERROR),
summary: Yup.string().required(PROPOSAL_SUMMARY_REQUIRED_ERROR),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-proposal-ui/src/components/ReviewProposalForm/fields.ts` at
line 31, The Yup validation for the summary field currently chains .optional()
and .required(), which is contradictory; update the schema by removing the
.optional() call on the summary field so it reads as
Yup.string().required(PROPOSAL_SUMMARY_REQUIRED_ERROR) (locate the "summary"
field in the schema definition in fields.ts).

transactions: Yup.array().min(1, 'Minimum one transaction required'),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { AddressType } from '@buildeross/types'
import {
addressValidationOptionalSchema,
addressValidationSchemaWithError,
} from '@buildeross/utils/yup'
import * as yup from 'yup'

import { TokenMetadataFormValidated, TokenMetadataSchema } from '../../shared'

export interface AirdropRecipientFormValues {
recipientAddress: string | AddressType
amount: string
}

export type AirdropType = 'instant' | 'll'

export interface AirdropTokensValues {
airdropType: AirdropType
campaignName: string
adminAddress: string | AddressType
campaignStartDate: string
expirationDate: string
vestingStartDate?: string
totalDurationDays?: number
cliffDurationDays?: number
cancelable: boolean
transferable: boolean
tokenAddress?: AddressType
tokenMetadata?: TokenMetadataFormValidated
recipients: AirdropRecipientFormValues[]
}

const DECIMAL_REGEX = /^(\d+\.?\d*|\.\d+)$/

const recipientSchema = yup.object({
recipientAddress: addressValidationSchemaWithError(
'Recipient address is invalid.',
'Recipient address is required.'
),
amount: yup
.string()
.required('Amount is required.')
.test(
'is-valid-decimal',
'Amount must be a valid decimal number (no scientific notation)',
(value) => {
if (!value) return false
return DECIMAL_REGEX.test(value)
}
)
.test('is-greater-than-0', 'Amount must be greater than 0', (value) => {
if (!value) return false
const num = parseFloat(value)
return !isNaN(num) && num > 0
}),
})

const airdropTokensSchema = () =>
yup.object({
airdropType: yup
.string()
.oneOf(['instant', 'll'])
.required('Airdrop type is required.'),
campaignName: yup
.string()
.trim()
.min(3, 'Campaign name must be at least 3 characters.')
.max(64, 'Campaign name must be 64 characters or fewer.')
.required('Campaign name is required.'),
adminAddress: addressValidationSchemaWithError(
'Admin address is invalid.',
'Admin address is required.'
),
campaignStartDate: yup.string().required('Campaign start date is required.'),
expirationDate: yup
.string()
.required('Campaign expiration date is required.')
.test(
'is-after-campaign-start',
'Expiration must be after campaign start.',
function (v) {
const campaignStartDate = this.parent.campaignStartDate
if (!v || !campaignStartDate) return true
return new Date(v).getTime() > new Date(campaignStartDate).getTime()
}
),
vestingStartDate: yup
.string()
.optional()
.when('airdropType', {
is: 'll',
then: (schema) =>
schema
.required('Vesting start date is required for LL airdrops.')
.test(
'is-on-or-after-campaign-start',
'Vesting start must be at or after campaign start.',
function (v) {
const campaignStartDate = this.parent.campaignStartDate
if (!v || !campaignStartDate) return true
return new Date(v).getTime() >= new Date(campaignStartDate).getTime()
}
),
otherwise: (schema) => schema.notRequired(),
}),
totalDurationDays: yup
.number()
.optional()
.when('airdropType', {
is: 'll',
then: (schema) =>
schema
.required('Total duration (days) is required for LL airdrops.')
.integer('Total duration must be a whole number.')
.positive('Total duration must be greater than 0.'),
otherwise: (schema) => schema.notRequired(),
}),
cliffDurationDays: yup
.number()
.optional()
.integer('Cliff duration must be a whole number.')
.min(0, 'Cliff duration cannot be negative.')
.when(['airdropType', 'totalDurationDays'], {
is: (airdropType: AirdropType, totalDurationDays: number | undefined) =>
airdropType === 'll' && typeof totalDurationDays === 'number',
then: (schema) =>
schema.max(
yup.ref('totalDurationDays'),
'Cliff duration cannot exceed total duration.'
),
}),
cancelable: yup.boolean().required('Cancelable is required.'),
transferable: yup.boolean().required('Transferable is required.'),
tokenAddress: addressValidationOptionalSchema,
tokenMetadata: TokenMetadataSchema.optional(),
recipients: yup
.array()
.of(recipientSchema)
.required('Recipients are required.')
.min(1, 'At least one recipient is required.'),
})

export default airdropTokensSchema
Loading
Loading