diff --git a/.gitignore b/.gitignore index 266a7e7..439486d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,11 @@ dist-ssr *.local node_modules/* stats.html - +.idea ### VisualStudioCode ### .vscode/**/* !.vscode/settings.suggested.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -.idea + diff --git a/makefile b/makefile index f865c81..2201d7d 100644 --- a/makefile +++ b/makefile @@ -47,6 +47,7 @@ local-keys: local-init: local-clean local-keys simd init $(MONIKER) --chain-id $(CHAIN_ID) --home $(CHAIN_HOME) simd add-genesis-account alice 10000000000000000000000001stake --home $(CHAIN_HOME) --keyring-backend test + simd add-genesis-account machete 0stake --home $(CHAIN_HOME) --keyring-backend test simd gentx alice 1000000000stake --chain-id $(CHAIN_ID) --home $(CHAIN_HOME) --keyring-backend test --keyring-dir $(CHAIN_HOME) simd collect-gentxs --home $(CHAIN_HOME) $(sed) "s/prometheus = false/prometheus = true/" $(CHAIN_HOME)/config/config.toml @@ -61,7 +62,7 @@ local-init: local-clean local-keys local-start: #simd start --home $(CHAIN_HOME) --grpc-web.enable true --grpc-web.address 0.0.0.0:9091 # simd start --mode validator --home $(CHAIN_HOME) - simd start --home $(CHAIN_HOME) + simd start --home $(CHAIN_HOME) --log_level debug .PHONY: query-balance query-balance: diff --git a/src/api/bank.messages.ts b/src/api/bank.messages.ts new file mode 100644 index 0000000..a3edcfa --- /dev/null +++ b/src/api/bank.messages.ts @@ -0,0 +1,27 @@ +import { MsgSend } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/bank/v1beta1/tx' +import { Coin } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/base/v1beta1/coin' +import Long from 'long' + +import type { GroupWithPolicyFormValues, UIGroupMetadata } from 'types' +import { clearEmptyStr } from 'util/helpers' + +import { BankSendType } from '../types/bank.types' + +import { MsgBankWithTypeUrl, MsgWithTypeUrl } from './cosmosgroups' +import { encodeDecisionPolicy } from './policy.messages' + +export function updateGroupMetadataMsg({ + admin, + metadata, + groupId, +}: { + admin: string + groupId: string + metadata: UIGroupMetadata +}) { + return MsgWithTypeUrl.updateGroupMetadata({ + admin, + group_id: Long.fromString(groupId), + metadata: JSON.stringify(metadata), + }) +} diff --git a/src/api/cosmosgroups.ts b/src/api/cosmosgroups.ts index 316e6af..7d1d813 100644 --- a/src/api/cosmosgroups.ts +++ b/src/api/cosmosgroups.ts @@ -2,3 +2,4 @@ import { cosmos } from '@haveanicedavid/cosmos-groups-ts' export const v1 = cosmos.group.v1 export const MsgWithTypeUrl = cosmos.group.v1.MessageComposer.withTypeUrl +export const MsgBankWithTypeUrl = cosmos.bank.v1beta1.MessageComposer.withTypeUrl diff --git a/src/api/group.actions.ts b/src/api/group.actions.ts index 77547d5..6913dd0 100644 --- a/src/api/group.actions.ts +++ b/src/api/group.actions.ts @@ -1,12 +1,18 @@ +import { MsgSend } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/bank/v1beta1/tx' +import { Coin } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/base/v1beta1/coin' import Long from 'long' import { type GroupWithPolicyFormValues, type UIGroup } from 'types' import { throwError } from 'util/errors' -import { Group, signAndBroadcast } from 'store' +import { Group, signAndBroadcast, Wallet } from 'store' +import { BankSendType } from '../types/bank.types' + +import { MsgBankWithTypeUrl } from './cosmosgroups' import { createGroupWithPolicyMsg } from './group.messages' import { addMembersToGroups, toUIGroup } from './group.utils' +import { fetchGroupPolicies } from './policy.actions' export async function createGroupWithPolicy(values: GroupWithPolicyFormValues) { try { diff --git a/src/api/group.messages.ts b/src/api/group.messages.ts index 2109a0c..494fef3 100644 --- a/src/api/group.messages.ts +++ b/src/api/group.messages.ts @@ -1,9 +1,11 @@ +import { MsgSend } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/bank/v1beta1/tx' +import { Coin } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/base/v1beta1/coin' import Long from 'long' import type { GroupWithPolicyFormValues, UIGroupMetadata } from 'types' import { clearEmptyStr } from 'util/helpers' -import { MsgWithTypeUrl } from './cosmosgroups' +import { MsgBankWithTypeUrl, MsgWithTypeUrl } from './cosmosgroups' import { encodeDecisionPolicy } from './policy.messages' export function createGroupWithPolicyMsg(values: GroupWithPolicyFormValues) { @@ -19,7 +21,7 @@ export function createGroupWithPolicyMsg(values: GroupWithPolicyFormValues) { threshold, votingWindow, } = values - return MsgWithTypeUrl.createGroupWithPolicy({ + const groupPolicyResponse = MsgWithTypeUrl.createGroupWithPolicy({ admin, group_policy_metadata: '', group_policy_as_admin: policyAsAdmin === 'true', @@ -41,6 +43,8 @@ export function createGroupWithPolicyMsg(values: GroupWithPolicyFormValues) { metadata: JSON.stringify(m.metadata), })), }) + + return groupPolicyResponse } export function updateGroupMetadataMsg({ diff --git a/src/api/proposal.actions.ts b/src/api/proposal.actions.ts new file mode 100644 index 0000000..b7a22e8 --- /dev/null +++ b/src/api/proposal.actions.ts @@ -0,0 +1,105 @@ +import { QueryProposalsByGroupPolicyResponse } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/query' +import { + Proposal, + Vote, +} from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' +import Long from 'long' + +import { throwError } from 'util/errors' + +import { Group, signAndBroadcast } from 'store' + +import { ProposalFormValues } from '@/organisms/proposal-form' +import { VoteFormValues } from '@/organisms/vote-form' + +import { UIGroup } from '../types' +import { ProposalExecMsg } from '../types/proposal.types' + +import { toUIGroup } from './group.utils' +import { createProposalMsg, execProposalMsg } from './proposal.messages' +import { createVoteMsg } from './vote.messages' + +export async function createProposal(values: ProposalFormValues) { + try { + const msg = createProposalMsg(values) + const data = await signAndBroadcast([msg]) + let proposalId + if (data.rawLog) { + const [raw] = JSON.parse(data.rawLog) + const idRaw = raw.events[0].attributes[0].value + proposalId = JSON.parse(idRaw) + } + return { ...data, proposalId } + } catch (error) { + throwError(error) + } +} + +export async function voteProposal(values: VoteFormValues) { + try { + const msg = createVoteMsg(values) + const data = await signAndBroadcast([msg]) + if (data.code !== 0) { + throwError(new Error(data.rawLog)) + } + return data + } catch (error) { + throwError(error) + } +} + +export async function execProposal(values: ProposalExecMsg) { + try { + const msg = execProposalMsg(values) + const data = await signAndBroadcast([msg]) + if (data.code !== 0) { + throwError(new Error(data.rawLog)) + } + return data + } catch (error) { + throwError(error) + } +} + +export async function fetchProposalById(proposalId?: string | Long): Promise { + if (!Group.query) throwError('Wallet not initialized') + if (!proposalId) throwError('proposalId is required') + try { + const { proposal } = await Group.query.proposal({ + proposal_id: proposalId instanceof Long ? proposalId : Long.fromString(proposalId), + }) + return proposal + } catch (error) { + throwError(error) + } +} + +export async function fetchProposalVotesById( + proposalId?: string | Long, +): Promise { + if (!Group.query) throwError('Wallet not initialized') + if (!proposalId) throwError('proposalId is required') + try { + const { votes } = await Group.query.votesByProposal({ + proposal_id: proposalId instanceof Long ? proposalId : Long.fromString(proposalId), + }) + return votes + } catch (error) { + throwError(error) + } +} + +export async function fetchProposalsByPolicyAddr( + policyAddress: string, +): Promise { + if (!Group.query) throwError('Wallet not initialized') + if (!policyAddress) throwError('policyAddress is required') + try { + const result = await Group.query.proposalsByGroupPolicy({ + address: policyAddress, + }) + return result + } catch (error) { + throwError(error) + } +} diff --git a/src/api/proposal.messages.ts b/src/api/proposal.messages.ts new file mode 100644 index 0000000..a7c0728 --- /dev/null +++ b/src/api/proposal.messages.ts @@ -0,0 +1,49 @@ +import { cosmos } from '@haveanicedavid/cosmos-groups-ts' +import { MsgSend } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/bank/v1beta1/tx' +import { Coin } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/base/v1beta1/coin' +import { + MsgExec, + MsgSubmitProposal, +} from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/tx' +import { Any } from '@haveanicedavid/cosmos-groups-ts/types/codegen/google/protobuf/any' + +import { ProposalFormValues } from '@/organisms/proposal-form' + +import { ProposalExecMsg } from '../types/proposal.types' + +import { MsgWithTypeUrl, v1 } from './cosmosgroups' + +export function createProposalMsg(values: ProposalFormValues) { + const { group_policy_address, metadata, proposers, Exec } = values + const coin: Coin = { + denom: 'stake', + amount: values.amount.toString(), + } + const msgSend: MsgSend = { + from_address: values.group_policy_address, + to_address: values.msgToAddr, + amount: [coin], + } + + const message: MsgSubmitProposal = { + messages: [ + { + value: cosmos.bank.v1beta1.MsgSend.encode(msgSend).finish(), + type_url: '/cosmos.bank.v1beta1.MsgSend', + }, + ], // For demo purposes, proposal have empty messages. + group_policy_address: group_policy_address, + metadata: metadata, + proposers: proposers.map((elm) => elm.address), + exec: Exec, + } + return MsgWithTypeUrl.submitProposal(message) +} + +export function execProposalMsg(values: ProposalExecMsg) { + const message: MsgExec = { + proposal_id: values.proposal_id, + executor: values.executor, + } + return MsgWithTypeUrl.exec(message) +} diff --git a/src/api/vote.messages.ts b/src/api/vote.messages.ts new file mode 100644 index 0000000..8548937 --- /dev/null +++ b/src/api/vote.messages.ts @@ -0,0 +1,21 @@ +import { + MsgSubmitProposal, + MsgVote, +} from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/tx' + +import { ProposalFormValues } from '@/organisms/proposal-form' +import { VoteFormValues } from '@/organisms/vote-form' + +import { MsgWithTypeUrl } from './cosmosgroups' + +export function createVoteMsg(values: VoteFormValues) { + const { proposal_id, metadata, option, exec, voter } = values + const message: MsgVote = { + voter: voter, + proposal_id: proposal_id, + metadata: metadata, + option: option, + exec: exec, + } + return MsgWithTypeUrl.vote(message) +} diff --git a/src/components/organisms/group-proposals-table.tsx b/src/components/organisms/group-proposals-table.tsx new file mode 100644 index 0000000..b44aa0e --- /dev/null +++ b/src/components/organisms/group-proposals-table.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { Proposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' + +import { useBoolean, useBreakpointValue, useColorModeValue } from 'hooks/chakra' + +import { Link, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@/atoms' +import { TableTitlebar, Truncate } from '@/molecules' + +export const GroupProposalsTable = ({ proposals }: { proposals: Proposal[] }) => { + const [isEdit, setEdit] = useBoolean(false) + const tableProposals: Proposal[] = useMemo(() => { + const proposalsVals = proposals.map((p) => p) + return [...proposalsVals] + }, [proposals]) + const tailSize = useBreakpointValue({ base: 4, sm: 6, md: 25, lg: 35, xl: 100 }) + return ( + + + + + th': { fontWeight: 'bold' } }}> + + + + + + + {tableProposals.map((proposal, i) => { + const key = proposal.id.toString() + return ( + + + + + + ) + })} + +
ProposalStatusPolicy Address
+ + + + {proposal.status} + {' '} + {' '} +
+
+ ) +} diff --git a/src/components/organisms/proposal-form.tsx b/src/components/organisms/proposal-form.tsx new file mode 100644 index 0000000..b921862 --- /dev/null +++ b/src/components/organisms/proposal-form.tsx @@ -0,0 +1,260 @@ +import { useState } from 'react' +import { type FieldError, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { + Box, + FormLabel, + Input, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, +} from '@chakra-ui/react' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' + +import { truncate } from 'util/helpers' +import { SPACING } from 'util/style.constants' +import { valid } from 'util/validation/zod' + +import { + Button, + DeleteButton, + Flex, + FormCard, + FormControl, + HStack, + IconButton, + Stack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@/atoms' +import { InputWithButton, Truncate } from '@/molecules' +import { FieldControl, TextareaField } from '@/molecules/form-fields' + +import { ProposerFormValues } from '../../types/proposal.types' + +export declare enum Exec { + /** + * EXEC_UNSPECIFIED - An empty value means that there should be a separate + * MsgExec request for the proposal to execute. + */ + EXEC_UNSPECIFIED = 0, + /** + * EXEC_TRY - Try to execute the proposal immediately. + * If the proposal is not allowed per the DecisionPolicy, + * the proposal will still be open and could + * be executed at a later point. + */ + EXEC_TRY = 1, + UNRECOGNIZED = -1, +} + +/** @see @haveanicedavid/cosmos-groups-ts/types/proto/cosmos/group/v1/types */ +export type ProposalFormValues = { + group_policy_address: string + msgToAddr: string + amount: number + metadata: string + proposers: ProposerFormValues[] + Exec: Exec +} + +export type ProposalFormKeys = keyof ProposalFormValues + +export const defaultProposalFormValues: ProposalFormValues = { + group_policy_address: '', + metadata: '', + amount: 150, + msgToAddr: '', + proposers: [], + Exec: -1, +} + +const resolver = zodResolver( + z.object({ + metadata: valid.json.optional(), + amount: valid.positiveNumber, + msgToAddr: valid.bech32Address, + proposers: valid.proposers, + }), +) + +export const ProposalForm = ({ + btnText = 'Submit', + defaultValues, + isLoading, + disabledFields = [], + onSubmit, +}: { + btnText?: string + defaultValues: ProposalFormValues + isLoading: boolean + disabledFields?: ProposalFormKeys[] + onSubmit: (data: ProposalFormValues) => void +}) => { + const [proposerAddr, setProposerAddr] = useState('') + const [msgToAddr, setMsgToAddr] = useState('') + const [amount, setAmount] = useState(150) + const form = useForm({ defaultValues, resolver }) + const { + fields: proposerFields, + append, + remove, + } = useFieldArray({ control: form.control, name: 'proposers' }) + const { + watch, + setValue, + getValues, + formState: { errors }, + } = form + + const watchFieldArray = watch('proposers') + + const controlledProposerFields = proposerFields.map((field, index) => { + return { + ...field, + ...watchFieldArray[index], + } + }) + + function validateAddress(addr: string): boolean { + if (proposerFields.find((m) => m.address === addr)) { + form.setError('proposers', { type: 'invalid', message: 'Address already added' }) + return false + } + try { + valid.bech32Address.parse(addr) + return true + } catch (err) { + if (err instanceof z.ZodError) { + form.setError('proposers', { type: 'invalid', message: err.issues[0].message }) + } + + return false + } + } + + function addProposer(): void { + if (!validateAddress(proposerAddr)) return + const proposer: ProposerFormValues = { address: proposerAddr } + append(proposer) + setProposerAddr('') + } + + return ( + + +
+ + + + {/* Because of how the form is structured, we need a controlled + value which is associated with the `proposers` array, but doesn't + directly add to it */} + + { + if (errors.proposers) { + form.clearErrors('proposers') + } + setProposerAddr(e.target.value) + }} + onBtnClick={addProposer} + > + {'+ Add'} + + + + {controlledProposerFields.length > 0 && ( + + + + + + + + + + {controlledProposerFields.map((member, i) => ( + + + + + ))} + +
Accounts addedWeight
+ + + + remove(i)} /> + +
+
+ )} + + + + Send funds to this account: + { + setMsgToAddr(e.target.value) + setValue('msgToAddr', e.target.value) + }} + > + + + + + Amount to send: + { + setAmount(parseInt(e, 10)) + setValue('amount', parseInt(e, 10)) + }} + > + + + + + + + + + + + + +
+
+
+
+ ) +} diff --git a/src/components/organisms/vote-form.tsx b/src/components/organisms/vote-form.tsx new file mode 100644 index 0000000..668cb45 --- /dev/null +++ b/src/components/organisms/vote-form.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { type FieldError, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { Select } from '@chakra-ui/react' +import { VoteOption } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' +import { zodResolver } from '@hookform/resolvers/zod' +import { Long } from '@osmonauts/helpers' +import { z } from 'zod' + +import { SPACING } from 'util/style.constants' +import { valid } from 'util/validation/zod' + +import { + Button, + DeleteButton, + Flex, + FormCard, + HStack, + Stack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@/atoms' +import { InputWithButton, Truncate } from '@/molecules' +import { FieldControl, TextareaField } from '@/molecules/form-fields' +import { Exec } from '@/organisms/proposal-form' + +import { ProposerFormValues } from '../../types/proposal.types' + +/** @see @haveanicedavid/cosmos-groups-ts/types/proto/cosmos/group/v1/types */ +export type VoteFormValues = { + /** proposal is the unique ID of the proposal. */ + proposal_id: Long + /** voter is the voter account address. */ + voter: string + /** option is the voter's choice on the proposal. */ + option: number + /** metadata is any arbitrary metadata to attached to the vote. */ + metadata: string + /** + * exec defines whether the proposal should be executed + * immediately after voting or not. + */ + exec: Exec +} + +export type VoteFormKeys = keyof VoteFormValues + +export const defaultVoteFormValues: VoteFormValues = { + proposal_id: Long.fromInt(0), + voter: '', + metadata: '', + option: 1, + exec: 0, +} + +const resolver = zodResolver( + z.object({ + option: valid.voteOption, + }), +) + +export const VoteForm = ({ + btnText = 'Submit Vote', + defaultValues, + isLoading, + disabledFields = [], + onSubmit, +}: { + btnText?: string + isLoading: boolean + defaultValues: VoteFormValues + disabledFields?: VoteFormKeys[] + onSubmit: (data: VoteFormValues) => void +}) => { + const [option, setOption] = useState(1) + const form = useForm({ defaultValues, resolver }) + const { + watch, + setValue, + getValues, + formState: { errors }, + } = form + + return ( + + +
+ + + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/templates/proposal-template.tsx b/src/components/templates/proposal-template.tsx new file mode 100644 index 0000000..f30a463 --- /dev/null +++ b/src/components/templates/proposal-template.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useBoolean } from '@chakra-ui/react' + +import type { GroupWithPolicyFormValues } from 'types' + +import { useSteps } from 'hooks/chakra' + +import { AnimatePresence, HorizontalSlide } from '@/animations' +import { Button, Flex, Heading, PageContainer, RouteLink, Stack, Text } from '@/atoms' +import { PageStepper } from '@/molecules' +import { type GroupFormValues, GroupForm, GroupFormKeys } from '@/organisms/group-form' +import { + type GroupPolicyFormValues, + GroupPolicyForm, +} from '@/organisms/group-policy-form' +import { + ProposalForm, + ProposalFormKeys, + ProposalFormValues, +} from '@/organisms/proposal-form' + +const Finished = ({ text, linkTo }: { text: string; linkTo: string }) => ( + + {text} + + +) + +export default function ProposalTemplate({ + initialProposalFormValues, + disabledProposalFormFields, + linkToProposalId, + groupPolicyAddr, + submit, + steps, + text, +}: { + disabledProposalFormFields?: ProposalFormKeys[] + initialProposalFormValues: ProposalFormValues + /** ID of group, used for redirect link */ + linkToProposalId?: string + groupPolicyAddr: string + submit: (values: ProposalFormValues) => Promise + steps: string[] + text: { + submitBtn?: string + finished: string + } +}) { + const { activeStep, nextStep, prevStep /* reset, setStep */ } = useSteps({ + initialStep: 0, + }) + const [proposalValues, setProposalValues] = useState( + initialProposalFormValues, + ) + const [submitting, setSubmitting] = useState(false) + const [priorStep, setPriorStep] = useState(0) + async function handleSubmit(proposalValues: ProposalFormValues) { + setSubmitting(true) + const success = await submit({ + ...proposalValues, + group_policy_address: groupPolicyAddr, + }) + setSubmitting(false) + if (success) nextStep() + } + + function handlePrev() { + setPriorStep(activeStep) + prevStep() + } + + function renderStep() { + switch (activeStep) { + case 0: + return ( + + With Policy Address: {groupPolicyAddr} + + + ) + case 1: + return ( + + + + ) + default: + return null + } + } + + return ( + + + + + {steps[activeStep]} + + {renderStep()} + + + ) +} diff --git a/src/hooks/use-query.ts b/src/hooks/use-query.ts index cf3c47c..95f4aa5 100644 --- a/src/hooks/use-query.ts +++ b/src/hooks/use-query.ts @@ -8,6 +8,12 @@ import { import { fetchGroupMembers } from 'api/member.actions' import { fetchGroupPolicies } from 'api/policy.actions' +import { + fetchProposalById, + fetchProposalsByPolicyAddr, + fetchProposalVotesById, +} from '../api/proposal.actions' + export function useGroup(groupId?: string) { return useQuery( ['group', groupId], @@ -18,6 +24,36 @@ export function useGroup(groupId?: string) { ) } +export function useProposal(proposalId?: string) { + return useQuery( + ['proposal', proposalId], + () => { + return fetchProposalById(proposalId) + }, + { enabled: !!proposalId }, + ) +} + +export function useProposalVotes(proposalId?: string) { + return useQuery( + ['proposalVotes', proposalId], + () => { + return fetchProposalVotesById(proposalId) + }, + { enabled: !!proposalId }, + ) +} + +export function useGroupPolicyProposals(groupAddr: string) { + return useQuery( + ['proposalsGroupPolicy', groupAddr], + () => { + return fetchProposalsByPolicyAddr(groupAddr) + }, + { enabled: !!groupAddr }, + ) +} + export function useGroupMembers(groupId?: string) { return useQuery( ['groupMembers', groupId], diff --git a/src/pages/group-details.tsx b/src/pages/group-details.tsx index 6d7e2be..55d72ed 100644 --- a/src/pages/group-details.tsx +++ b/src/pages/group-details.tsx @@ -1,11 +1,18 @@ +import { MdCreate } from 'react-icons/md' import { useParams } from 'react-router-dom' +import { Proposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' import type { MemberFormValues } from 'types' import { handleError, throwError } from 'util/errors' import { signAndBroadcast } from 'store' import { updateGroupMembersMsg } from 'api/member.messages' -import { useGroup, useGroupMembers, useGroupPolicies } from 'hooks/use-query' +import { + useGroup, + useGroupMembers, + useGroupPolicies, + useGroupPolicyProposals, +} from 'hooks/use-query' import { useTxToasts } from 'hooks/useToasts' import { @@ -21,20 +28,23 @@ import { import { GroupMembersTable } from '@/organisms/group-members-table' import { GroupPolicyTable } from '@/organisms/group-policy-table' +import { GroupProposalsTable } from '../components/organisms/group-proposals-table' + export default function GroupDetails() { const { groupId } = useParams() const { data: group } = useGroup(groupId) + const { data: members, refetch: refetchMembers } = useGroupMembers(groupId) const { data: policies } = useGroupPolicies(groupId) const { toastSuccess, toastErr } = useTxToasts() - console.log('group :>> ', group) - console.log('members :>> ', members) - const [policy] = policies ?? [] - console.log('policy :>> ', policy) + const { data: proposalsData } = useGroupPolicyProposals(policy?.address) + let proposals: Proposal[] = [] + if (proposalsData) { + proposals = proposalsData.proposals + } const policyIsAdmin = policy?.admin === policy?.address - async function handleUpdateMembers(values: MemberFormValues[]): Promise { if (!groupId || !group?.admin) throwError(`Can't update members: missing group ID or admin`) @@ -60,9 +70,17 @@ export default function GroupDetails() { {group?.metadata.name} - + + + + {group?.metadata.description} @@ -73,6 +91,7 @@ export default function GroupDetails() { + ) diff --git a/src/pages/proposal-create.tsx b/src/pages/proposal-create.tsx new file mode 100644 index 0000000..a8c279a --- /dev/null +++ b/src/pages/proposal-create.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { useParams } from 'react-router-dom' + +import { handleError } from 'util/errors' +import { defaultGroupFormValues, defaultGroupPolicyFormValues } from 'util/form.constants' + +import { Wallet } from 'store' +import { useTxToasts } from 'hooks/useToasts' + +import { defaultProposalFormValues, ProposalFormValues } from '@/organisms/proposal-form' +import ProposalTemplate from '@/templates/proposal-template' + +import { createProposal } from '../api/proposal.actions' +import { useGroupPolicies } from '../hooks/use-query' + +export default function ProposalCreate() { + const { toastErr, toastSuccess } = useTxToasts() + const [newProposalId, setNewProposalId] = useState() + const { groupId } = useParams() + const { data: policies } = useGroupPolicies(groupId) + const [policy] = policies ?? [] + async function handleCreate(values: ProposalFormValues): Promise { + try { + const { transactionHash, proposalId } = await createProposal(values) + values.group_policy_address = policy.address + setNewProposalId(proposalId?.toString()) + toastSuccess(transactionHash, 'Vote created!') + return true + } catch (err) { + console.error('err', err) + handleError(err) + toastErr(err, 'Vote could not be created:') + return false + } + } + + if (!Wallet.account?.address) return null + + return ( + + ) +} diff --git a/src/pages/proposal-details.tsx b/src/pages/proposal-details.tsx new file mode 100644 index 0000000..1a54d77 --- /dev/null +++ b/src/pages/proposal-details.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { MdEject } from 'react-icons/md' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Container, + Flex, + FormControl, + FormErrorMessage, + FormLabel, + Select, + Stat, + StatGroup, + StatHelpText, + StatLabel, + StatNumber, + Tag, + useBoolean, +} from '@chakra-ui/react' +import { Vote } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' +import Long from 'long' + +import { useProposal, useProposalVotes } from 'hooks/use-query' + +import { Button, Heading, HStack, PageContainer, RouteLink, Stack, Text } from '@/atoms' +import { ProposalFormValues } from '@/organisms/proposal-form' +import { defaultVoteFormValues, VoteForm, VoteFormValues } from '@/organisms/vote-form' + +import { createProposal, execProposal, voteProposal } from '../api/proposal.actions' +import { createVoteMsg } from '../api/vote.messages' +import { useTxToasts } from '../hooks/useToasts' +import { Wallet } from '../store' +import { ProposalExecMsg } from '../types/proposal.types' +import { handleError } from '../util/errors' + +const statStyle = { + fontSize: '32px', +} +const buildVoteStats = (votes?: Vote[]) => { + if (!votes) { + return {} + } + const result: any = {} + for (const v of votes) { + if (!result[v.option]) { + result[v.option] = 1 + } else { + result[v.option] += 1 + } + } + return result +} + +export default function ProposalDetails() { + const { proposalId, groupId } = useParams() + const navigate = useNavigate() + const { toastErr, toastSuccess } = useTxToasts() + const { data: proposal } = useProposal(proposalId) + const { data: votes } = useProposalVotes(proposalId) + const voteStats = buildVoteStats(votes) + const [isLoading, setLoading] = useBoolean(false) + const [time, setTime] = useState(Date.now()) + + useEffect(() => { + const interval = setInterval(() => setTime(Date.now()), 1000) + return () => { + clearInterval(interval) + } + }, []) + const handleSubmit = async (data: VoteFormValues) => { + setLoading.on() + data.voter = Wallet.account?.address ? Wallet.account?.address : '' + data.proposal_id = Long.fromString(proposalId ? proposalId : '') + data.metadata = '' + try { + const resp = await voteProposal(data) + toastSuccess(resp.transactionHash, 'Vote created!') + return true + } catch (err) { + handleError(err) + toastErr(err, 'Vote could not be created:') + return false + } finally { + setLoading.off() + } + } + const handleExecute = async () => { + setLoading.on() + const data: ProposalExecMsg = { + proposal_id: Long.fromString(proposalId ? proposalId : ''), + executor: Wallet.account?.address ? Wallet.account?.address : '', + } + try { + const resp = await execProposal(data) + toastSuccess(resp.transactionHash, 'Proposal Executed!') + navigate(`/`) + return true + } catch (err) { + handleError(err) + toastErr(err, 'Vote could not be created:') + return false + } finally { + setLoading.off() + } + } + return ( + + + +
+ {`Proposal: ${proposal?.id}`} + {`Status: ${proposal?.status}`} +
+ + + +
+ + {`Results: `} + + + + + Yes + + + {voteStats?.VOTE_OPTION_YES ? voteStats?.VOTE_OPTION_YES : 0} + + + + + + No + + + {voteStats?.VOTE_OPTION_NO ? voteStats?.VOTE_OPTION_NO : 0} + + + + + No With Veto + + + {voteStats?.VOTE_OPTION_NO_WITH_VETO + ? voteStats?.VOTE_OPTION_NO_WITH_VETO + : 0} + + + + + Abstain + + + {voteStats?.VOTE_OPTION_ABSTAIN ? voteStats?.VOTE_OPTION_ABSTAIN : 0} + + + + + {`Vote: `} + + + + + +
+
+ ) +} diff --git a/src/routes.tsx b/src/routes.tsx index 9c013cd..28f98ba 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -4,6 +4,9 @@ import { Route, Routes as RRouterRoutes, useLocation } from 'react-router-dom' import { AnimatePresence } from '@/animations' import { AppLayout } from '@/templates/app-layout' +import ProposalCreate from './pages/proposal-create' +import ProposalDetails from './pages/proposal-details' + const GroupCreate = lazy(() => import('./pages/group-create')) const GroupEdit = lazy(() => import('./pages/group-edit')) const GroupDetails = lazy(() => import('./pages/group-details')) @@ -21,6 +24,10 @@ export const Routes = () => { } /> } /> + } /> + + + }> } /> diff --git a/src/types/bank.types.ts b/src/types/bank.types.ts new file mode 100644 index 0000000..ef07fa7 --- /dev/null +++ b/src/types/bank.types.ts @@ -0,0 +1,7 @@ +import { Coin } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/base/v1beta1/coin' + +export type BankSendType = { + from_address: string + to_address: string + amount: Coin[] +} diff --git a/src/types/proposal.types.ts b/src/types/proposal.types.ts new file mode 100644 index 0000000..c04ecd7 --- /dev/null +++ b/src/types/proposal.types.ts @@ -0,0 +1,11 @@ +import { Long } from '@osmonauts/helpers' + +export type ProposerFormValues = { + address: string +} + +export type ProposalExecMsg = { + proposal_id: Long + /** executor is the account address used to execute the proposal. */ + executor: string +} diff --git a/src/util/validation/zod.ts b/src/util/validation/zod.ts index 889d4e4..a1615af 100644 --- a/src/util/validation/zod.ts +++ b/src/util/validation/zod.ts @@ -10,7 +10,12 @@ const member = z.object({ // metadata: z.string().optional() // TODO: ? }) +const proposer = z.object({ + address: bech32Address, +}) + const members = member.array().min(1, 'Must have at least one member') +const proposers = proposer.array().min(1, 'Must have at least one proposer') const json = z.string().refine(isJSON, 'Must be a valid JSON string') @@ -19,6 +24,8 @@ const name = z .min(1, 'Name is required') .max(50, 'Name must be less than 50 characters') +const voteOption = z.number() + const description = z .string() .min(4, 'Description is too short') @@ -59,9 +66,11 @@ export const valid = { emptyStr, boolStr, name, + voteOption, description, groupOrAddress, members, + proposers, json, url, positiveNumber,