Setup

Configuring custom actions is a two-step process:

  1. Add custom action descriptor information to the custom action configuration file.

  2. 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. The value of the field element is the Hoon data type. In the case of invite-member, this is cord 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

  • plugin - 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

Although 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