32.3. Volto actions and component state [voting story] – Mastering Plone Development – 31. Create an add-on [The voting story]

32.3. Volto actions and component state [voting story]#

In this part you will create the voting component which calls the backend to fetch and store votes.

Topics covered:

  • Redux actions and reducers

  • React component state

Check out mastering-plone-votable-add-on at tag endpoints:

git checkout endpoints

The code at the end of the chapter:

git checkout actions

More info in The code for the training

The conference team placed a call for proposals. Now the program committee wants to select talks. To support this process we will add a section to the talk view (from chapter Add a custom view) where program committee members can vote for a talk.

Volto Voting

Voting#

Volto Voting

Voting component, user has already voted#

Request votes from the REST API#

As you have seen in chapter REST API endpoints [voting story], the @votes service was created to provide the data we need: votes per talk plus info if the current user has the permission to vote on this talk. Now we can fetch this data and display it.

We start with a component to display votes. Create the file frontend/packages/volto-ploneconf-votable/src/components/Voting/Voting.jsx.

 1import React from 'react';
 2import { useDispatch, useSelector } from 'react-redux';
 3import { useLocation } from 'react-router-dom';
 4import { getVotes } from 'volto-ploneconf-votable/actions/votes/votes';
 5import config from '@plone/volto/registry';
 6import { Container as SemanticContainer } from 'semantic-ui-react';
 7
 8const Voting = () => {
 9  const votes = useSelector((state) => state.votes);
10  const dispatch = useDispatch();
11  let location = useLocation();
12  const content = useSelector((state) => state.content.data);
13
14  React.useEffect(() => {
15    dispatch(getVotes(location.pathname));
16  }, [dispatch, location]);
17
18  const Container =
19    config.getComponent({ name: 'Container' }).component || SemanticContainer;
20
21  return votes?.loaded && votes?.can_vote ? ( // is store content available? (votable behavior is optional)
22    <Container>
23      <div className="ui segment voting">
24        <div className="ui dividing header">
25          Conference Talk and Training Selection
26        </div>
27        <div className="ui list">
28          <div className="ui medium labels">
29            {votes?.has_votes ? (
30              <div className="ui olive ribbon label">
31                Average vote for this{' '}
32                {content.type_of_talk?.title.toLowerCase()}:{' '}
33                {votes?.average_vote}
34                <div className="detail">
35                  ( Votes Cast {votes?.total_votes} )
36                </div>
37              </div>
38            ) : (
39              <div className="ui yellow ribbon label">
40                There are no votes so far for this{' '}
41                {content.type_of_talk?.title.toLowerCase()}.
42              </div>
43            )}
44          </div>
45        </div>
46      </div>
47      <br />
48    </Container>
49  ) : null;
50};
51export default Voting;

The useEffect hook runs after the component has been mounted. It initiates the action getVotes by calling dispatch(getVotes(location.pathname));. The action fetches the data, and the corresponding reducer stores the result in the global app store (provided by the Redux library).

The component Voting as well as any other component can now access the data from the global app store by subscribing with const votes = useSelector((state) => state.votes);. Once the action has completed, votes will hold the necessary data for the current talk and user in an object in this format:

 1votes: {
 2  loaded: true,
 3  loading: false,
 4  error: null,
 5  already_voted: false,
 6  average_vote: 1,
 7  can_clear_votes: true,
 8  can_vote: true,
 9  has_votes: true,
10  total_votes: 2
11}

See the condition of the rendering function. We receive all needed info for displaying from the one request including the info about the permission of the current user to vote. Why do we need only one request? We designed the endpoint votes to provide all necessary information.

Actions, reducers and the Redux store#

Before we add the Voting component to the talk view, let's take a closer look at how the Redux store works. The action getVotes starts a request to get the data from the API server. The corresponding reducer writes the data to the global app store.

The action getVotes is defined by the request method GET, the address of the @votes endpoint and an identifier GET_VOTES.

frontend/packages/volto-ploneconf-votable/src/actions/votes/votes.js

 1export const GET_VOTES = 'GET_VOTES';
 2
 3export function getVotes(url) {
 4  return {
 5    type: GET_VOTES,
 6    request: {
 7      op: 'get',
 8      path: `${url}/@votes`,
 9    },
10  };
11}

The reducer writes the data from the response to the app store.

frontend/packages/volto-ploneconf-votable/src/reducers/votes/votes.js

 1import { GET_VOTES } from 'volto-ploneconf-votable/actions/votes/votes';
 2
 3const initialState = {
 4  loaded: false,
 5  loading: false,
 6  error: null,
 7};
 8
 9
10export default function votes(state = initialState, action = {}) {
11  switch (action.type) {
12    case `${GET_VOTES}_PENDING`:
13      return {
14        ...state,
15        error: null,
16        loaded: false,
17        loading: true,
18      };
19    case `${GET_VOTES}_SUCCESS`:
20      return {
21        ...state,
22        ...action.result,
23        error: null,
24        loaded: true,
25        loading: false,
26      };
27    case `${GET_VOTES}_FAIL`:
28      return {
29        ...state,
30        error: action.error,
31        loaded: false,
32        loading: false,
33      };
34    default:
35      return state;
36  }
37}

We have to add our reducer to the overall Volto configuration:

frontends/packages/volto-ploneconf-votable/src/config/settings.ts

import type { ConfigType } from '@plone/registry';
import votes from 'volto-ploneconf-votable/reducers/votes/votes';

export default function install(config: ConfigType) {
  config.addonReducers = {
    ...config.addonReducers,
    votes,
  };

  return config;
}

After a successful action getVotes, the app store has an entry

 1votes: {
 2  loaded: true,
 3  loading: false,
 4  error: null,
 5  already_voted: false,
 6  average_vote: 1,
 7  can_clear_votes: true,
 8  can_vote: true,
 9  has_votes: true,
10  total_votes: 2
11}

This data written by the reducer is the response of the request to http://localhost:3000/++api++/talks/python-in-arts/@votes which is proxied to http://localhost:8080/Plone/talks/python-in-arts/@votes.

The response is the data that the adapter ploneconf.votable.behaviors.votable.Votable provides and exposes via the REST API endpoint @votes.

The component gets access to this data by subscribing to the store with const votes = useSelector((state) => state.votes);

Include the new component in the talk view#

Now we can include the Voting component in the talk view.

frontend/packages/volto-ploneconf-votable/config/settings.ts

 1import type { ConfigType } from '@plone/registry';
 2import type { Content } from '@plone/types';
 3import votes from 'volto-ploneconf-votable/reducers/votes/votes';
 4import Voting from 'volto-ploneconf-votable/components/Voting/Voting';
 5
 6function FieldCondition(field: string) {
 7  return ({ content }: { content: Content }) => {
 8    return Boolean(content?.[field]);
 9  };
10}
11
12export default function install(config: ConfigType) {
13  config.addonReducers = {
14    ...config.addonReducers,
15    votes,
16  };
17
18  config.registerSlotComponent({
19    slot: 'aboveContent',
20    name: 'voting',
21    component: Voting,
22    predicates: [FieldCondition('voting_enabled')],
23  });
24
25  return config;
26}

We are registering the Voting component in the aboveContent slot. It has a predicate which is a condition for when to show the component. The FieldCondition here will show the component only for content items that have the voting_enabled field (because they have our behavior enabled).

Volto Voting: displaying votes

Check the Redux tab of the browser developer tools to see the store changes made by our reducer. You can filter by "votes".

Developer Tools Redux

Write votes to the REST API#

Now we can add the actions to actually place a vote.

We add a section to our Voting component.

frontend/packages/volto-ploneconf-votable/src/components/Voting/Voting.jsx

 1          <div className="ui horizontal section divider">Vote</div>
 2          {votes?.already_voted ? (
 3            <div className="item">
 4              <div className="content">
 5                <div className="header">
 6                  You voted for this {content.type_of_talk?.title}.
 7                </div>
 8                <div className="description">
 9                  Please review more interesting talks and vote.
10                </div>
11              </div>
12            </div>
13          ) : (
14            <div className="item">
15              <div className="ui buttons">
16                <button
17                  type="button"
18                  className="ui green button"
19                  onClick={() => handleVoteClick(1)}
20                >
21                  Approve
22                </button>
23                <button
24                  type="button"
25                  className="ui blue button"
26                  onClick={() => handleVoteClick(0)}
27                >
28                  Do not know what to expect
29                </button>
30                <button
31                  type="button"
32                  className="ui red button"
33                  onClick={() => handleVoteClick(-1)}
34                >
35                  Decline
36                </button>
37              </div>
38            </div>
39          )}

We check if the user has already voted with votes?.already_voted. We get this info from our votes data selected from the app store state.

If the user has not voted yet, the component shows buttons to vote. The click event handler handleVoteClick starts the communication with the backend by dispatching the vote action. We import this action from src/actions.

import { getVotes, vote, clearVotes } from 'volto-ploneconf-votable/actions/votes/votes';

The click event handler handleVoteClick dispatches the action vote:

  const handleVoteClick = (value) => {
    dispatch(vote(location.pathname, value));
  };

The action vote is similar to our previous action getVotes. It creates a request to submit the rating to the @votes POST endpoint.

frontend/packages/volto-ploneconf-votable/src/actions/votes/votes.js

 1export const VOTE = 'VOTE';
 2
 3export function vote(url, vote) {
 4  if ([-1, 0, 1].includes(vote)) {
 5    return {
 6      type: VOTE,
 7      request: {
 8        op: 'post',
 9        path: `${url}/@votes`,
10        data: { rating: vote },
11      },
12    };
13  }
14}

As the corresponding reducer updates the app store, the subscribed component Voting reacts by updating itself. The subscription is done with:

const votes = useSelector((state) => state.votes);

The component updates itself, it renders with the updated info about if the user has already voted, about the average vote and the total number of already posted votes. So the buttons disappear as we made the rendering conditional to votes?.already_voted which checks whether the current user has already voted.

Why is it possible that this info about the current user has been fetched by getVotes? Every request of a Volto app is done with the token of the logged in user.

The authorized user can now vote:

Volto Voting

Observe that we do not calculate average votes and do not check if a user can vote via permissions, roles, whatsoever. This logic is done in the backend. We request votes and information like 'can the current user do this and that' from the backend.

The reducer is enhanced to handle the VOTE action:

frontend/volto-ploneconf-votable/src/reducers/votes/votes.js

 1import {
 2  GET_VOTES,
 3  VOTE,
 4} from 'volto-ploneconf-votable/actions/votes/votes';
 5
 6const initialState = {
 7  loaded: false,
 8  loading: false,
 9  error: null,
10};
11
12export default function votes(state = initialState, action = {}) {
13  switch (action.type) {
14    case `${GET_VOTES}_PENDING`:
15    case `${VOTE}_PENDING`:
16      return {
17        ...state,
18        error: null,
19        loaded: false,
20        loading: true,
21      };
22    case `${GET_VOTES}_SUCCESS`:
23    case `${VOTE}_SUCCESS`:
24      return {
25        ...state,
26        ...action.result,
27        error: null,
28        loaded: true,
29        loading: false,
30      };
31    case `${GET_VOTES}_FAIL`:
32    case `${VOTE}_FAIL`:
33      return {
34        ...state,
35        error: action.error,
36        loaded: false,
37        loading: false,
38      };
39    default:
40      return state;
41  }
42}

Component state#

Finally, let's add a feature for developers to clear votes of a talk while preparing the app. We want to offer a button to clear votes and integrate a hurdle to prevent unwanted clearing. The user shall click and see a question to confirm whether to clear the votes.

We are using the component state to be incremented before requesting the backend to definitely clear votes.

 1          {votes?.can_clear_votes && votes?.has_votes ? (
 2            <>
 3              <div className="ui red horizontal section divider">
 4                Danger Zone
 5              </div>
 6              <div className="item">
 7                <button className="ui red button" onClick={handleClearVotes}>
 8                  {
 9                    [
10                      'Clear votes for this item',
11                      'Are you sure to clear votes for this item?',
12                      'Votes for this item are reset.',
13                    ][stateClearVotes]
14                  }
15                </button>
16              </div>
17            </>
18          ) : null}

This additional code snippet of our Voting component displays a delete button with a label depending on the component state stateClearVotes.

The stateClearVotes component state is defined as a value/accessor pair like this:

const [stateClearVotes, setStateClearVotes] = useState(0);

The click event handler handleClearVotes distinguishes on the stateClearVotes component state to decide if it already dispatches the delete action clearVotes or if it waits for a second confirming click.

1  const handleClearVotes = () => {
2    if (stateClearVotes === 1) {
3      dispatch(clearVotes(location.pathname));
4    }
5    // increment count up to 2
6    let counter = stateClearVotes < 2 ? stateClearVotes + 1 : 2;
7    setStateClearVotes(counter);
8  };

For completeness, we need the clearVotes action. As you have already guessed, it does a DELETE request to the @votes endpoint.

 1export const CLEAR_VOTES = 'CLEAR_VOTES';
 2
 3export function clearVotes(url) {
 4  return {
 5    type: CLEAR_VOTES,
 6    request: {
 7      op: 'del',
 8      path: `${url}/@votes`,
 9    },
10  };
11}

You will see now that the clearing section disappears after clearing. This is because it is conditional with votes?.has_votes. After a successful clearVotes action the corresponding reducer updates the store. As the component is subscribed to the store via const votes = useSelector((state) => state.votes); the component updates itself (it is re-rendered with the updated values). And the voting buttons are visible again.