Setup
Configuring custom actions is a two-step process:
Add custom action descriptor information to the custom action configuration file.
Create a corresponding custom action hoon source code file that's executed based when a poll is closed.
By default, Ballot comes pre-canned with a configuration file containing two basic custom-actions and two custom action source code files.
Configuration
Custom actions are made available thru Ballot using a json configuration file. This configuration file can be found in the following location of the ship's desk:
<desk>/lib/ballot/custom-actions/config.json
The config file follows a specific format. It's basically a generic key/value map (dictionary) of custom action keys to action detail. The information found in this file is used to render elements in the Ballot UI. Let's take a look:
{
"<custom-action-key>": {
"label": "<user-friendly label as displayed in the UI>",
"description": "<describe the custom action>",
"form": {
"<input-field-key>": {
"<field>": "<data type of the input field>"
}
}
},
...other custom actions
}
Each top level element (e.g. custom-action-key) is a "key" - a unique value across all other keys found in the file.
Below is a description of each of the elements found in the configuration file:
custom-action-key - a value (unique to all other keys in the config file) which can be used to uniquely identify the custom action (e.g. 'invite-member')
label - a user-friendly label (name) of the custom action as it will appear in the Ballot UI (e.g. 'Invite Member' for the invite-member action)
description - describe the custom action. This information will also be displayed in the Ballot UI. use this description to help your users understand the purpose of the custom action.
form - a custom action may require inputs to carry out its task. Elements defined in this section are rendered in the proposal editor UI and used to capture inputs that are ultimately passed to the custom action at runtime.
For example, in the case of the default invite-member action, this action requires the ship name of the member to invite. The form section is used to define the requirements of the UI fields needed to capture the required input data.
field - the name of the field as it appears in the UI. in the case of invite-member, the field name is
member
. Thevalue
of thefield
element is the Hoon data type. In the case of invite-member, this iscord
to indicate a string cord
Here's the default ballot configuration file provided when the Ballot application is installed:
{
"invite-member": {
"label": "Invite member",
"description": "Invites a member to the associated group by poking %group-store",
"form": {
"member": {
"type": "cord"
}
}
},
"kick-member": {
"label": "Kick member",
"description": "Removes a member from the associated group by poking %group-store",
"form": {
"member": {
"type": "cord"
}
}
}
}
Hoon Spec
Custom action source code is stored in the following folder on the desk:
<desk>/lib/ballot/custom-actions
Ballot comes installed with two hoon source files located within this folder: invite-member.hoon
and kick-member.hoon
Take note of the names of these files. As part of the specification, custom action source files must be named after the custom action key located in the configuration file. Since there are two keys in the default config.json
file, there must two hoon files "backing" these settings.
In order to function propery, each custom action source file must contain two cores (parent -> child) where the parent contains an arm (on
) and the "child" core contains an arm (action
).
Both the parent core/arm and action core/arm take arguments which we'll look at in more detail later, but first let's review the invite-member.hoon
file located in the <desk>/lib/ballot/custom-actions
folder. There you will find the expected structure and format of a custom action handler. Note that in addition to the inline comments seen in the hoon code below, you can find additional information
:: sur file includes
:: required sur files: plugin, ballot
:: optional sur files: resource, invite-store
::
:: note: optional sur files (e.g. resource) are specific to the invite-member use-case
:: and therefore may or may not be needed depending on your needs
/- *plugin, ballot, res=resource, inv=invite-store
:: note that there are no lib file includes /+
:: this is because invite-member.hoon contains all the necessary code required
:: to invite new members to a landscape group.
:: /+ ... lib files
:: |% barcen is used to define a dry core containing a battery and payload [battery payload]
:: at the very list this core must contain one arm named 'on'. to learn more about barcen,
:: see: https://urbit.org/docs/hoon/reference/rune/bar#-barcen
|%
:: the 'on' arm is is the only required arm within the outer (parent) core
++ on
:: |= produces a gate which in turn defines the input arguments
:: the 'on' gate must adhere to this specification as seen below
:: args:
:: bowl:gall - this is the bowl passed into the http POST request sent by Eyre
:: learn more about bowls here: https://urbit.org/docs/arvo/gall/data-types#bowl
:: store - Ballot app state. this type is defined in ./sur/ballot.hoon. the type
:: includes a # of different dictionaries for managing booths.
:: context - cell of booth-key/proposal-key values. all sub-stores (see state-1:ballot)
:: expect a booth-key to get to the underlying booth specific data.
|= [=bowl:gall store=state-1:ballot context=[booth-key=@t proposal-key=@t]]
:: |% another barcen is used to define a dry core that wraps the 'action' arm
|%
:: the 'action' arm is the only required arm within this core (child)
++ action
:: |= defines a gate which in turn describes the input arguments to the 'action' arm
:: args:
:: action-data (json) - the key/value pairs provided in this argument are dependent
:: on the 'form' element configured in the custom-action (see config.json)
:: payload (json) - proposal vote tally results
|= [action-data=json payload=json]
:: ^- defines the return type of this arm. action-result (defined in ./sur/plugin.hoon)
:: is used to indicate if this arm succeeded/failed and any other data (e.g. error :: information, new agent state, and/or cards (%poke, %gift, etc...)
^- action-result
:: actual actual code goes here
...
...
...
The first thing you'll notice are references to sur
include files. At a bare minimum, your custom action will need to include plugin
and ballot
. The resource and invite-store are specific to this particular action (use-case). Therefore, custom actions you write will vary depending on use-case.
Required sur
include files
sur
include filesplugin - contains type/structure definitions required for the custom action to interoperate within the broader context of the Ballot app
ballot - contains the
root
store of the Ballot application. The root store is basically generic dictionary(map @t json)
.
lib
files
lib
filesAlthough the default custom action source does not include references to lib
files, your use-case may require the use of lib
files. Include them at the top of this file using the /+
rune. For example (my-lib-here
):
/- *plugin, ballot, res=resource, inv=invite-store
/+ my-lib-here
|%
++ on
|= [=bowl:gall store=state-1:ballot context=[booth-key=@t proposal-key=@t]]
|%
++ action
|= [action-data=json payload=json]
^- action-result
:: more code below
...
...
...
Parent core
The body of the on
parent core is a single core containing a single arm action
. This action arm returns an action-result instance (defined in plugin.hoon). Ballot is expecting all valid plugins to return data using the action-result structure.
action
accepts to arguments:
action-data
sample
=/ data=json
%- pairs:enjs:format
:~
['member' s+member]
==
%- pairs:enjs:format
:~
['data' data]
==
action-data
is populated based on how the action is configured. Its values are determined by the form that is associated with the custom action, and selections that the user has made when creating the proposal. For example, in the case of the invite-member custom action, action-data will be provided as indicated above; where s+member is the cord
indicating the ship to invite.
payload (json) - proposal vote tally results
If the tally fails (e.g. not enough voter support), expect to receive data in the following format:
%- pairs:enjs:format
:~
:: always set to `failed` when the tally results calculation fails
['status' s+'failed']
:: human readable text describing the reason for the failure (e.g. s+'voter turnout not :: sufficient. not enough voter support.')
['reason' s+(need reason.result)]
:: the total # of votes received including votes cast by delegates
['voteCount' (numb:enjs:format `@ud`vote-count)]
:: total # of booth participants
['participantCount' (numb:enjs:format `@ud`participant-count)]
==
If the tally succeeds:
%- pairs:enjs:format
:~
:: always set to counted when the tally results calculation succeeds
['status' s+'counted']
:: the total # of votes received including votes cast by delegates
['voteCount' (numb:enjs:format `@ud`vote-count)]
:: total # of booth participants
['participantCount' (numb:enjs:format `@ud`participant-count)]
:: the choice option that received the highest # of votes (e.g. 'Approve')
['topChoice' s+(need choice.result)]
:: the overall percentage of the total vote count this choice received
['tallies' ?~(tallies ~ [%a tallies])]
==
Notice in the success tally object above there's a tallies property which contains an array of tally detail objects. Each tally object found within the tallies array takes the following form:
%- pairs:enjs:format
:~
:: the choice label (e.g. 'Approve' or 'Deny'). choice labels are set when
:: creating proposals. The default choices are 'Approve' and 'Deny'.
:: Choices can be customized; in which case the label you give the choice
:: in the UI will be the label value found here.
['label' s+choice-label]
:: the custom action to execute. This can either be '' ('No Action' which
:: does nothing) or one of the custom actions configured on the ship/desk.
:: If the action is anything other than '', the Ballot app will load the
:: custom action code from the desk at runtime and execute the action arm.
['action' s+custom-action]
:: the total # of votes the choice received
['count' (numb:enjs:format choice-count)]
:: the overall percentage of the total vote count this choice received
['percentage' n+(crip "{(r-co:co (drg:rd percentage))}")]
==
Last updated