47. Volto Actions and Component State

The Conference team placed a call for proposals. Now the team wants to select talks. To support this process we add a section to talk view from chapter Volto View Components: A Default View for "Talk" where team members can vote for the 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

47.1. Fetching data from backend and displaying

As you have seen in chapter Endpoints, 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 = () => {
10const votes = useSelector((store) => store.votes);
11const dispatch = useDispatch();
12let location = useLocation();
13const content = useSelector((store) => store.content.data);
14
15React.useEffect(() => {
16    dispatch(getVotes(location.pathname));
17}, [dispatch, location]);
18
19return 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 to fetch the data by dispatch(getVotes(location.pathname));. The action fetches the data. The corresponding reducer writes the data in global app store. The component Voting as other components can now access the data from the app store by const votes = useSelector((store) => store.votes);. 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.

Before we include the component Voting in talk view from chapter Volto View Components: A Default View for "Talk", some words about actions and reducers. The action getVotes fetches the data. The corresponding reducer writes the data in global app store.

The action getVotes is defined by the request method get, the address of the endpoint votes an and an identifier for the corresponding reducer to react.

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 app store.

 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}

With a successfull 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 <backend>/mailto:api/@votes: http://greenthumb.ch/api/@votes, if your backend is available at http://greenthumb.ch. It is the data that the adapter Vote from starzel.votable_behavior behavior/voting.py provides and exposes via the REST API endpoint @votes.

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

Now we can include the component Voting in talk view from chapter Volto View Components: A Default View for "Talk".

 1import { Voting } from '~/components';
 2
 3const TalkView = ({ content }) => {
 4const color_mapping = {
 5    Beginner: 'green',
 6    Advanced: 'yellow',
 7    Professional: 'purple',
 8};
 9
10return (
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

47.2. 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 just

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 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.

47.3. 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 definitly 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 successfull 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 ).