Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display threshold on ProposalForm #478

Merged
merged 9 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 80 additions & 10 deletions apps/web/components/Forms/FormAddressInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeEvent, useCallback, useEffect } from "react";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { blo } from "blo";
import { uniqueId } from "lodash-es";
import { Address, isAddress } from "viem";
Expand All @@ -7,20 +7,24 @@ import { InfoWrapper } from "../InfoWrapper";
import { useDebounce } from "@/hooks/useDebounce";
import { isENS } from "@/utils/web3";

/**
* Address input with ENS name resolution
*/
type ValidationStatus = {
isValid: boolean;
message?: string;
};

type Props = {
label?: string;
errors?: any;
required?: boolean;
placeholder?: string;
readOnly?: boolean;
registerKey?: string;
disabled?: boolean;
className?: string;
value?: string;
tooltip?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
onValidationChange?: (status: ValidationStatus) => void;
};

export const FormAddressInput = ({
Expand All @@ -29,20 +33,26 @@ export const FormAddressInput = ({
required = false,
placeholder = "0x",
readOnly,
registerKey,
disabled,
className,
value = undefined,
tooltip,
onChange,
onValidationChange,
}: Props) => {
const id = uniqueId("address-input-");
const debouncedValue = useDebounce(value, 500);
const [validationStatus, setValidationStatus] = useState<ValidationStatus>({
isValid: false,
});
const [shouldValidate, setShouldValidate] = useState(false);

const debouncedOrValue = isAddress(value ?? "") ? value : debouncedValue;
const isDebouncedValueLive = debouncedOrValue === value;

const settledValue = isDebouncedValueLive ? debouncedOrValue : undefined;

const { data: ensAddress } = useEnsAddress({
const { data: ensAddress, isError: ensError } = useEnsAddress({
name: settledValue,
enabled: isENS(debouncedOrValue),
chainId: 1,
Expand All @@ -63,6 +73,51 @@ export const FormAddressInput = ({
cacheTime: 30_000,
});

const validateAddress = useCallback(() => {
if (!value) {
setValidationStatus({
isValid: !required,
message: required ? "Address is required" : undefined,
});
return;
}

if (isAddress(value)) {
setValidationStatus({ isValid: true });
} else if (isENS(value)) {
if (ensError) {
setValidationStatus({
isValid: false,
message: "Invalid ENS name",
});
} else if (ensAddress) {
setValidationStatus({ isValid: true });
} else {
setValidationStatus({
isValid: false,
message: "Resolving ENS name...",
});
}
} else {
setValidationStatus({
isValid: false,
message: "Invalid Ethereum address or ENS name",
});
}
}, [value, ensAddress, ensError, required]);

// Only validate when shouldValidate is true (after blur)
useEffect(() => {
if (shouldValidate) {
validateAddress();
}
}, [shouldValidate, validateAddress]);

// Notify parent component of validation changes
useEffect(() => {
onValidationChange?.(validationStatus);
}, [validationStatus, onValidationChange]);

useEffect(() => {
if (!ensAddress) return;
const ev = {
Expand All @@ -81,14 +136,20 @@ export const FormAddressInput = ({
[onChange],
);

const handleBlur = useCallback(() => {
setShouldValidate(true);
}, []);

let modifier = "";
if (Object.keys(errors).length > 0) {
if (Object.keys(errors).find((err) => err === registerKey)) {
modifier = "border-error";
} else if (disabled) {
modifier = "border-disabled";
} else if (readOnly) {
modifier =
"!border-gray-300 !focus-within:border-gray-300 focus-within:outline !outline-gray-300 cursor-not-allowed bg-transparent";
} else if (!validationStatus.isValid && shouldValidate) {
modifier = "border-error";
}

return (
Expand All @@ -113,28 +174,37 @@ export const FormAddressInput = ({
className={`form-control input input-info flex flex-row font-normal items-center ${modifier}`}
>
<input
className={`input font-mono text-sm px-0 w-full border-none focus:border-none outline-none focus:outline-none ${(readOnly ?? disabled) ? "cursor-not-allowed" : ""}`}
className={`input font-mono text-sm px-0 w-full border-none focus:border-none outline-none focus:outline-none ${
(readOnly ?? disabled) ? "cursor-not-allowed" : ""
}`}
placeholder={placeholder || "Enter address or ENS name"}
id={id}
name={id}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
disabled={disabled ?? readOnly}
readOnly={readOnly ?? disabled}
required={required}
value={value}
/>
{value && (
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
className={"!rounded-full ml-2"}
className="!rounded-full ml-2"
src={avatarUrl ? avatarUrl : blo(value as Address)}
width="30"
height="30"
/>
)}
</div>
{validationStatus.message &&
!validationStatus.isValid &&
shouldValidate && (
<p className="text-xs mt-[6px] text-error">
{validationStatus.message}
</p>
)}
</div>
);
};
61 changes: 52 additions & 9 deletions apps/web/components/Forms/ProposalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,30 @@ import React, { useState, useMemo } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Address, encodeAbiParameters, parseUnits } from "viem";
import { useToken } from "wagmi";
import { useContractRead, useToken } from "wagmi";
import {
Allo,
ArbitrableConfig,
CVStrategy,
CVStrategyConfig,
TokenGarden,
} from "#/subgraph/.graphclient";
import { FormInput } from "./FormInput";
import { FormPreview, FormRow } from "./FormPreview";
import { LoadingSpinner } from "../LoadingSpinner";
import { FormAddressInput } from "./FormAddressInput";
import { WalletBalance } from "../WalletBalance";
import { Button, EthAddress } from "@/components";
import { Button, EthAddress, InfoBox } from "@/components";
import { QUERY_PARAMS } from "@/constants/query-params";
import { usePubSubContext } from "@/contexts/pubsub.context";
import { useChainIdFromPath } from "@/hooks/useChainIdFromPath";
import { useContractWriteWithConfirmations } from "@/hooks/useContractWriteWithConfirmations";
import { ConditionObject, useDisableButtons } from "@/hooks/useDisableButtons";
import { alloABI } from "@/src/generated";
import { alloABI, cvStrategyABI } from "@/src/generated";
import { PoolTypes } from "@/types";
import { getEventFromReceipt } from "@/utils/contracts";
import { ipfsJsonUpload } from "@/utils/ipfsUtils";
import { formatTokenAmount } from "@/utils/numbers";
import { calculatePercentageBigInt, formatTokenAmount } from "@/utils/numbers";

//protocol : 1 => means ipfs!, to do some checks later
type FormInputs = {
Expand All @@ -36,7 +38,12 @@ type FormInputs = {
};

type ProposalFormProps = {
strategy: Pick<CVStrategy, "id" | "token">;
strategy: Pick<
CVStrategy,
"id" | "token" | "maxCVSupply" | "totalEffectiveActivePoints"
> & {
config: Pick<CVStrategyConfig, "decay" | "proposalType">;
};
arbitrableConfig: Pick<ArbitrableConfig, "submitterCollateralAmount">;
poolId: number;
proposalType: number;
Expand Down Expand Up @@ -123,7 +130,7 @@ export const ProposalForm = ({
getValues,
setValue,
watch,
} = useForm<FormInputs>();
} = useForm<FormInputs>({ mode: "onBlur" });

const { publish } = usePubSubContext();

Expand All @@ -147,11 +154,33 @@ export const ProposalForm = ({

const [showPreview, setShowPreview] = useState<boolean>(false);
const [previewData, setPreviewData] = useState<FormInputs>();
const [requestedAmount, setRequestedAmount] = useState<string>();
const [loading, setLoading] = useState(false);
const [isEnoughBalance, setIsEnoughBalance] = useState(true);
const router = useRouter();
const pathname = usePathname();

const chainIdFromPath = useChainIdFromPath();

const { data: thresholdFromContract } = useContractRead({
address: strategy.id as Address,
abi: cvStrategyABI,
chainId: chainIdFromPath,
functionName: "calculateThreshold",
args: [
requestedAmount ? parseUnits(requestedAmount, tokenGarden.decimals) : 0n,
],
enabled:
requestedAmount !== undefined &&
PoolTypes[strategy?.config?.proposalType] === "funding",
});

const thresholdPct = calculatePercentageBigInt(
thresholdFromContract as bigint,
BigInt(strategy.maxCVSupply),
tokenGarden?.decimals ?? 18,
);

const disableSubmitBtn = useMemo<ConditionObject[]>(
() => [
{
Expand Down Expand Up @@ -254,7 +283,7 @@ export const ProposalForm = ({
const metadata = [1, metadataIpfs as string];

const strAmount = previewData.amount?.toString() || "";
const requestedAmount = parseUnits(
const amount = parseUnits(
strAmount,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
poolToken?.decimals || 0,
Expand All @@ -263,7 +292,7 @@ export const ProposalForm = ({
console.debug([
poolId,
previewData.beneficiary,
requestedAmount,
amount,
poolTokenAddr,
metadata,
]);
Expand All @@ -273,7 +302,7 @@ export const ProposalForm = ({
poolId,
previewData?.beneficiary ||
"0x0000000000000000000000000000000000000000",
requestedAmount,
amount,
poolTokenAddr,
metadata,
],
Expand Down Expand Up @@ -327,6 +356,9 @@ export const ProposalForm = ({
subLabel={`Max ${formatNumber(spendingLimitString)} ${poolToken?.symbol} (${spendingLimitPct.toFixed(2)}% of Pool Funds)`}
register={register}
required
onChange={(e) => {
setRequestedAmount(e.target.value);
}}
registerOptions={{
max: {
value: spendingLimitNumber,
Expand All @@ -350,6 +382,17 @@ export const ProposalForm = ({
/>
</div>
)}

{requestedAmount && thresholdPct !== 0 && thresholdPct < 100 && (
<InfoBox infoBoxType={"warning"}>
The conviction required in order for the proposal to pass with the
requested amount is {thresholdPct}%.{" "}
{requestedAmount &&
thresholdPct > 50 &&
thresholdPct < 100 &&
"It may be difficult to pass"}
</InfoBox>
)}
{proposalTypeName !== "signaling" && (
<div className="flex flex-col">
<FormAddressInput
Expand Down
10 changes: 1 addition & 9 deletions apps/web/hooks/useConvictionRead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,7 @@ import { logOnce } from "@/utils/log";
import { calculatePercentageBigInt, CV_SCALE_PRECISION } from "@/utils/numbers";

export type ProposalDataLight = Maybe<
Pick<
CVProposal,
| "proposalNumber"
| "convictionLast"
| "stakedAmount"
| "threshold"
| "requestedAmount"
| "blockLast"
> & {
Pick<CVProposal, "proposalNumber" | "stakedAmount" | "requestedAmount"> & {
strategy: Pick<
CVStrategy,
"id" | "maxCVSupply" | "totalEffectiveActivePoints"
Expand Down
Loading
Loading