32.3. Volto Actions and Component State [voting story] – Mastering Plone 6 development – 32. Roundtrip [The voting story] frontend, backend, and REST

32.3. Volto Actions and Component State [voting story]#

Frontend chapter

Get the code! volto-training-votable

The Conference team placed a call for proposals. Now the jury wants to select talks. To support this process we add a section to talk view from chapter volto_talkview where jury members can vote for a talk.

Topics covered:

  • actions: fetch data from backend and write data to backend

  • component state: user interaction: call back to user before dispatching an action

  • theming with Semantic-UI

Volto Voting

Voting#

Volto Voting

Voting component, user has already voted#

Requesting data from backend and displaying#

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

We start with a component Voting to display votes.

src/components/Voting/Voting.jsx

 1import React from 'react';
 2import { useDispatch, useSelector } from 'react-redux';
 3import { useLocation } from 'react-router-dom';
 4
 5import { Header, Label, List, Segment } from 'semantic-ui-react';
 6
 7import { getVotes } from '../../actions';
 8
 9const Voting = () => {
10  const votes = useSelector((store) => store.votes);
11  const dispatch = useDispatch();
12  let location = useLocation();
13  const content = useSelector((store) => store.content.data);
14
15  React.useEffect(() => {
16    dispatch(getVotes(location.pathname));
17  }, [dispatch, location]);
18
19  return votes?.loaded && votes?.can_vote ? ( // is store content available? (votable behavior is optional)
20    <Segment className="voting">
21      <Header dividing>Conference Talk and Training Selection</Header>
22      <List>
23        <p>
24          <Label.Group size="medium">
25            {votes?.has_votes ? (
26              <Label color="olive" ribbon>
27                Average vote for this{' '}
28                {content.type_of_talk?.title.toLowerCase()}:{' '}
29                {votes?.average_vote}
30                <Label.Detail>( Votes Cast {votes?.total_votes} )</Label.Detail>
31              </Label>
32            ) : (
33              <b>
34                There are no votes so far for this{' '}
35                {content.type_of_talk?.title.toLowerCase()}.
36              </b>
37            )}
38          </Label.Group>
39        </p>
40      </List>
41    </Segment>
42  ) : null;
43};
44export default Voting;

On mount of the component the action getVotes is dispatched by dispatch(getVotes(location.pathname));.

  • The action fetches the data.

  • The corresponding reducer writes the data in global app store.

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((store) => store.votes);. Therefore the constant votes holds the necessary data for the current talk and user in a dictionary like

 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 of data 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 app store#

Before we include the component Voting in talk view from chapter volto_talkview, some words about actions and reducers. The action getVotes requests the data. 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 endpoint votes and an identifier GET_VOTES for the corresponding reducer to react.

actions/votes/votes.js

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

The reducer writes the data fetched by its action to the app store.

reducers/votes/votes.js

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

The action type identifiers are listed in constants/ActionTypes.js to keep reducer and action pairs in sync.

/**
 * Add your action types here.
 * @module constants/ActionTypes
 * @example
 * export const UPDATE_CONTENT = 'UPDATE_CONTENT';
 */

export const GET_VOTES = 'GET_VOTES';

We now add our reducer to the overall Volto configuration:

index.js

import { votes } from './reducers';

const applyConfig = (config) => {
  config.addonReducers.votes = votes;

  return config;
};

export default applyConfig;

With 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 training.votable.behaviors.votable.Votable provides and exposes via the REST API endpoint @votes.

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

Now we can include the component Voting in a talk view from chapter volto_talkview.

 1import { Voting } from 'volto-training-votable/components';
 2
 3const TalkView = ({ content }) => {
 4  const color_mapping = {
 5    Beginner: 'green',
 6    Advanced: 'yellow',
 7    Professional: 'purple',
 8  };
 9
10  return (
11    <Container id="page-talk">
12    <h1 className="documentFirstHeading">
13      {content.type_of_talk.title}: {content.title}
14    </h1>
15    <Voting />
Volto Voting: displaying votes

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

Developer Tools Redux

Writing to the backend…#

… and the clue about a React component

Now we can care about providing the actual voting feature.

We add a section to our Voting component.

 1<Divider horizontal section>
 2    Vote
 3</Divider>
 4
 5{votes?.already_voted ? (
 6  <List.Item>
 7    <List.Content>
 8      <List.Header>
 9        You voted for this {content.type_of_talk?.title}.
10      </List.Header>
11      <List.Description>
12        Please see more interesting talks and vote.
13      </List.Description>
14    </List.Content>
15  </List.Item>
16) : (
17  <List.Item>
18    <Button.Group widths="3">
19      <Button color="green" onClick={() => handleVoteClick(1)}>
20        Approve
21      </Button>
22      <Button color="blue" onClick={() => handleVoteClick(0)}>
23        Do not know what to expect
24      </Button>
25      <Button color="orange" onClick={() => handleVoteClick(-1)}>
26        Decline
27      </Button>
28    </Button.Group>
29  </List.Item>
30)}

We check if the user has already voted by votes?.already_voted. We get this info from our votes subscriber to the app store.

After some info the code offers buttons to vote. The click event handler handleVoteClick starts the communication with the backend by dispatching action vote. We import this action from src/actions.

import { getVotes, vote, clearVotes } from "../../actions";

The click event handler handleVoteClick dispatches the action vote:

function handleVoteClick(value) {
  dispatch(vote(location.pathname, value));
}

The action vote is similar to our previous action getvotes. It is defined by the request method post to submit the necessary data rating.

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

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

const votes = useSelector((store) => store.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 says if 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. Every logic is done by the backend. We request votes and infos like 'can the current user do this and that' from the backend.

The reducer is enhanced by the voting part:

src/reducers/votes/votes.js

 1/**
 2 * Voting reducer.
 3 * @module reducers/votes/votes
 4 */
 5
 6import { GET_VOTES, VOTE, CLEAR_VOTES } from '../../constants/ActionTypes';
 7
 8const initialState = {
 9  loaded: false,
10  loading: false,
11  error: null,
12};
13
14/**
15 * Voting reducer.
16 * @function votes
17 * @param {Object} state Current state.
18 * @param {Object} action Action to be handled.
19 * @returns {Object} New state.
20 */
21export default function votes(state = initialState, action = {}) {
22  switch (action.type) {
23    case `${GET_VOTES}_PENDING`:
24    case `${VOTE}_PENDING`:
25      return {
26        ...state,
27        error: null,
28        loaded: false,
29        loading: true,
30      };
31    case `${GET_VOTES}_SUCCESS`:
32    case `${VOTE}_SUCCESS`:
33      return {
34        ...state,
35        ...action.result,
36        error: null,
37        loaded: true,
38        loading: false,
39      };
40    case `${GET_VOTES}_FAIL`:
41    case `${VOTE}_FAIL`:
42      return {
43        ...state,
44        error: action.error,
45        loaded: false,
46        loading: false,
47      };
48    default:
49      return state;
50  }
51}

Component State#

Next step is the 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 if she really wants 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    <Divider horizontal section color="red">
 4        Danger Zone
 5    </Divider>
 6    <List.Item>
 7      <Button.Group widths="2">
 8      <Button color="red" onClick={handleClearVotes}>
 9        {
10          [
11            'Clear votes for this item',
12            'Are you sure to clear votes for this item?',
13            'Votes for this item are reset.',
14          ][stateClearVotes]
15        }
16      </Button>
17      </Button.Group>
18    </List.Item>
19  </>
20) : null}

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

The stateClearVotes component state is defined as 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.

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

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((store) => store.votes); the component updates itself ( is rendered with the updated values ). And the voting buttons are visible again.

For completnes, the action. You have already guessed, it does a DEL request to the @votes endpoint. And the endpoint service from last chapter knows what to do.

/**
 * Delete votes of an item
 * @function clearVotes
 * @returns {Object} Votes action.
 */
export function clearVotes(url) {
  return {
    type: CLEAR_VOTES,
    request: {
      op: 'del',
      path: `${url}/@votes`,
    },
  };
}

Note

Get the code! volto-training-votable