-
Notifications
You must be signed in to change notification settings - Fork 21
Adding an action to DAO DAO
DAO DAO actions allow users to interact with the chain using a nice UI instead of typing complex JSON incantations. For example, the JSON for sending 1 $JUNO from a DAO's treasury looks like this:
{
bank: {
send: {
amount: [{ denom: "ujuno", amount: "1000000" }],
to_address: "receiver"
}
}
}
Making people type this out every time they'd like to spend some tokens is a pain, so we made a spend action. The spend action is a little form wherein proposal creators can select a token denomination and an amount, and the corresponding JSON will be generated for them. It looks something like this:
This is nice. What is also nice is that writing your own custom actions isn't too hard. Here we'll walk through the process of doing that.
The code related to actions can be found in
packages/stateful/actions
.
In that folder, there are a couple pieces:
-
actions/
holds all of the logic related to each action. -
actions/index.tsx
is responsible for registering the available actions. -
components/
holds all of the stateless React components related to each action as well as general components used by many actions. -
react/
holds the React Context provider and hooks used to access actions from React apps. This does not matter for building your own actions and can be ignored.
Action types are stored separately in
packages/types/actions.ts
.
We split the business logic and visual logic into two parts because we want to minimize the amount of dependency the visual logic has on state. We currently fetch all of our state directly from a RPC node, but down the line we may want to switch over to using an indexer. If we did that swap, we wouldn't need to change any UI code as the components are stateless and do not know where state is coming from. Additionally, this allows us to create stateless Storybook stories and iterate on the visual design quickly (see the Manage SubDaos story for an example).
The voting module adapters
(packages/stateful/voting-module-adapter
)
and proposal module adapters
(packages/stateful/proposal-module-adapter
)
occasionally have actions that only apply to DAOs and proposals that use them.
For example, the CwdVotingCw20Staked
voting module
adapter
takes advantage of CW20
governance tokens, and as such would want to provide an action that lets the DAO
mint more governance tokens. Both adapter systems contain useActions
hooks
that let you add actions just for those contexts, and are organized and defined
in a very similar way to the actions package itself. See the
CwdVotingCw20Staked
voting module adapter's useActions
hook
and its mint action
definition
for an example.
Something to keep in mind, which is explained further below, is that actions are
defined as maker functions that take an options
parameter so they can
dynamically respond to the context they are being used in. These options
are
provided through the actions
provider
and can be accessed on all DAO pages through the useActionOptions
hook.
Since the context for all actions is the same, these options can be accessed in
the adapter code via that hook, as you can see is being done for the mint action
in CwdVotingCw20Staked
voting module adapter's useActions
hook
mentioned above.
The core action logic can be found in the actions/
folder
of
packages/stateful/actions
,
and the Action
type definition can be found
here.
Below is each field of the Action
type definition, explained:
- A unique
key
differentiates it from other actions. For a core action defined in thestateful
package, a new enum variant would be added to theCoreActionKey
enum in the type definition file. For an action defined inside an adapter, such as the mint action referenced previously, the enum variant would be added to theAdapterActionKey
enum instead to differentiate it from the actions provided by the React Context provider available on all DAO pages. Adapter actions are only available in the context of the adapter, and are thus manually inserted at a later point. See how actions are pieced together from various sources in the dApp here. - An
Icon
,label
, anddescription
display in the action list. - A pretty, stateless form
Component
collects and displays user input. - A
useDefaults
hook fetches any necessary state, and subsequently returns an object containing default values for the action's user input fields, which the stateless component lets the user change. The returned object should conform to the data interface for this action. - A
useTransformToCosmos
hook returns a function that takes an object in the shape of the data interface (the same shape as the object returned byuseDefaults
) and converts it into a JSON object that the underlying blockchain can parse and execute (i.e. a CosmWasm message). - A
useDecodedCosmosMsg
hook takes a decoded CosmWasm message object and returns (1) whether or not the message matches the action and, if the message is a match, (2) an object conforming to the same data interface referenced above with the values extracted from the message. This is used when rendering a proposal that has been created, matching CosmWasm messages to actions, so that the associated data can be rendered using the pretty actionComponent
s. This hook is called for all actions on every message, and the first match returned is displayed. Many actions are subsets of theExecute Smart Contract
action, and all actions are subsets of theCustom
action, so those two are always checked last, in that order.
Actions are defined as maker functions which take some options
to provide
context. This type can also be found in the action types definition
file
towards the bottom. If an action maker returns null
, it will not be included.
This may happen if the action does not fit into the context provided by the
options
parameter. For example, the Manage SubDaos
action is only relevant
for v2 DAOS, and as such its maker function returns null
in the context of a
wallet or a v1 DAO, seen
here.
Components define the UI form the user interacts with to create and view actions. We use react-hook-form for all of our forms. If at any point you're confused by the form code, that's likely the place to look.
Action components take a handful of props. The props interface is defined in the action types definition file referenced previously, and its properties are explained below:
- A
fieldNamePrefix
string. Since actions are all included in onereact-hook-form
form context, we need to perform some magic to properly register fields with the form so that user input is stored in state correctly. This string provides the prefix for field names used in the form and should be prepended to all inputs registered with the names of fields in your action data (like "name"). This is confusing and tedious, so poke around at existing actions and the relationship between their data interfaces and components to see how this functions. In essence, all you need to do is ensure to prefix thefieldName
props withfieldNamePrefix
. This looks something likefieldName={fieldNamePrefix + 'name'}
in most of the stateless inputs that have been built for you already (mentioned below). - An
allActionsWithData
object array. This contains a list of all action keys and data objects in the proposal. You likely won't need to use this. It was added so that smart contract instantiate actions could extract their instantiated contract address from the transaction log and display them, after a proposal is executed. When there are multiple instantiate actions, we use the position (i.e. index) of each instantiate action in relation to the other instantiate actions to match the action to the instantiated contract address from the transaction log. Without access to the other action data and types, we would not be able to understand the effects of each action within the larger proposal context and its resulting transaction log. - An
index
number. The index of this action in the list of all actions. - A
data
object. This simply provides direct access to the data object for this action. This is essentially a shortcut for usingreact-hook-form
'swatch
andgetValue
functions to access field values. - An
isCreating
boolean. If this istrue
, the action is being displayed in the context of creating a new proposal and should be editable. Iffalse
, the action is being displayed as part of an existing proposal and should be read only.
When isCreating
is true
, there are a couple more props present:
- An
onRemove
function which removes this action. Typically, this will be associated with some sort ofx
button on your action. TheActionCard
handles this for you as long as you passonRemove
through. - An
errors
object, which may beundefined
. This is the nested object within theerrors
object ofreact-hook-form
'sformState
that corresponds to the action's form field errors. For example, to find if there are any errors for thename
field, we can just check the value oferrors?.name
. - An
addAction
function which lets the action add another action to the current proposal.
All actions are wrapped by react-hook-form
's lovely
FormProvider
component, and as
such your action may use the useFormContext
hook to get a variety of
helpful methods for dealing with your form.
The @dao-dao/stateless
package
contains a number of input components designed to work with these forms. You can
browse them by taking a look at the files in
packages/stateless/components/inputs
.
Likely everything you need can be found in here.
Now that we've covered all the pieces, let's walk through an example building an action together.
Here we'll be writing an action that handles the business of updating the name, description, and image for a DAO. We'll call the action "Update Info".
Our first step is to create a new UpdateInfo.tsx
file in
packages/stateful/actions/actions/
.
Before we actually get to writing the action logic, we'll want to decide what
fields we'd like our form to have. In this case, as we're simply updating the
config of the DAO, we can just give it the same shape as the actual DAO config.
We'll make that clear by creating a new type at the top of UpdateInfo.tsx
:
import { ConfigResponse } from "@dao-dao/types/contracts/CwdCore.v2";
type UpdateInfoData = ConfigResponse;
With that done, we can move down our list of properties in the Action
type
definition and make the required hooks.
For this particular action, we'd like the default values to be the values already being used by the DAO. That way, proposal creators don't need to bother with copying over all the stuff they aren't interested in changing.
Because our data has the same shape as the data on-chain, our useDefaults
hook
will simply query for the state on-chain, and return it. Since the maker
function receives an options
parameter with context information, we can access
the DAO's address via options.address
.
import { useRecoilValue } from "recoil";
import { configSelector } from "@dao-dao/state/recoil/selectors/contracts/CwdCore.v2";
import { UseDefaults } from "@dao-dao/types/actions";
const useDefaults: UseDefaults<UpdateInfoData> = () => {
const config = useRecoilValue(
configSelector({ contractAddress: options.address })
);
if (!config) {
throw new Error("Failed to load config from chain.");
}
return config;
};
Having already defined the shape of our data, we can easily write the
useTransformToCosmos
hook. What we'll want to do is convert our data object
(typed UpdateInfoData
above) into an update_config
smart contract execute
message. Again, we can access the DAO's address via options.address
from the
provided options
parameter in the maker function. We also take advantage of
the makeWasmMessage
function from the utils
package
which serializes the JSON into the format the chain expects, which in this case
is stringifying the msg
object and converting it into a base64
string. This
is abstracted away so you don't have to worry about how it works.
import { useCallback } from "react";
import { UseTransformToCosmos } from "@dao-dao/types/actions";
import { makeWasmMessage } from "@dao-dao/utils";
const useTransformToCosmos: UseTransformToCosmos<UpdateInfoData> = () =>
useCallback(
(data: UpdateInfoData) =>
makeWasmMessage({
wasm: {
execute: {
contract_addr: options.address,
funds: [],
msg: {
update_config: {
config: data,
},
},
},
},
}),
[]
);
If you're not familiar with how on-chain messages work, this may appear a little arcane. Covering how all that works is out of scope for this tutorial, but Callum's excellent CosmWasm Zero To Hero guide would be a great place to look if you'd like to learn more.
This hook is tasked with recognizing a CosmWasm
message as
being a certain type of action. Every one of these will have a similar shape and
involve a relatively laborious process wherein the code checks that all the
fields it expects to be present in the message are present. This is essentially
the inverse operation of useTransformToCosmos
. If they are all present, it
informs the caller that it has found a match, and constructs a data object with
values extracted from the message to be used and displayed in the form. If a
field is missing or something doesn't look right, the caller is informed that
there is no match.
Here is how the "Update Info" action would be matched and its data extracted:
import { useMemo } from "react";
import { UseDecodedCosmosMsg } from "@dao-dao/types/actions";
const useDecodedCosmosMsg: UseDecodedCosmosMsg<UpdateInfoData> = (
msg: Record<string, any>
) =>
useMemo(
() =>
"wasm" in msg &&
"execute" in msg.wasm &&
"update_config" in msg.wasm.execute.msg &&
"config" in msg.wasm.execute.msg.update_config &&
"name" in msg.wasm.execute.msg.update_config.config &&
"description" in msg.wasm.execute.msg.update_config.config &&
"automatically_add_cw20s" in msg.wasm.execute.msg.update_config.config &&
"automatically_add_cw721s" in msg.wasm.execute.msg.update_config.config
? {
match: true,
data: {
name: msg.wasm.execute.msg.update_config.config.name,
description:
msg.wasm.execute.msg.update_config.config.description,
// Only add image url if it is in the message.
...(!!msg.wasm.execute.msg.update_config.config.image_url && {
image_url: msg.wasm.execute.msg.update_config.config.image_url,
}),
automatically_add_cw20s:
msg.wasm.execute.msg.update_config.config
.automatically_add_cw20s,
automatically_add_cw721s:
msg.wasm.execute.msg.update_config.config
.automatically_add_cw721s,
},
}
: { match: false },
[msg]
);
It's a bit of a chore.
Now we need to create the UI that the user will interact with. We'll start by
creating a new UpdateInfo.tsx
file in
packages/stateful/actions/components/
and exporting it in
packages/stateful/actions/components/index.tsx
(so we can neatly import it in our action logic file with all the hooks inside)
like below:
export * from "./UpdateInfo";
Lets walk through an extremely simple component to get a feel for what this
looks like. In this example, say that we only want to allow changing the name
field on the DAO config. Our action would then look something like this:
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { InfoEmoji, InputErrorMessage, TextInput } from "@dao-dao/stateless";
import { ActionComponent } from "@dao-dao/types/actions";
import { validateRequired } from "@dao-dao/utils";
import { ActionCard } from "./ActionCard";
export const UpdateInfoComponent: ActionComponent = ({
fieldNamePrefix,
errors,
onRemove,
isCreating,
}) => {
const { t } = useTranslation();
const { register } = useFormContext();
return (
<ActionCard
Icon={InfoEmoji}
onRemove={onRemove}
title={t("title.updateInfo")}
>
<TextInput
disabled={!isCreating}
error={errors?.name}
fieldName={fieldNamePrefix + "name"}
placeholder={t("form.name")}
register={register}
validation={[validateRequired]}
/>
<InputErrorMessage error={errors?.name} />
</ActionCard>
);
};
Here we have a single TextInput
component. It is associated with the name
field because it has the field name fieldNamePrefix + "name"
, and will be
disabled (i.e. read only) if the action is not being created. The action title
and input placeholder are retrieved from the internationalization
system we
use to support different languages in the UI.
We also added a new emoji component (InfoEmoji
) to
packages/stateless/components/emoji.tsx
to use as the icon for the component. The icon can be any component, but let's
use emojis to be consistent.
Q: How do I access contextual information, such as address
and chainId
?
A: You can access the ActionOptions
via the useActionOptions
hook defined in packages/stateful/actions/react/context.ts
. This provides you with the current action context, such as if we're in a DAO proposal or a wallet, the address of the DAO or wallet, the chainId
we're currently using, the bech32Prefix
of the chainId
, and the coreVersion
of the DAO if in a DAO context. Check out the full type in packages/types/actions.ts
.
Q: How are default values filled in here?
A: react-hook-form
handles this. The caller will take the default values
returned earlier and fill in the appropriate fields in the state so they get
loaded in the form. As long as you correctly use the fieldNamePrefix
value to
prefix your field names, they will all magically sync.
Q: How does this actually get submitted?
A: More magic. This form you're writing here is actually part of a much larger
form. When that much larger form gets submitted, some code runs which calls
useTransformToCosmos
on all the form data and outputs CosmWasm
messages.
The last thing we need to do before putting it all together is to add some metadata about our action. This consists of a couple things:
-
Add two new translation keys to the
en/translation.json
locale file. To do this, we add an"updateInfo"
key to the top-level"title"
object and an"updateInfoActionDescription"
key to the top-level"info"
object:{ "title": { "updateInfo": "Update Info" }, "info": { "updateInfoActionDescription": "Update the DAO's name, description, and image." } }
These keys will be surrounded by other keys in the file, but I've omitted them for brevity.
-
Pick an icon to represent the action. We'll use the
InfoEmoji
component we created earlier for the stateless component. -
Add a new variant to the
CoreActionKey
enum inpackages/stateful/actions/types.ts
to identify this action among the rest. We add it toCoreActionKey
and notAdapterActionKey
since we're creating a core action and not an adapter action:export enum CoreActionKey { ... UpdateInfo = "updateInfo", }
Now that we have created all our hooks and component for our action, we can finally write the action maker.
Since our action is only relevant in the context
of a DAO, we need to return null
if the context is anything other than a DAO.
We're also going to use the translation function, like in our component
definition above, to get the title and description text of our action.
Putting it all together, we get the following:
import { useCallback, useMemo } from "react";
import { useRecoilValue } from "recoil";
import { configSelector } from "@dao-dao/state/recoil/selectors/contracts/CwdCore.v2";
import { InfoEmoji } from "@dao-dao/stateless";
import {
ActionMaker,
ActionOptionsContextType,
CoreActionKey,
UseDecodedCosmosMsg,
UseDefaults,
UseTransformToCosmos,
} from "@dao-dao/types/actions";
import { ConfigResponse } from "@dao-dao/types/contracts/CwdCore.v2";
import { makeWasmMessage } from "@dao-dao/utils";
import { UpdateInfoComponent } from "../components";
type UpdateInfoData = ConfigResponse;
export const makeUpdateInfoAction: ActionMaker<UpdateInfoData> = (options) => {
// Only relevant in the context of a DAO. Return null if not a DAO context.
if (options.context.type !== ActionOptionsContextType.Dao) {
return null;
}
const useDefaults: UseDefaults<UpdateInfoData> = () => {
const config = useRecoilValue(
configSelector({ contractAddress: options.address })
);
if (!config) {
throw new Error("Failed to load config from chain.");
}
return config;
};
const useTransformToCosmos: UseTransformToCosmos<UpdateInfoData> = () =>
useCallback(
(data: UpdateInfoData) =>
makeWasmMessage({
wasm: {
execute: {
contract_addr: options.address,
funds: [],
msg: {
update_config: {
config: data,
},
},
},
},
}),
[]
);
const useDecodedCosmosMsg: UseDecodedCosmosMsg<UpdateInfoData> = (
msg: Record<string, any>
) =>
useMemo(
() =>
"wasm" in msg &&
"execute" in msg.wasm &&
"update_config" in msg.wasm.execute.msg &&
"config" in msg.wasm.execute.msg.update_config &&
"name" in msg.wasm.execute.msg.update_config.config &&
"description" in msg.wasm.execute.msg.update_config.config &&
"automatically_add_cw20s" in
msg.wasm.execute.msg.update_config.config &&
"automatically_add_cw721s" in msg.wasm.execute.msg.update_config.config
? {
match: true,
data: {
name: msg.wasm.execute.msg.update_config.config.name,
description:
msg.wasm.execute.msg.update_config.config.description,
// Only add image url if it is in the message.
...(!!msg.wasm.execute.msg.update_config.config.image_url && {
image_url:
msg.wasm.execute.msg.update_config.config.image_url,
}),
automatically_add_cw20s:
msg.wasm.execute.msg.update_config.config
.automatically_add_cw20s,
automatically_add_cw721s:
msg.wasm.execute.msg.update_config.config
.automatically_add_cw721s,
},
}
: { match: false },
[msg]
);
return {
key: CoreActionKey.UpdateInfo,
Icon: InfoEmoji,
label: options.t("title.updateInfo"),
description: options.t("info.updateInfoActionDescription"),
Component: UpdateInfoComponent,
useDefaults,
useTransformToCosmos,
useDecodedCosmosMsg,
};
};
Now that we've written our action maker, we need to register it by adding it to
packages/stateful/actions/actions/index.tsx
.
Simply import the action maker and add it to the array:
import { makeUpdateInfoAction } from "./UpdateInfo";
export const getActions = (options: ActionOptions): Action[] => {
const actionMakers = [
...
// Add our action maker here.
makeUpdateInfoAction,
]
...
}
Congratulations! You now know every step required for creating and registering an action in the DAO DAO UI. When you are ready to add your action to the UI, submit a pull request to this repository.
Check out the real Update Info action component and action logic/maker definition. This action is a bit more complex than the one we just created because it supports updating config for both V1 and V2 DAOs, but it looks mostly the same.
If you've made it this far, you may be terribly confused. This isn't your fault and is likely mostly to do with the fact that writing tutorials is quite hard, and actions have iterated to reach the complexity that they now embody. The best way to figure all this out is to read existing actions, and then go write your own! It will all make sense, eventually...
If you have any questions, drop a message in the #frontend channel on our Discord. We are always looking to help out and improve our docs.