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

Toucan Voting #112

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
Binary file modified bun.lockb
Binary file not shown.
4 changes: 1 addition & 3 deletions components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ export const Layout: React.FC<{ children: ReactNode }> = (props) => {
<div className="flex flex-col items-center gap-20">
<div className="flex w-full flex-col items-center">
<Navbar />
<div className="flex w-full flex-col items-center px-4 py-6 md:w-4/5 md:p-6 lg:w-2/3 xl:py-10 2xl:w-3/5">
{props.children}
</div>
<div className="flex w-full flex-col items-center">{props.children}</div>
</div>

{/* Footer */}
Expand Down
40 changes: 40 additions & 0 deletions components/proposal/cardResources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { IProposalResource } from "@/utils/types";
import { Card, CardEmptyState, Heading, IconType, Link } from "@aragon/ods";
import React from "react";

interface ICardResourcesProps {
displayLink?: boolean;
resources?: IProposalResource[];
title: string;
}

export const CardResources: React.FC<ICardResourcesProps> = (props) => {
const { displayLink = true, title } = props;
let { resources } = props;

if (resources == null || resources.length === 0) {
return <CardEmptyState objectIllustration={{ object: "ARCHIVE" }} heading="No resources were added" />;
}

// Check that resources is not a empty but not an array
if (!Array.isArray(resources)) resources = [resources];

return (
<Card className="flex flex-col gap-y-4 p-6 shadow-neutral">
<Heading size="h3">{title}</Heading>
<div className="flex flex-col gap-y-4">
{resources?.map((resource) => (
<Link
key={resource.url}
href={resource.url}
variant="primary"
iconRight={displayLink ? IconType.LINK_EXTERNAL : undefined}
description={displayLink ? resource.url : undefined}
>
{resource.name}
</Link>
))}
</div>
</Card>
);
};
41 changes: 41 additions & 0 deletions components/proposal/proposalBodySection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CardCollapsible, DocumentParser, Heading } from "@aragon/ods";
import classNames from "classnames";

interface IBodySectionProps {
body: string;
}

export const BodySection: React.FC<IBodySectionProps> = (props) => {
const { body } = props;

return (
<CardCollapsible
buttonLabelClosed="Read full PIP"
buttonLabelOpened="Read less"
collapsedSize="md"
className="w-full shadow-neutral"
>
<div className="flex flex-col gap-y-4">
<Heading size="h2">Proposal Body</Heading>
<hr className="rounded-full border-neutral-100" />
<DocumentParser document={body} className={proseClasses} />
</div>
</CardCollapsible>
);
};

// Temporary until exported prose has been fixed
export const proseClasses = classNames(
"prose-p:text-base prose-p:md:text-lg", //prose-p
"prose-a:text-primary-400 prose-a:no-underline prose-a:hover:text-primary-600 prose-a:active:text-primary-800", // prose-a
"prose-strong:text-base prose-strong:md:text-lg prose-strong:text-neutral-500", // prose-strong
"prose-em:text-base prose-em:md:text-lg prose-em:text-neutral-500", //em
"prose-blockquote:rounded-lg prose-blockquote:border prose-blockquote:border-neutral-200 prose-blockquote:bg-neutral-50 prose-blockquote:prose-p-10 prose-blockquote:shadow-md", // blockquote
"prose-pre:rounded-lg prose-pre:bg-neutral-900 prose-pre:text-neutral-50", //pre
"prose-code:bg-neutral-900 prose-code:text-neutral-50 prose-code:text-sm prose-code:py-1 prose-code:px-1 prose-code:rounded prose-code:font-normal", //code
"prose-img:overflow-hidden prose-img:rounded-xl prose-img:shadow-md", // img
"prose-video:overflow-hidden prose-video:rounded-xl prose-video:shadow-md", // video
"prose-hr:mt-10 prose-hr:border-neutral-200", // hr
"prose-lead:text-neutral-600",
"prose-headings:text-neutral-800 prose-headings:leading-tight text-neutral-500 prose-headings:font-normal"
);
19 changes: 19 additions & 0 deletions components/proposalAction/callParamField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { decodeCamelCase } from "@/utils/case";
import { InputText } from "@aragon/ods";
import { type AbiFunction } from "viem";
import { resolveAddon, resolveValue, type CallParameterFieldType } from "./decoderUtils";

interface ICallParamFiledProps {
value: CallParameterFieldType;
idx: number;
functionAbi: AbiFunction | null;
}

export const CallParamField: React.FC<ICallParamFiledProps> = ({ value, idx, functionAbi }) => {
if (functionAbi?.type !== "function") return;

const resolvedValue = resolveValue(value, functionAbi.inputs?.[idx]);
const label = resolveAddon(functionAbi.inputs?.[idx].name ?? "", functionAbi.inputs?.[idx].type, idx);

return <InputText label={decodeCamelCase(label)} className="w-full" value={resolvedValue} disabled={true} />;
};
72 changes: 72 additions & 0 deletions components/proposalAction/decoderUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { type InputValue } from "@/utils/input-values";
import { type Address, type Hex, type AbiParameter } from "viem";

export type CallParameterFieldType =
| string
| Hex
| Address
| number
| bigint
| boolean
| CallParameterFieldType[]
| { [k: string]: CallParameterFieldType };

export function resolveValue(value: CallParameterFieldType, abi?: AbiParameter): string {
if (!abi?.type) {
if (Array.isArray(value)) return value.join(", ");
return value.toString();
} else if (abi.type === "tuple[]") {
const abiClone = { ...abi };
abiClone.type = abiClone.type.replace(/\[\]$/, "");

const items = (value as any as any[]).map((item) => resolveValue(item, abiClone));
return items.join(", ");
} else if (abi.type === "tuple") {
const result = {} as Record<string, string>;
const components: AbiParameter[] = (abi as any).components || [];

for (const element of components) {
const k = element.name!;
result[k] = resolveValue((value as any)[k], element);
}

return getReadableJson(result);
} else if (abi.type.endsWith("[]")) {
return (value as any as any[]).join(", ");
} else if (abi.type === "address") {
return value as string;
} else if (abi.type === "bytes32") {
return value as string;
} else if (abi.type.startsWith("uint") || abi.type.startsWith("int")) {
return value.toString();
} else if (abi.type.startsWith("bool")) {
return value ? "Yes" : "No";
}
return value.toString();
}

export function resolveAddon(name: string, abiType: string | undefined, idx: number): string {
if (name) return name;
else if (abiType) {
if (abiType === "address") {
return "Address";
} else if (abiType === "bytes32") {
return "Identifier";
} else if (abiType === "bytes") {
return "Data";
} else if (abiType === "string") {
return "Text";
} else if (abiType.startsWith("uint") || abiType.startsWith("int")) {
return "Number";
} else if (abiType.startsWith("bool")) {
return "Boolean";
}
}
return (idx + 1).toString();
}

function getReadableJson(value: Record<string, InputValue>): string {
const items = Object.keys(value).map((k) => `${k}: ${value[k]}`);

return `{ ${items.join(", ")} }`;
}
33 changes: 33 additions & 0 deletions components/proposalAction/encodedView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PUB_CHAIN } from "@/constants";
import { capitalizeFirstLetter } from "@/utils/text";
import { type RawAction } from "@/utils/types";
import { InputText, NumberFormat, formatterUtils } from "@aragon/ods";
import { formatEther } from "viem";

type IEncodedViewProps = {
rawAction: RawAction;
};

export const EncodedView: React.FC<IEncodedViewProps> = (props) => {
const { rawAction } = props;

return getEncodedArgs(rawAction).map((arg) => (
<InputText key={arg.title} label={arg.title} disabled={true} value={arg.value} className="w-full" />
));
};

function getEncodedArgs(action: RawAction) {
const isEthTransfer = !action.data || action.data === "0x";

if (isEthTransfer) {
return [
{ title: "To", value: action.to },
{
title: "Value",
value: `${formatterUtils.formatNumber(formatEther(action.value, "wei"), { format: NumberFormat.TOKEN_AMOUNT_SHORT })} ${PUB_CHAIN.nativeCurrency.symbol}`,
},
];
}

return Object.entries(action).map(([key, value]) => ({ title: capitalizeFirstLetter(key), value: value.toString() }));
}
111 changes: 111 additions & 0 deletions components/proposalAction/proposalAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { PUB_CHAIN } from "@/constants";
import { formatHexString } from "@/utils/evm";
import {
AccordionContainer,
AccordionItem,
AccordionItemContent,
AccordionItemHeader,
AvatarIcon,
Button,
IconType,
} from "@aragon/ods";
import Link from "next/link";
import { CallParamField } from "./callParamField";
import { EncodedView } from "./encodedView";
import { RawAction } from "@/utils/types";
import { Else, If, Then } from "../if";
import { useAction } from "@/hooks/useAction";

interface IProposalActionProps {
canExecute: boolean;
isConfirmingExecution: boolean;
onExecute: () => void;
actions?: RawAction[];
}

export const ProposalAction: React.FC<IProposalActionProps> = (props) => {
const { actions, canExecute, onExecute, isConfirmingExecution } = props;

return (
<div className="overflow-hidden rounded-xl bg-neutral-0 pb-2 shadow-neutral">
{/* Header */}
<div className="flex flex-col gap-y-2 px-4 py-4 md:gap-y-3 md:px-6 md:py-6">
<div className="flex justify-between gap-x-2 gap-y-2">
<p className="text-xl leading-tight text-neutral-800 md:text-2xl">Actions</p>
{canExecute && (
<Button size="md" disabled={isConfirmingExecution} onClick={() => onExecute()} className="">
Execute
</Button>
)}
</div>
<p className="text-base leading-normal text-neutral-500 md:text-lg">
The proposal must pass all voting stages above before the binding onchain actions are able to be executed.
</p>
</div>

{/* Content */}
<AccordionContainer isMulti={true} className="border-t border-t-neutral-100">
{actions?.map((action, index) => <ActionItem key={index} index={index} rawAction={action} />)}
</AccordionContainer>
</div>
);
};

const ActionItem = ({ index, rawAction }: { index: number; rawAction: RawAction }) => {
const action = useAction(rawAction);
const title = `Action ${index + 1}`;
const isEthTransfer = !action.data || action.data === "0x";
const functionName = isEthTransfer ? "Withdraw assets" : action.functionName;
const functionAbi = action.functionAbi ?? null;
const explorerUrl = `${PUB_CHAIN.blockExplorers?.default.url}/address/${action.to}`;

return (
<AccordionItem className="border-t border-t-neutral-100 bg-neutral-0" value={title}>
<AccordionItemHeader className="!items-start">
<div className="flex w-full gap-x-6">
<div className="flex flex-1 flex-col items-start gap-y-2">
{functionName && (
<div className="flex">
{/* Method name */}
<span className="flex w-full text-left text-lg leading-tight text-neutral-800 md:text-xl">
{functionName}
</span>
</div>
)}
<div className="flex w-full gap-x-6 text-sm leading-tight md:text-base">
<Link href={explorerUrl} target="_blank">
<span className="flex items-center gap-x-2 text-neutral-500">
{formatHexString(rawAction.to)}
{functionName != null && <AvatarIcon variant="primary" size="sm" icon={IconType.CHECKMARK} />}
{functionName == null && (
<span className="flex items-center gap-x-2">
Not Verified <AvatarIcon variant="warning" size="sm" icon={IconType.WARNING} />
</span>
)}
</span>
</Link>
</div>
</div>
<span className="hidden text-sm leading-tight text-neutral-500 sm:block md:text-base">{title}</span>
</div>
</AccordionItemHeader>

<AccordionItemContent className="!overflow-none">
<div className="flex flex-col gap-y-4">
<If condition={!action?.args?.length}>
<Then>
<EncodedView rawAction={action} />
</Then>
<Else>
{action?.args?.map((arg, i) => (
<div className="flex" key={i}>
<CallParamField value={arg} idx={i} functionAbi={functionAbi} />
</div>
))}
</Else>
</If>
</div>
</AccordionItemContent>
</AccordionItem>
);
};
Empty file.
5 changes: 5 additions & 0 deletions components/proposalVoting/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./proposalVoting";
export * from "./votesDataList";
export * from "./votingBreakdown/";
export * from "./votingDetails/";
export * from "./votingStage/votingStage";
35 changes: 35 additions & 0 deletions components/proposalVoting/proposalVoting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AccordionContainer, Card, Heading } from "@aragon/ods";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import React from "react";
import { VotingStage, type IVotingStageProps } from "./votingStage/votingStage";
import { ITransformedStage } from "@/utils/types";

dayjs.extend(utc);
dayjs.extend(relativeTime);

interface IProposalVotingProps {
stages: ITransformedStage[];
}

export const ProposalVoting: React.FC<IProposalVotingProps> = ({ stages }) => {
return (
<Card className="overflow-hidden rounded-xl bg-neutral-0 shadow-neutral">
{/* Header */}
<div className="flex flex-col gap-y-2 p-6">
<Heading size="h2">Voting</Heading>
<p className="text-lg leading-normal text-neutral-500">
The onchain multisig process allows for the Members to create proposals that if approve will be moved to the
Optimistic Proposal Stage.
</p>
</div>
{/* Stages */}
<AccordionContainer isMulti={false} defaultValue="Stage 1" className="border-t border-t-neutral-100">
{stages.map((stage, index) => (
<VotingStage key={stage.id} {...({ ...stage, number: index + 1 } as IVotingStageProps)} />
))}
</AccordionContainer>
</Card>
);
};
1 change: 1 addition & 0 deletions components/proposalVoting/votesDataList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./votesDataList";
Loading
Loading