From 85e82b5d18c4a11fc3b63f92839dc12f1c6a4380 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 19 Sep 2022 17:27:54 -0600 Subject: [PATCH 1/8] Auto stash before merge of "main" and "origin/main" --- .gitignore | 4 +- src/components/organisms/ProposalForm.tsx | 198 ++++++++++++++++++++++ src/pages/ProposalCreate.tsx | 135 +++++++++++++++ src/pages/group-details.tsx | 15 +- src/routes.tsx | 4 + 5 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 src/components/organisms/ProposalForm.tsx create mode 100644 src/pages/ProposalCreate.tsx 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/src/components/organisms/ProposalForm.tsx b/src/components/organisms/ProposalForm.tsx new file mode 100644 index 0000000..3a3d7f1 --- /dev/null +++ b/src/components/organisms/ProposalForm.tsx @@ -0,0 +1,198 @@ +import { useState } from 'react' +import { type FieldError, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' + +import { type MemberFormValues, defaultMemberFormValues } from 'models' +import { SPACING } from 'util/constants' +import { truncate } from 'util/helpers' +import { valid } from 'util/validation/zod' + +import { + Button, + DeleteButton, + Flex, + FormCard, + FormControl, + IconButton, + NumberInput, + Stack, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@/atoms' +import { InputWithButton } from '@/molecules' +import { + FieldControl, + InputField, + RadioGroupField, + TextareaField, +} from '@/molecules/FormFields' + +import { DeleteIcon } from 'assets/tsx' + +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 + metadata?: string + proposers?: Array + Exec: Exec +} + +export const defaultProposalFormValues: ProposalFormValues = { + group_policy_address: '', + metadata: '', + proposers: [], + Exec: 1, +} + +const resolver = zodResolver( + z.object({ + group_policy_address: valid.bech32, + metadata: valid.json.optional(), + proposers: valid.bech32.array(), + }), +) + +export const ProposalForm = ({ + btnText = 'Submit', + defaultValues, + onSubmit, +}: { + btnText?: string + defaultValues: ProposalFormValues + onSubmit: (data: ProposalFormValues) => void +}) => { + const [proposerAddr, setProposerAddr] = useState('') + const form = useForm({ defaultValues, resolver }) + const { + fields: proposerFields, + append, + remove, + } = useFieldArray({ control: form.control }) + 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 === addr)) { + form.setError('proposers', { type: 'invalid', message: 'Address already added' }) + return false + } + try { + valid.bech32.parse(addr) + return true + } catch (err) { + if (err instanceof z.ZodError) { + form.setError('proposers', { type: 'invalid', message: err.issues[0].message }) + } + return false + } + } + + function addMember(): void { + if (!validateAddress(proposerAddr)) return + const member: MemberFormValues = { ...defaultMemberFormValues, address: proposerAddr } + append(member) + setProposerAddr('') + } + + return ( + + +
+ + + + + + + {/* Because of how the form is structured, we need a controlled + value which is associated with the `members` array, but doesn't + directly add to it */} + + { + if (errors.proposers) { + form.clearErrors('proposers') + } + setProposerAddr(e.target.value) + }} + onBtnClick={addMember} + > + {'+ Add'} + + + + {controlledProposerFields.length > 0 && ( + + + + + + + + + + {controlledProposerFields.map((proposer, i) => ( + + + + + ))} + +
Proposers addedActions
{proposer.address} + + remove(i)} /> + +
+
+ )} + + + +
+
+
+
+ ) +} diff --git a/src/pages/ProposalCreate.tsx b/src/pages/ProposalCreate.tsx new file mode 100644 index 0000000..dfcf3fb --- /dev/null +++ b/src/pages/ProposalCreate.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { type GroupPolicyFormValues } from 'models' +import { TOAST_DEFAULTS } from 'util/constants' +import { toErrorWithMessage } from 'util/errors' + +import { useSteps, useToast } from 'hooks/chakra' +import { createGroupWithPolicy } from 'store/Group' +import { Wallet } from 'store/Wallet' + +import { AnimatePresence, Button, RouteLink, Stack, Text } from '@/atoms' +import { HorizontalSlide } from '@/molecules/animations' +import { + type GroupFormValues, + defaultGroupFormValues, + GroupForm, +} from '@/organisms/GroupForm' +import { + defaultGroupPolicyFormValues, + GroupPolicyForm, +} from '@/organisms/GroupPolicyForm' +import { StepperTemplate } from '@/templates/StepperTemplate' + +const steps = ['Create Proposal', 'Finished'] + +export default function GroupCreate() { + const toast = useToast() + const navigate = useNavigate() + const { activeStep, nextStep, prevStep /* reset, setStep */ } = useSteps({ + initialStep: 0, + }) + const [groupValues, setGroupValues] = useState({ + ...defaultGroupFormValues, + admin: Wallet.account?.address ?? '', + }) + const [submitting, setSubmitting] = useState(false) + const [priorStep, setPriorStep] = useState(0) + + function handleNext() { + setPriorStep(activeStep) + nextStep() + } + + function handlePrev() { + setPriorStep(activeStep) + prevStep() + } + + function handleGroupSubmit(values: GroupFormValues) { + setGroupValues(values) + nextStep() + } + + async function handleCreate(policyValues: GroupPolicyFormValues) { + setSubmitting(true) + try { + const { transactionHash } = await createGroupWithPolicy({ + ...groupValues, + ...policyValues, + }) + const time = 3000 + toast({ + ...TOAST_DEFAULTS, + title: 'Group created', + description: 'Transaction hash: ' + transactionHash, + status: 'success', + duration: time, + }) + handleNext() + setTimeout(() => navigate('/'), time + 500) + } catch (err) { + const msg = toErrorWithMessage(err).message + console.error(err) + toast({ + ...TOAST_DEFAULTS, + title: 'Group creation failed', + description: msg, + status: 'error', + duration: 9000, + }) + } finally { + setSubmitting(false) + } + } + + function renderStep() { + switch (activeStep) { + case 0: + return ( + + + + ) + case 1: + return ( + + + + ) + case 2: + return ( + + + + ) + default: + return null + } + } + + return ( + + {renderStep()} + + ) +} + +const Finished = () => ( + + You have successfully set up your group and group policy. + + +) diff --git a/src/pages/group-details.tsx b/src/pages/group-details.tsx index 6d7e2be..0744a1b 100644 --- a/src/pages/group-details.tsx +++ b/src/pages/group-details.tsx @@ -20,6 +20,7 @@ import { } from '@/atoms' import { GroupMembersTable } from '@/organisms/group-members-table' import { GroupPolicyTable } from '@/organisms/group-policy-table' +import { MdCreate } from "react-icons/md"; export default function GroupDetails() { const { groupId } = useParams() @@ -60,9 +61,17 @@ export default function GroupDetails() { {group?.metadata.name} - + + + + {group?.metadata.description} diff --git a/src/routes.tsx b/src/routes.tsx index 9c013cd..58e79f4 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -20,7 +20,11 @@ export const Routes = () => { } /> } /> +<<<<<<< Updated upstream:src/routes.tsx } /> +======= + } /> +>>>>>>> Stashed changes:src/Routes.tsx } /> From cf02768c1700822ff82958494b61f1dd72fbfecd Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 19 Sep 2022 19:36:17 -0600 Subject: [PATCH 2/8] wip: adding create proposal --- makefile | 2 +- src/api/proposal.actions.ts | 23 +++ src/api/proposal.messages.ts | 17 +++ .../{ProposalForm.tsx => proposal-form.tsx} | 71 ++++----- .../templates/proposal-template.tsx | 115 +++++++++++++++ src/pages/ProposalCreate.tsx | 135 ------------------ src/pages/proposal-create.tsx | 46 ++++++ src/routes.tsx | 3 - src/types/proposal.types.ts | 3 + 9 files changed, 242 insertions(+), 173 deletions(-) create mode 100644 src/api/proposal.actions.ts create mode 100644 src/api/proposal.messages.ts rename src/components/organisms/{ProposalForm.tsx => proposal-form.tsx} (72%) create mode 100644 src/components/templates/proposal-template.tsx delete mode 100644 src/pages/ProposalCreate.tsx create mode 100644 src/pages/proposal-create.tsx create mode 100644 src/types/proposal.types.ts diff --git a/makefile b/makefile index f865c81..1f432c5 100644 --- a/makefile +++ b/makefile @@ -61,7 +61,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/proposal.actions.ts b/src/api/proposal.actions.ts new file mode 100644 index 0000000..90fea1f --- /dev/null +++ b/src/api/proposal.actions.ts @@ -0,0 +1,23 @@ +import { throwError } from 'util/errors' + +import { signAndBroadcast } from 'store' + +import { ProposalFormValues } from '@/organisms/proposal-form' + +import { createProposalMsg } from './proposal.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) + } +} diff --git a/src/api/proposal.messages.ts b/src/api/proposal.messages.ts new file mode 100644 index 0000000..0672be1 --- /dev/null +++ b/src/api/proposal.messages.ts @@ -0,0 +1,17 @@ +import { MsgSubmitProposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/tx' + +import { ProposalFormValues } from '@/organisms/proposal-form' + +import { MsgWithTypeUrl } from './cosmosgroups' + +export function createProposalMsg(values: ProposalFormValues) { + const { group_policy_address, metadata, proposers, Exec } = values + const message: MsgSubmitProposal = { + messages: [], // 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) +} diff --git a/src/components/organisms/ProposalForm.tsx b/src/components/organisms/proposal-form.tsx similarity index 72% rename from src/components/organisms/ProposalForm.tsx rename to src/components/organisms/proposal-form.tsx index 3a3d7f1..7f62318 100644 --- a/src/components/organisms/ProposalForm.tsx +++ b/src/components/organisms/proposal-form.tsx @@ -3,9 +3,8 @@ import { type FieldError, FormProvider, useFieldArray, useForm } from 'react-hoo import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' -import { type MemberFormValues, defaultMemberFormValues } from 'models' -import { SPACING } from 'util/constants' import { truncate } from 'util/helpers' +import { SPACING } from 'util/style.constants' import { valid } from 'util/validation/zod' import { @@ -14,6 +13,7 @@ import { Flex, FormCard, FormControl, + HStack, IconButton, NumberInput, Stack, @@ -25,15 +25,10 @@ import { Thead, Tr, } from '@/atoms' -import { InputWithButton } from '@/molecules' -import { - FieldControl, - InputField, - RadioGroupField, - TextareaField, -} from '@/molecules/FormFields' +import { InputWithButton, Truncate } from '@/molecules' +import { FieldControl, TextareaField } from '@/molecules/form-fields' -import { DeleteIcon } from 'assets/tsx' +import { ProposerFormValues } from '../../types/proposal.types' export declare enum Exec { /** @@ -54,11 +49,13 @@ export declare enum Exec { /** @see @haveanicedavid/cosmos-groups-ts/types/proto/cosmos/group/v1/types */ export type ProposalFormValues = { group_policy_address: string - metadata?: string - proposers?: Array + metadata: string + proposers: ProposerFormValues[] Exec: Exec } +export type ProposalFormKeys = keyof ProposalFormValues + export const defaultProposalFormValues: ProposalFormValues = { group_policy_address: '', metadata: '', @@ -68,19 +65,21 @@ export const defaultProposalFormValues: ProposalFormValues = { const resolver = zodResolver( z.object({ - group_policy_address: valid.bech32, + group_policy_address: valid.bech32Address, metadata: valid.json.optional(), - proposers: valid.bech32.array(), + proposers: valid.bech32Address.array(), }), ) export const ProposalForm = ({ btnText = 'Submit', defaultValues, + disabledFields = [], onSubmit, }: { btnText?: string defaultValues: ProposalFormValues + disabledFields?: ProposalFormKeys[] onSubmit: (data: ProposalFormValues) => void }) => { const [proposerAddr, setProposerAddr] = useState('') @@ -89,7 +88,7 @@ export const ProposalForm = ({ fields: proposerFields, append, remove, - } = useFieldArray({ control: form.control }) + } = useFieldArray({ control: form.control, name: 'proposers' }) const { watch, setValue, @@ -106,12 +105,12 @@ export const ProposalForm = ({ }) function validateAddress(addr: string): boolean { - if (proposerFields.find((m) => m === addr)) { + if (proposerFields.find((m) => m.address === addr)) { form.setError('proposers', { type: 'invalid', message: 'Address already added' }) return false } try { - valid.bech32.parse(addr) + valid.bech32Address.parse(addr) return true } catch (err) { if (err instanceof z.ZodError) { @@ -121,10 +120,10 @@ export const ProposalForm = ({ } } - function addMember(): void { + function addProposer(): void { if (!validateAddress(proposerAddr)) return - const member: MemberFormValues = { ...defaultMemberFormValues, address: proposerAddr } - append(member) + const proposer: ProposerFormValues = { address: proposerAddr } + append(proposer) setProposerAddr('') } @@ -133,10 +132,7 @@ export const ProposalForm = ({
- - - - + {/* Because of how the form is structured, we need a controlled value which is associated with the `members` array, but doesn't @@ -157,7 +153,7 @@ export const ProposalForm = ({ } setProposerAddr(e.target.value) }} - onBtnClick={addMember} + onBtnClick={addProposer} > {'+ Add'} @@ -168,18 +164,25 @@ export const ProposalForm = ({ - - + + - {controlledProposerFields.map((proposer, i) => ( - - - + + ))} diff --git a/src/components/templates/proposal-template.tsx b/src/components/templates/proposal-template.tsx new file mode 100644 index 0000000..e234996 --- /dev/null +++ b/src/components/templates/proposal-template.tsx @@ -0,0 +1,115 @@ +import { useState } from '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, + submit, + steps, + text, +}: { + disabledProposalFormFields?: ProposalFormKeys[] + initialProposalFormValues: ProposalFormValues + /** ID of group, used for redirect link */ + linkToProposalId?: 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) + + function handleProposalSubmit(values: ProposalFormValues) { + setProposalValues(values) + nextStep() + } + + function handlePrev() { + setPriorStep(activeStep) + prevStep() + } + + async function handleSubmit(policyValues: ProposalFormValues) { + setSubmitting(true) + const success = await submit({ + ...proposalValues, + }) + setSubmitting(false) + if (success) nextStep() + } + + function renderStep() { + switch (activeStep) { + case 0: + return ( + + + + ) + case 1: + return ( + + + + ) + default: + return null + } + } + + return ( + + + + + {steps[activeStep]} + + {renderStep()} + + + ) +} diff --git a/src/pages/ProposalCreate.tsx b/src/pages/ProposalCreate.tsx deleted file mode 100644 index dfcf3fb..0000000 --- a/src/pages/ProposalCreate.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' - -import { type GroupPolicyFormValues } from 'models' -import { TOAST_DEFAULTS } from 'util/constants' -import { toErrorWithMessage } from 'util/errors' - -import { useSteps, useToast } from 'hooks/chakra' -import { createGroupWithPolicy } from 'store/Group' -import { Wallet } from 'store/Wallet' - -import { AnimatePresence, Button, RouteLink, Stack, Text } from '@/atoms' -import { HorizontalSlide } from '@/molecules/animations' -import { - type GroupFormValues, - defaultGroupFormValues, - GroupForm, -} from '@/organisms/GroupForm' -import { - defaultGroupPolicyFormValues, - GroupPolicyForm, -} from '@/organisms/GroupPolicyForm' -import { StepperTemplate } from '@/templates/StepperTemplate' - -const steps = ['Create Proposal', 'Finished'] - -export default function GroupCreate() { - const toast = useToast() - const navigate = useNavigate() - const { activeStep, nextStep, prevStep /* reset, setStep */ } = useSteps({ - initialStep: 0, - }) - const [groupValues, setGroupValues] = useState({ - ...defaultGroupFormValues, - admin: Wallet.account?.address ?? '', - }) - const [submitting, setSubmitting] = useState(false) - const [priorStep, setPriorStep] = useState(0) - - function handleNext() { - setPriorStep(activeStep) - nextStep() - } - - function handlePrev() { - setPriorStep(activeStep) - prevStep() - } - - function handleGroupSubmit(values: GroupFormValues) { - setGroupValues(values) - nextStep() - } - - async function handleCreate(policyValues: GroupPolicyFormValues) { - setSubmitting(true) - try { - const { transactionHash } = await createGroupWithPolicy({ - ...groupValues, - ...policyValues, - }) - const time = 3000 - toast({ - ...TOAST_DEFAULTS, - title: 'Group created', - description: 'Transaction hash: ' + transactionHash, - status: 'success', - duration: time, - }) - handleNext() - setTimeout(() => navigate('/'), time + 500) - } catch (err) { - const msg = toErrorWithMessage(err).message - console.error(err) - toast({ - ...TOAST_DEFAULTS, - title: 'Group creation failed', - description: msg, - status: 'error', - duration: 9000, - }) - } finally { - setSubmitting(false) - } - } - - function renderStep() { - switch (activeStep) { - case 0: - return ( - - - - ) - case 1: - return ( - - - - ) - case 2: - return ( - - - - ) - default: - return null - } - } - - return ( - - {renderStep()} - - ) -} - -const Finished = () => ( - - You have successfully set up your group and group policy. - - -) diff --git a/src/pages/proposal-create.tsx b/src/pages/proposal-create.tsx new file mode 100644 index 0000000..662c30d --- /dev/null +++ b/src/pages/proposal-create.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' + +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 GroupTemplate from '@/templates/group-template' +import ProposalTemplate from '@/templates/proposal-template' + +import { createProposal } from '../api/proposal.actions' + +export default function ProposalCreate() { + const { toastErr, toastSuccess } = useTxToasts() + const [newProposalId, setNewProposalId] = useState() + + async function handleCreate(values: ProposalFormValues): Promise { + try { + const { transactionHash, proposalId } = await createProposal(values) + setNewProposalId(proposalId?.toString()) + toastSuccess(transactionHash, 'Proposal created!') + return true + } catch (err) { + handleError(err) + toastErr(err, 'Proposal could not be created:') + return false + } + } + + if (!Wallet.account?.address) return null + + return ( + + ) +} diff --git a/src/routes.tsx b/src/routes.tsx index 58e79f4..3b0f459 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -20,11 +20,8 @@ export const Routes = () => { } /> } /> -<<<<<<< Updated upstream:src/routes.tsx } /> -======= } /> ->>>>>>> Stashed changes:src/Routes.tsx } /> diff --git a/src/types/proposal.types.ts b/src/types/proposal.types.ts new file mode 100644 index 0000000..559f934 --- /dev/null +++ b/src/types/proposal.types.ts @@ -0,0 +1,3 @@ +export type ProposerFormValues = { + address: string +} From f1511ded357d06c0021f5ce968863c557ee7de70 Mon Sep 17 00:00:00 2001 From: Pablo Date: Tue, 20 Sep 2022 20:40:22 -0600 Subject: [PATCH 3/8] feat: proposal create, detail and list --- src/api/proposal.actions.ts | 37 ++++++++- .../organisms/group-proposals-table.tsx | 56 ++++++++++++++ src/components/organisms/proposal-form.tsx | 12 +-- .../templates/proposal-template.tsx | 23 +++--- src/hooks/use-query.ts | 22 ++++++ src/pages/group-details.tsx | 24 ++++-- src/pages/proposal-create.tsx | 11 ++- src/pages/proposal-details.tsx | 76 +++++++++++++++++++ src/routes.tsx | 8 +- src/util/validation/zod.ts | 6 ++ 10 files changed, 247 insertions(+), 28 deletions(-) create mode 100644 src/components/organisms/group-proposals-table.tsx create mode 100644 src/pages/proposal-details.tsx diff --git a/src/api/proposal.actions.ts b/src/api/proposal.actions.ts index 90fea1f..6ba76f4 100644 --- a/src/api/proposal.actions.ts +++ b/src/api/proposal.actions.ts @@ -1,9 +1,16 @@ +import { QueryProposalsByGroupPolicyResponse } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/query' +import { Proposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' +import Long from 'long' + import { throwError } from 'util/errors' -import { signAndBroadcast } from 'store' +import { Group, signAndBroadcast } from 'store' import { ProposalFormValues } from '@/organisms/proposal-form' +import { UIGroup } from '../types' + +import { toUIGroup } from './group.utils' import { createProposalMsg } from './proposal.messages' export async function createProposal(values: ProposalFormValues) { @@ -21,3 +28,31 @@ export async function createProposal(values: ProposalFormValues) { 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 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/components/organisms/group-proposals-table.tsx b/src/components/organisms/group-proposals-table.tsx new file mode 100644 index 0000000..91f769b --- /dev/null +++ b/src/components/organisms/group-proposals-table.tsx @@ -0,0 +1,56 @@ +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 updatedBg = useColorModeValue('blue.100', 'blue.800') + const tailSize = useBreakpointValue({ base: 4, sm: 6, md: 25, lg: 35, xl: 100 }) + return ( + + +
Proposers addedActionsAccounts addedWeight
{proposer.address} - - remove(i)} /> - + {controlledProposerFields.map((member, i) => ( +
+ + + + remove(i)} /> +
+ + 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 index 7f62318..3c7094b 100644 --- a/src/components/organisms/proposal-form.tsx +++ b/src/components/organisms/proposal-form.tsx @@ -60,14 +60,13 @@ export const defaultProposalFormValues: ProposalFormValues = { group_policy_address: '', metadata: '', proposers: [], - Exec: 1, + Exec: -1, } const resolver = zodResolver( z.object({ - group_policy_address: valid.bech32Address, metadata: valid.json.optional(), - proposers: valid.bech32Address.array(), + proposers: valid.proposers, }), ) @@ -97,6 +96,7 @@ export const ProposalForm = ({ } = form const watchFieldArray = watch('proposers') + const controlledProposerFields = proposerFields.map((field, index) => { return { ...field, @@ -116,6 +116,7 @@ export const ProposalForm = ({ if (err instanceof z.ZodError) { form.setError('proposers', { type: 'invalid', message: err.issues[0].message }) } + return false } } @@ -135,12 +136,12 @@ export const ProposalForm = ({ {/* Because of how the form is structured, we need a controlled - value which is associated with the `members` array, but doesn't + value which is associated with the `proposers` array, but doesn't directly add to it */} @@ -148,6 +149,7 @@ export const ProposalForm = ({ name="proposerAddr" value={proposerAddr} onChange={(e) => { + console.log('PROPOSER errors.proposers', errors.proposers) if (errors.proposers) { form.clearErrors('proposers') } diff --git a/src/components/templates/proposal-template.tsx b/src/components/templates/proposal-template.tsx index e234996..54bb004 100644 --- a/src/components/templates/proposal-template.tsx +++ b/src/components/templates/proposal-template.tsx @@ -31,6 +31,7 @@ export default function ProposalTemplate({ initialProposalFormValues, disabledProposalFormFields, linkToProposalId, + groupPolicyAddr, submit, steps, text, @@ -39,6 +40,7 @@ export default function ProposalTemplate({ initialProposalFormValues: ProposalFormValues /** ID of group, used for redirect link */ linkToProposalId?: string + groupPolicyAddr: string submit: (values: ProposalFormValues) => Promise steps: string[] text: { @@ -55,33 +57,30 @@ export default function ProposalTemplate({ const [submitting, setSubmitting] = useState(false) const [priorStep, setPriorStep] = useState(0) - function handleProposalSubmit(values: ProposalFormValues) { - setProposalValues(values) - nextStep() - } - - function handlePrev() { - setPriorStep(activeStep) - prevStep() - } - - async function handleSubmit(policyValues: ProposalFormValues) { + 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} diff --git a/src/hooks/use-query.ts b/src/hooks/use-query.ts index cf3c47c..a1b5fa0 100644 --- a/src/hooks/use-query.ts +++ b/src/hooks/use-query.ts @@ -8,6 +8,8 @@ import { import { fetchGroupMembers } from 'api/member.actions' import { fetchGroupPolicies } from 'api/policy.actions' +import { fetchProposalById, fetchProposalsByPolicyAddr } from '../api/proposal.actions' + export function useGroup(groupId?: string) { return useQuery( ['group', groupId], @@ -18,6 +20,26 @@ export function useGroup(groupId?: string) { ) } +export function useProposal(proposalId?: string) { + return useQuery( + ['proposal', proposalId], + () => { + return fetchProposalById(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 0744a1b..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 { @@ -20,22 +27,24 @@ import { } from '@/atoms' import { GroupMembersTable } from '@/organisms/group-members-table' import { GroupPolicyTable } from '@/organisms/group-policy-table' -import { MdCreate } from "react-icons/md"; + +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`) @@ -82,6 +91,7 @@ export default function GroupDetails() { +
) diff --git a/src/pages/proposal-create.tsx b/src/pages/proposal-create.tsx index 662c30d..5b8bccd 100644 --- a/src/pages/proposal-create.tsx +++ b/src/pages/proposal-create.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useParams } from 'react-router-dom' import { handleError } from 'util/errors' import { defaultGroupFormValues, defaultGroupPolicyFormValues } from 'util/form.constants' @@ -7,22 +8,27 @@ import { Wallet } from 'store' import { useTxToasts } from 'hooks/useToasts' import { defaultProposalFormValues, ProposalFormValues } from '@/organisms/proposal-form' -import GroupTemplate from '@/templates/group-template' 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 { + console.log('AAAAA', values) const { transactionHash, proposalId } = await createProposal(values) + values.group_policy_address = policy.address setNewProposalId(proposalId?.toString()) toastSuccess(transactionHash, 'Proposal created!') return true } catch (err) { + console.error('err', err) handleError(err) toastErr(err, 'Proposal could not be created:') return false @@ -33,6 +39,7 @@ export default function ProposalCreate() { return ( { + const interval = setInterval(() => setTime(Date.now()), 1000) + return () => { + clearInterval(interval) + } + }, []) + // console.log('proposal :>> ', proposal) + + return ( + + + {`Proposal: ${proposal?.id}`} + + + + + + Yes + + + {proposal?.final_tally_result.yes_count} + + + + + + No + + + {proposal?.final_tally_result.yes_count} + + + + + No With Veto + + + {proposal?.final_tally_result.yes_count} + + + + + Abstain + + + {proposal?.final_tally_result.yes_count} + + + + + + + ) +} diff --git a/src/routes.tsx b/src/routes.tsx index 3b0f459..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,7 +24,10 @@ export const Routes = () => { } /> } /> - } /> + } /> + + + }> } /> diff --git a/src/util/validation/zod.ts b/src/util/validation/zod.ts index 889d4e4..f9c9e9d 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') @@ -62,6 +67,7 @@ export const valid = { description, groupOrAddress, members, + proposers, json, url, positiveNumber, From 68a0c036cc61e35907bac03ae74988982f728176 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 21 Sep 2022 18:00:11 -0600 Subject: [PATCH 4/8] feat: add vote transaction --- src/api/proposal.actions.ts | 35 ++++- src/api/proposal.messages.ts | 2 + src/api/vote.messages.ts | 21 +++ src/components/organisms/proposal-form.tsx | 10 +- src/components/organisms/vote-form.tsx | 128 ++++++++++++++++++ .../templates/proposal-template.tsx | 3 +- src/hooks/use-query.ts | 16 ++- src/pages/proposal-create.tsx | 5 +- src/pages/proposal-details.tsx | 103 ++++++++++++-- src/util/validation/zod.ts | 3 + 10 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 src/api/vote.messages.ts create mode 100644 src/components/organisms/vote-form.tsx diff --git a/src/api/proposal.actions.ts b/src/api/proposal.actions.ts index 6ba76f4..937f4ae 100644 --- a/src/api/proposal.actions.ts +++ b/src/api/proposal.actions.ts @@ -1,5 +1,8 @@ import { QueryProposalsByGroupPolicyResponse } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/query' -import { Proposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' +import { + Proposal, + Vote, +} from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/types' import Long from 'long' import { throwError } from 'util/errors' @@ -7,11 +10,13 @@ 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 { toUIGroup } from './group.utils' import { createProposalMsg } from './proposal.messages' +import { createVoteMsg } from './vote.messages' export async function createProposal(values: ProposalFormValues) { try { @@ -29,6 +34,19 @@ export async function createProposal(values: ProposalFormValues) { } } +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 fetchProposalById(proposalId?: string | Long): Promise { if (!Group.query) throwError('Wallet not initialized') if (!proposalId) throwError('proposalId is required') @@ -42,6 +60,21 @@ export async function fetchProposalById(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 { diff --git a/src/api/proposal.messages.ts b/src/api/proposal.messages.ts index 0672be1..0c60acd 100644 --- a/src/api/proposal.messages.ts +++ b/src/api/proposal.messages.ts @@ -15,3 +15,5 @@ export function createProposalMsg(values: ProposalFormValues) { } return MsgWithTypeUrl.submitProposal(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/proposal-form.tsx b/src/components/organisms/proposal-form.tsx index 3c7094b..16ea76f 100644 --- a/src/components/organisms/proposal-form.tsx +++ b/src/components/organisms/proposal-form.tsx @@ -73,11 +73,13 @@ const resolver = zodResolver( export const ProposalForm = ({ btnText = 'Submit', defaultValues, + isLoading, disabledFields = [], onSubmit, }: { btnText?: string defaultValues: ProposalFormValues + isLoading: boolean disabledFields?: ProposalFormKeys[] onSubmit: (data: ProposalFormValues) => void }) => { @@ -142,8 +144,8 @@ export const ProposalForm = ({ required error={errors.proposers as FieldError} // TODO fix type cast name="proposerAddr" - label="Add member accounts" - helperText="Input the addresses of the members of this group." + label="Add proposers accounts" + helperText="Input the addresses of the proposers of this group." > )} - + 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 index 54bb004..f30a463 100644 --- a/src/components/templates/proposal-template.tsx +++ b/src/components/templates/proposal-template.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useBoolean } from '@chakra-ui/react' import type { GroupWithPolicyFormValues } from 'types' @@ -56,7 +57,6 @@ export default function ProposalTemplate({ ) const [submitting, setSubmitting] = useState(false) const [priorStep, setPriorStep] = useState(0) - async function handleSubmit(proposalValues: ProposalFormValues) { setSubmitting(true) const success = await submit({ @@ -79,6 +79,7 @@ export default function ProposalTemplate({ With Policy Address: {groupPolicyAddr} { + return fetchProposalVotesById(proposalId) + }, + { enabled: !!proposalId }, + ) +} + export function useGroupPolicyProposals(groupAddr: string) { return useQuery( ['proposalsGroupPolicy', groupAddr], diff --git a/src/pages/proposal-create.tsx b/src/pages/proposal-create.tsx index 5b8bccd..a8c279a 100644 --- a/src/pages/proposal-create.tsx +++ b/src/pages/proposal-create.tsx @@ -21,16 +21,15 @@ export default function ProposalCreate() { const [policy] = policies ?? [] async function handleCreate(values: ProposalFormValues): Promise { try { - console.log('AAAAA', values) const { transactionHash, proposalId } = await createProposal(values) values.group_policy_address = policy.address setNewProposalId(proposalId?.toString()) - toastSuccess(transactionHash, 'Proposal created!') + toastSuccess(transactionHash, 'Vote created!') return true } catch (err) { console.error('err', err) handleError(err) - toastErr(err, 'Proposal could not be created:') + toastErr(err, 'Vote could not be created:') return false } } diff --git a/src/pages/proposal-details.tsx b/src/pages/proposal-details.tsx index d0d7779..eb0785f 100644 --- a/src/pages/proposal-details.tsx +++ b/src/pages/proposal-details.tsx @@ -1,24 +1,64 @@ import { useEffect, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { MdEject } from 'react-icons/md' import { 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 } from 'hooks/use-query' +import { useProposal, useProposalVotes } from 'hooks/use-query' -import { Heading, HStack, PageContainer, Stack, Text } from '@/atoms' +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, voteProposal } from '../api/proposal.actions' +import { createVoteMsg } from '../api/vote.messages' +import { useTxToasts } from '../hooks/useToasts' +import { Wallet } from '../store' +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 } = useParams() + 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()) + const defaultValues = { vote: 'VOTE_OPTION_YES' } useEffect(() => { const interval = setInterval(() => setTime(Date.now()), 1000) @@ -26,13 +66,46 @@ export default function ProposalDetails() { clearInterval(interval) } }, []) - // console.log('proposal :>> ', proposal) - + console.log('votes ==>', votes) + 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 { + console.log('voteProposal', data) + const resp = await voteProposal(data) + console.log('response', resp) + toastSuccess(resp.transactionHash, 'Vote created!') + return true + } catch (err) { + handleError(err) + toastErr(err, 'Vote could not be created:') + return false + } finally { + setLoading.off() + } + } return ( - {`Proposal: ${proposal?.id}`} + +
+ {`Proposal: ${proposal?.id}`} + {`Status: ${proposal?.status}`} +
+ + + +
+ {`Results: `} @@ -40,7 +113,7 @@ export default function ProposalDetails() { Yes - {proposal?.final_tally_result.yes_count} + {voteStats?.VOTE_OPTION_YES ? voteStats?.VOTE_OPTION_YES : 0} @@ -49,7 +122,7 @@ export default function ProposalDetails() { No - {proposal?.final_tally_result.yes_count} + {voteStats?.VOTE_OPTION_NO ? voteStats?.VOTE_OPTION_NO : 0} @@ -57,7 +130,9 @@ export default function ProposalDetails() { No With Veto - {proposal?.final_tally_result.yes_count} + {voteStats?.VOTE_OPTION_NO_WITH_VETO + ? voteStats?.VOTE_OPTION_NO_WITH_VETO + : 0} @@ -65,11 +140,21 @@ export default function ProposalDetails() { Abstain - {proposal?.final_tally_result.yes_count} + {voteStats?.VOTE_OPTION_ABSTAIN ? voteStats?.VOTE_OPTION_ABSTAIN : 0} + {`Vote: `} + + + + +
) diff --git a/src/util/validation/zod.ts b/src/util/validation/zod.ts index f9c9e9d..a1615af 100644 --- a/src/util/validation/zod.ts +++ b/src/util/validation/zod.ts @@ -24,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') @@ -64,6 +66,7 @@ export const valid = { emptyStr, boolStr, name, + voteOption, description, groupOrAddress, members, From a2966ead12176b9834225ff3117ffdee8fd0cd02 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 21 Sep 2022 20:03:44 -0600 Subject: [PATCH 5/8] feat: proposal execute --- src/api/proposal.actions.ts | 16 +++++++++++++++- src/api/proposal.messages.ts | 16 ++++++++++++++-- src/pages/proposal-details.tsx | 35 +++++++++++++++++++++++++++++----- src/types/proposal.types.ts | 8 ++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/api/proposal.actions.ts b/src/api/proposal.actions.ts index 937f4ae..b7a22e8 100644 --- a/src/api/proposal.actions.ts +++ b/src/api/proposal.actions.ts @@ -13,9 +13,10 @@ 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 } from './proposal.messages' +import { createProposalMsg, execProposalMsg } from './proposal.messages' import { createVoteMsg } from './vote.messages' export async function createProposal(values: ProposalFormValues) { @@ -47,6 +48,19 @@ export async function voteProposal(values: VoteFormValues) { } } +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') diff --git a/src/api/proposal.messages.ts b/src/api/proposal.messages.ts index 0c60acd..a3bb0e6 100644 --- a/src/api/proposal.messages.ts +++ b/src/api/proposal.messages.ts @@ -1,7 +1,13 @@ -import { MsgSubmitProposal } from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/tx' +import { + MsgExec, + MsgSubmitProposal, +} from '@haveanicedavid/cosmos-groups-ts/types/codegen/cosmos/group/v1/tx' +import { Long } from '@osmonauts/helpers' import { ProposalFormValues } from '@/organisms/proposal-form' +import { ProposalExecMsg } from '../types/proposal.types' + import { MsgWithTypeUrl } from './cosmosgroups' export function createProposalMsg(values: ProposalFormValues) { @@ -16,4 +22,10 @@ export function createProposalMsg(values: ProposalFormValues) { 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/pages/proposal-details.tsx b/src/pages/proposal-details.tsx index eb0785f..e1fce60 100644 --- a/src/pages/proposal-details.tsx +++ b/src/pages/proposal-details.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Controller, useForm } from 'react-hook-form' import { MdEject } from 'react-icons/md' -import { useParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import { Box, Container, @@ -27,10 +27,11 @@ import { Button, Heading, HStack, PageContainer, RouteLink, Stack, Text } from ' import { ProposalFormValues } from '@/organisms/proposal-form' import { defaultVoteFormValues, VoteForm, VoteFormValues } from '@/organisms/vote-form' -import { createProposal, voteProposal } from '../api/proposal.actions' +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', @@ -51,14 +52,14 @@ const buildVoteStats = (votes?: Vote[]) => { } export default function ProposalDetails() { - const { proposalId } = useParams() + 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()) - const defaultValues = { vote: 'VOTE_OPTION_YES' } useEffect(() => { const interval = setInterval(() => setTime(Date.now()), 1000) @@ -86,6 +87,25 @@ export default function ProposalDetails() { 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 ( @@ -99,7 +119,12 @@ export default function ProposalDetails() { >{`Status: ${proposal?.status}`} - diff --git a/src/types/proposal.types.ts b/src/types/proposal.types.ts index 559f934..c04ecd7 100644 --- a/src/types/proposal.types.ts +++ b/src/types/proposal.types.ts @@ -1,3 +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 +} From ce41244ed97088755be083c0c8b364c6de0b6b74 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 22 Sep 2022 16:52:39 -0600 Subject: [PATCH 6/8] feat: proposal now includes bank message transaction --- makefile | 1 + src/api/bank.messages.ts | 27 ++++++++++ src/api/cosmosgroups.ts | 1 + src/api/group.actions.ts | 23 ++++++++- src/api/group.messages.ts | 8 ++- src/api/proposal.messages.ts | 24 +++++++-- src/components/organisms/proposal-form.tsx | 57 +++++++++++++++++++++- src/pages/proposal-details.tsx | 4 +- src/types/bank.types.ts | 7 +++ 9 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 src/api/bank.messages.ts create mode 100644 src/types/bank.types.ts diff --git a/makefile b/makefile index 1f432c5..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 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..bb05742 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 { @@ -18,6 +24,21 @@ export async function createGroupWithPolicy(values: GroupWithPolicyFormValues) { const idRaw = raw.events[0].attributes[0].value groupId = JSON.parse(idRaw) } + const coin: Coin = { + denom: 'stake', + amount: '1000000000', + } + // TODO: this is for demo purposes only. Code should be removed. + // Send some coins to policy address + const policies = await fetchGroupPolicies(groupId) + const policy = policies[0] + const msgSend: BankSendType = { + amount: [coin], + from_address: Wallet.account?.address ? Wallet.account.address : '', + to_address: policy.address, + } + const bankSendRes = MsgBankWithTypeUrl.send(msgSend) + const dataSend = await signAndBroadcast([bankSendRes]) return { ...data, groupId } } catch (error) { throwError(error) 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.messages.ts b/src/api/proposal.messages.ts index a3bb0e6..a7c0728 100644 --- a/src/api/proposal.messages.ts +++ b/src/api/proposal.messages.ts @@ -1,19 +1,37 @@ +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 { Long } from '@osmonauts/helpers' +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 } from './cosmosgroups' +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: [], // For demo purposes, proposal have empty messages. + 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), diff --git a/src/components/organisms/proposal-form.tsx b/src/components/organisms/proposal-form.tsx index 16ea76f..b921862 100644 --- a/src/components/organisms/proposal-form.tsx +++ b/src/components/organisms/proposal-form.tsx @@ -1,5 +1,15 @@ 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' @@ -15,7 +25,6 @@ import { FormControl, HStack, IconButton, - NumberInput, Stack, Table, TableContainer, @@ -49,6 +58,8 @@ export declare enum Exec { /** @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 @@ -59,6 +70,8 @@ export type ProposalFormKeys = keyof ProposalFormValues export const defaultProposalFormValues: ProposalFormValues = { group_policy_address: '', metadata: '', + amount: 150, + msgToAddr: '', proposers: [], Exec: -1, } @@ -66,6 +79,8 @@ export const defaultProposalFormValues: ProposalFormValues = { const resolver = zodResolver( z.object({ metadata: valid.json.optional(), + amount: valid.positiveNumber, + msgToAddr: valid.bech32Address, proposers: valid.proposers, }), ) @@ -84,6 +99,8 @@ export const ProposalForm = ({ 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, @@ -151,7 +168,6 @@ export const ProposalForm = ({ name="proposerAddr" value={proposerAddr} onChange={(e) => { - console.log('PROPOSER errors.proposers', errors.proposers) if (errors.proposers) { form.clearErrors('proposers') } @@ -194,6 +210,43 @@ export const ProposalForm = ({ )} + + + + 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)) + }} + > + + + + + + + + +