27. The Sponsors Component – Mastering Plone 6 development

27. The Sponsors Component#

In a previous chapter Content types III: Sponsors you created the sponsor content type. Now let's learn how to display content of this type.

To be solved task in this part:

  • Advert to sponsors on all pages, sorted by level

In this part you will:

  • Display data from fetched content

Topics covered:

  • Create React component

  • Use React action of Volto to fetch data from Plone backend via REST API

  • Style component with Semantic UI

Frontend chapter

Checkout volto-ploneconf at tag "listing_variation":

git checkout listing_variation

The code at the end of the chapter:

git checkout sponsors

More info in The code for the training

Sponsors component

For sponsors we will stay with the default view as we will only display the sponsors in the footer and do not modify their own pages. Using what you learned in Volto view component: A default view for a "Talk" you should be able to write a view for sponsors if you wanted to.

27.1. A React component#

React components let you split the UI into independent, reusable pieces, and think about each piece in isolation.

  • You can write a view component for the current context - like the TalkView.

  • You can also write components that are visible on all views of content objects.

  • Volto comes with several components like header, footer, sidebar. In fact everything of the UI is build of nested components.

  • Inspect existing components with the React Developer Tools.

27.2. The Sponsors Component#

Steps to take

  • Copy and customize the Footer component.

  • Create component to fetch data from backend and to display the fetched data.

Getting the sponsors data#

With our Sponsors component in place we can take the next step and explore Volto some more to figure out how it does data fetching.

As the data is in the backend, we need to find a way to address it. Volto provides various predefined actions to communicate with the backend (fetching data, creating content, editing content, etc.). A Redux action communicates with the backend and has a common pattern: It addresses the backend via REST API and updates the global app store according to the response of the backend. A component calls an action and has hereupon access to the global app store (shortened: store) with the fetched data.

For more information which actions are already provided by Volto have a look at core/packages/volto/src/actions.

Our component will use the action searchContent to fetch data of all sponsors. It takes as arguments the path where to search, the information what to search and an argument with which key the data should be stored in the store. Remember: the result is stored in the global app store.

So if we call the action searchContent to fetch data of sponsors, that means data of the instances of content type sponsor, then we can access this data from the store.

The Hook useEffect lets you perform side effects in function components. We use it to fetch the sponsors data from the backend.

 1const dispatch = useDispatch();
 2
 3useEffect(() => {
 4  dispatch(
 5    searchContent(
 6      '/',
 7      {
 8        portal_type: ['sponsor'],
 9        review_state: 'published',
10        fullobjects: true,
11      },
12      'sponsors',
13    ),
14  );
15}, [dispatch]);

Search options#

  • The default representation for search results is a summary that contains only the most basic information like title, review state, type, path and description.

  • With the option fullobjects all available field values are present in the fetched data.

  • Another option is metadata_fields, which allows to get more attributes (selection of Plone catalog metadata columns) than the default search. The search is done without a performance expensive fetch via option fullobjects as soon as the attributes are available from catalog as metadata.

Possible sort criteria are indices of the Plone catalog.

 1const dispatch = useDispatch();
 2
 3useEffect(() => {
 4  dispatch(
 5    searchContent(
 6      '/',
 7      {
 8        portal_type: ['News Items'],
 9        review_state: 'published',
10        sort_on: "effective",
11      },
12      'sponsors',
13    ),
14  );
15}, [dispatch]);

Check which info you get with the search request in Google developer tools:

search response

See also

REST API Documentation Search

Connection of component and store#

Let's connect the store to our component. The Selector Hook useSelector allows a function component to connect to the store.

It's worth exploring the store of our app with the Redux Dev Tools which are additional Dev Tools to React Dev Tools. There you can see what is stored in state.search.subrequests.sponsors. And you can walk through time and watch how the store is changing.

1const sponsors = useSelector((state) =>
2  groupedSponsorsByLevel(state.search.subrequests.sponsors?.items),
3);

With these both: dispatching the action and a connection to the state in place, the component can call the predefined action searchContent and has access to the fetched data via its constant sponsors.

The next step is advanced and can be skipped on a first reading. As by now we fetch the sponsors data on mounting event of the component. The mounting is done once on the first visit of a page of our app. What if a new sponsor is added or a sponsor is published? We want to achieve a re-rendering of the component on changed sponsorship. To subscribe to these changes in sponsorship, we extend our already defined connection.

 1const content = useSelector((state) => state.workflow.transition);
 2
 3useEffect(() => {
 4  dispatch(
 5    searchContent(
 6      '/',
 7      {
 8        portal_type: ['sponsor'],
 9        review_state: 'published',
10        fullobjects: true,
11      },
12      'sponsors',
13    ),
14  );
15}, [dispatch, content]);

Listening to this subscription the component fetches the data from the store if a workflow state changes.

Presentation of the prepared data#

With the data fetched and accessible in the component constant sponsors we can now render the sponsors data.

We prepare the sponsors data as a dictionary grouped by sponsor level: groupedSponsorsByLevel.

const groupedSponsorsByLevel = (array = []) =>
  array.reduce((obj, item) => {
    let token = item.level?.token || 'bronze';
    obj[token] ? obj[token].push(item) : (obj[token] = [item]);
    return obj;
  }, {});

Which results in an dictionary Object available with our subscription sponsors:

{
  bronze: [sponsordata1, sponsodata2]
}

With the subscription sponsors we can now show a nested list.

 1{keys(sponsors).map((level) => {
 2  return (
 3    <div key={level} className={'sponsorlevel ' + level}>
 4      <h3>{level.toUpperCase()}</h3>
 5      <Grid centered>
 6        <Grid.Row centered>
 7          {sponsors[level].map((item) => (
 8            <Grid.Column key={item['@id']} className="sponsor">
 9              <Component
10                componentName="PreviewImage"
11                item={item}
12                alt={item.title}
13                responsive={true}
14                className="ui image"
15              />
16            </Grid.Column>
17          ))}
18        </Grid.Row>
19      </Grid>
20    </div>
21  );
22})}
Complete code of the Sponsors component
 1import { useEffect } from 'react';
 2import { useDispatch, useSelector } from 'react-redux';
 3import { Segment, Grid } from 'semantic-ui-react';
 4import { keys, isEmpty } from 'lodash';
 5
 6import { ConditionalLink, Component } from '@plone/volto/components';
 7import { searchContent } from '@plone/volto/actions';
 8
 9const groupedSponsorsByLevel = (array = []) =>
10  array.reduce((obj, item) => {
11    let token = item.level || 'bronze';
12    obj[token] ? obj[token].push(item) : (obj[token] = [item]);
13    return obj;
14  }, {});
15
16const Sponsors = () => {
17  const dispatch = useDispatch();
18  const sponsors = useSelector((state) =>
19    groupedSponsorsByLevel(state.search.subrequests.sponsors?.items),
20  );
21
22  useEffect(() => {
23    dispatch(
24      searchContent(
25        '/',
26        {
27          portal_type: ['sponsor'],
28          review_state: 'published',
29          sort_on: 'effective',
30          metadata_fields: ['level', 'url'],
31        },
32        'sponsors',
33      ),
34    );
35  }, [dispatch]);
36
37  return !isEmpty(sponsors) ? (
38    <Segment basic textAlign="center" className="sponsors">
39      <div className="sponsorheader">
40        <h2 className="subheadline">SPONSORS</h2>
41      </div>
42      {keys(sponsors).map((level) => {
43        return (
44          <div key={level} className={'sponsorlevel ' + level}>
45            <h3>{level.toUpperCase()}</h3>
46            <Grid centered>
47              <Grid.Row centered>
48                {sponsors[level].map((item) => (
49                  <Grid.Column key={item['@id']} className="sponsor">
50                    <Component
51                      componentName="PreviewImage"
52                      item={item}
53                      alt={item.title}
54                      responsive={true}
55                      className="ui image"
56                    />
57                  </Grid.Column>
58                ))}
59              </Grid.Row>
60            </Grid>
61          </div>
62        );
63      })}
64    </Segment>
65  ) : (
66    <></>
67  );
68};
69
70export default Sponsors;

We group the sponsors by sponsorship level.

An Object sponsors using the sponsorship level as key helps to build rows with sponsors by sponsorship level.

The Volto component Image is used to display the logo. It cares about the markup of an html image node with all necessary attributes in place.

We also benefit from Semantic UI React component Grid to build our list of sponsors. The styling can be customized but these predefined components help simplifying the code and achieve an app wide harmonic style.

See the new footer. A restart is not necessary as we didn't add a new file. The browser updates automagically by configuration.

Sponsors component

27.3. Exercise#

Modify the component to display a sponsor logo as a link to the sponsors website. The address is set in sponsor field "url".

Solution
<ConditionalLink
  to={item.url}
  openLinkInNewTab={true}
  condition={item.url}
>
  <Component
    componentName="PreviewImage"
    item={item}
    alt={item.title}
    responsive={true}
    className="ui image"
  />
</ConditionalLink>

The image component is now rendered with a wrapping anchor tag.

<a
  href="https://www.rohberg.ch" 
  target="_blank"
  rel="noopener noreferrer"
  class="external">
    <img
    src="/sponsors/orangenkiste/@@images/image-170-1914fccf158dd627126054f9c7bb1b17.png"
    width="170" height="170"
    class="ui image responsive"
    srcset="/sponsors/orangenkiste/@@images/image-32-36422a6365defe3ee485bbecaa5cfeda.png 32w, /sponsors/orangenkiste/@@images/image-64-c28b7f3d3c5b0c1de514fbf81091c43c.png 64w, /sponsors/orangenkiste/@@images/image-128-307f157f27fecd23ec2896b44f6ac4aa.png 128w"
    fetchpriority="high"
    alt="Orangenkiste"
    image_field="image"
    >
</a>

27.4. Summary#

You know how to fetch data from backend. With the data you are able to create a component displayed at any place in the website.