33.3. Volto Actions and component state [voting story]#
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 view component: A default view for a "Talk" 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
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 view component: A default view for a "Talk", 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);
Include the new component in the talk view#
Now we can include the component Voting
in a talk view from chapter Volto view component: A default view for a "Talk".
1import { Container as SemanticContainer } from 'semantic-ui-react';
2import { ContentTypeCondition } from '@plone/volto/helpers';
3import { Voting } from 'volto-training-votable/components';
4import { TalkView, TalkListingBlockVariation } from './components';
5
6const applyConfig = (config) => {
7 config.views = {
8 ...config.views,
9 contentTypesViews: {
10 ...config.views.contentTypesViews,
11 talk: TalkView,
12 },
13 };
14
15 config.blocks.blocksConfig.listing.variations = [
16 ...config.blocks.blocksConfig.listing.variations,
17 {
18 id: 'talks',
19 title: 'Talks',
20 template: TalkListingBlockVariation,
21 },
22 ];
23
24 const Container =
25 config.getComponent({ name: 'Container' }).component || SemanticContainer;
26 const WrappedVoting = () => (
27 <Container>
28 <Voting />
29 </Container>
30 );
31
32 config.registerSlotComponent({
33 slot: 'aboveContent',
34 name: 'voting',
35 component: WrappedVoting,
36 predicates: [ContentTypeCondition(['talk'])],
37 });
38
39 return config;
40};
41
42export default applyConfig;
Check the Redux
tab of Google developer tools to see the store changes forced by our reducer.
You can filter by "votes".
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 subscription 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:
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 completeness, 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