26. Volto View Components: A Listing View for Talks

To be solved task in this part:

  • Create a view that shows a list of talks

In this part you will:

  • Create the view component

  • Register a react view component for folderish content types

  • Use an existing endpoint of Plone REST API to fetch content

  • Display the fetched data

Volto has has a default view for content type Document. The talk list should list all talks in this folderish page and also show information about the dates, the locations and the speakers. We will create an additonal view for the content type Document.

26.1. Register the view in Volto and Plone

Create a new file src/components/Views/TalkList.jsx.

As a first step the file will hold a simple view component:

import React from 'react';

const TalkListView = props => {
  return <div>I'm the TalkList component!</div>;
};
export default TalkListView;

As a convention we provide the view from src/components/index.js.

import TalkView from './Views/Talk';
import TalkListView from './Views/TalkList';

export { TalkView, TalkListView };

Now register the new component as a view for folderish types in src/config.js.

 1import { TalkListView, TalkView } from './components';
 2
 3// All your imports required for the config here BEFORE this line
 4import '@plone/volto/config';
 5
 6export default function applyConfig(config) {
 7  config.views = {
 8    ...config.views,
 9    contentTypesViews: {
10      ...config.views.contentTypesViews,
11      talk: TalkView,
12    },
13    layoutViews: {
14      ...config.views.layoutViews,
15      talklist_view: TalkListView,
16    }
17  };
18  return config;
19}

This extends the list of available views with the talklist_view.

To add a layout view you also have to add this new view in the ZMI of your Plone. Login to your Plone instance. Go to portal_types and select the Document-Type to add your new talklist_view to the Available view methods.

Add new View to content type Folder in the ZMI.

Add new View to content type Document in the ZMI.

Warning

This step is not in the final code for this chapter since it only changes the frontend, you need to do it manually for now. It will be added in the next chapter where you change the backend-code.

The change would be in profiles/default/types/Document.xml:

 1<?xml version="1.0"?>
 2<object name="Document" meta_type="Dexterity FTI" i18n:domain="plone"
 3    xmlns:i18n="http://xml.zope.org/namespaces/i18n">
 4  <property name="filter_content_types" purge="false">False</property>
 5  <property name="view_methods" purge="false">
 6    <element value="talklist_view"/>
 7  </property>
 8  <property name="behaviors" purge="false">
 9    <element value="plone.constraintypes"/>
10  </property>
11</object>

From now on you can select the new view for folder:

../_images/talklistview_select.png

Now we will improve this view step by step. We start working directly with the context of our talks folder. The context is part of the props of the view. To have a convenient access to the context we assign a variable content the value of props.content.

Via prop content we have access to title, description and other attributes

 1import React from 'react';
 2import { Container } from 'semantic-ui-react';
 3import { Helmet } from '@plone/volto/helpers';
 4
 5const TalkListView = ({content}) => {
 6  return (
 7    <Container className="view-wrapper">
 8      <Helmet title={content.title} />
 9      <article id="content">
10        <header>
11        <h1 className="documentFirstHeading">{content.title}</h1>
12        {content.description && (
13          <p className="documentDescription">{content.description}</p>
14        )}
15        </header>
16      </article>
17    </Container>
18  )
19};
20export default TalkListView;

26.2. Display the content of a folder

Note

For the next part you should have some talks and no other content in a page to work on the progressing view.

Warning

Due to a breaking change in Volto 10 the following code does not work anymore. content no longer holds the full content objects but a simplified representation of them. See https://docs.voltocms.com/upgrade-guide/#getcontent-changes

Skip ahead to talklistview_search_endpoint-label until we fix this :)

You can iterate over all items in our talks folder by using the map content.items. To build a view with some elements we used in the TalkView before, we can reuse some components and definitions like the color_mapping for the audience.

import React from 'react';
import { Container, Segment, Label, Image } from 'semantic-ui-react';
import { Helmet } from '@plone/volto/helpers';
import { Link } from 'react-router-dom';
import { flattenToAppURL } from '@plone/volto/helpers';

const TalkListView = props => {
  const { content } = props;
  const results = content.items;
  const color_mapping = {
    Beginner: 'green',
    Advanced: 'yellow',
    Professional: 'purple',
  };
  return (
    <Container className="view-wrapper">
      <Helmet title={content.title} />
      <article id="content">
        <header>
          <h1 className="documentFirstHeading">{content.title}</h1>
          {content.description && (
            <p className="documentDescription">{content.description}</p>
          )}
        </header>
        <section id="content-core">
          {results &&
            results.map(item => (
              <Segment padded>
                <h2>
                  <Link to={item['@id']} title={item['@type']}>
                    {item.type_of_talk.title}: {item.title}
                  </Link>
                </h2>
                {item.audience?.map(item => {
                  let audience = item.title;
                  let color = color_mapping[audience] || 'green';
                  return (
                    <Label key={audience} color={color}>
                      {audience}
                    </Label>
                  );
                })}
                {item.image && (
                  <Image
                    src={flattenToAppURL(item.image.scales.preview.download)}
                    size="small"
                    floated="right"
                    alt={content.image_caption}
                    avatar
                  />
                )}
                {item.description && <div>{item.description}</div>}
                <Link to={item['@id']} title={item['@type']}>
                  read more ...
                </Link>
              </Segment>
            ))}
        </section>
      </article>
    </Container>
  );
};
export default TalkListView;
  • With {content.items} we iterate over the contents of the folder and assign the received map to the constant results for further use.

  • With {results && results.map(item => ()} we test if there is any item in the map and then iterate over this items.

  • To use the existing Link-Component we’ll have to use import { Link } from 'react-router-dom'; and configure the component:

    • to={item['@id']} will make the link point to the URL of the item and assign it to the Link as destination

    • {item['@type']} will give you the contenttype name of the item, which could help you to change layouts for the listed items if you have different content in your folder

  • You can get all other information like title and description with the dotted notation like {item.title} and {item.description}. We use that to display audience, image and description like we already did in the talkview.

The iteration over content.items to build a listing can be problematic though, because this approach has some limitations you may have to deal with:

  • listed content can include different types and could have different fields or use cases (long, difficult-to-read code if every addable type/use case has to be covered) or

  • not all content for the listing exists in one folder but may arranged in a wide structure (for example in topics or by day)

26.3. Using the search endpoint

To get a list of all talks - no matter where they are in our site - we will use the search endpoint of the Plone REST API. That is the equivalent of using a catalog search in classic Plone (see Using portal_catalog).

import React from 'react';
import { Container, Segment, Label, Image } from 'semantic-ui-react';
import { Helmet } from '@plone/volto/helpers';
import { Link } from 'react-router-dom';
import { flattenToAppURL } from '@plone/volto/helpers';
import { searchContent } from '@plone/volto/actions';
import { useDispatch, useSelector } from 'react-redux';

const TalkListView = ({ content }) => {
  const talks = useSelector(
    (state) => state.search.subrequests.conferencetalks?.items,
  );
  const dispatch = useDispatch();

  const color_mapping = {
    Beginner: 'green',
    Advanced: 'yellow',
    Professional: 'purple',
  };

  React.useEffect(() => {
    dispatch(
      searchContent(
        '/',
        {
          portal_type: ['talk'],
          fullobjects: true,
        },
        'conferencetalks',
      ),
    );
  }, [dispatch]);

  return (
    <Container className="view-wrapper">
      <Helmet title={content.title} />
      <article id="content">
        <header>
          <h1 className="documentFirstHeading">{content.title}</h1>
          {content.description && (
            <p className="documentDescription">{content.description}</p>
          )}
        </header>
        <section id="content-core">
          {talks &&
            talks.map(item => (
              <Segment padded>
                <h2>
                  <Link to={item['@id']} title={item['@type']}>
                    {item.type_of_talk.title || item.type_of_talk.token}:{' '}
                    {item.title}
                  </Link>
                </h2>
                {item.audience?.map(item => {
                  let audience = item.title || item.token;
                  let color = color_mapping[audience] || 'green';
                  return (
                    <Label key={audience} color={color}>
                      {audience}
                    </Label>
                  );
                })}
                {item.image && (
                  <Image
                    src={flattenToAppURL(item.image.scales.preview.download)}
                    size="small"
                    floated="right"
                    alt={content.image_caption}
                    avatar
                  />
                )}
                {item.description && <div>{item.description}</div>}
                <Link to={item['@id']} title={item['@type']}>
                  read more ...
                </Link>
              </Segment>
            ))}
        </section>
      </article>
    </Container>
  );
};

export default TalkListView;

We make use of the useSelector and useDispatch hooks from the react-redux library. They are used to subscribe our component to the store changes (useSelector) and for issuing Redux actions (useDispatch) from our components.

Afterwards we can define the new results with const results = searchRequests.items;, which will use the hooks and actions to receive a map of items.

The search itself will be defined in the React.useEffect(() => {})- section of the code and will contain all parameters for the search. In case of the talks listing view we search for all objects of type talk with portal_type:['talk'] and force to fetch full objects with all information.

The items themselves won’t change though, so the rest of the code will stay untouched.

Now you see all talks in the list no matter where they are located in the site.

Warning

If you change the view in Volto you’ll also change the view in the backend (Plone). As long as the same view isn’t available in the backend too, the site will show an error!

26.4. 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 without a performance expensive fetch via option fullobjects.

Possible sort criteria are indices of the Plone catalog.

See also

26.5. Exercises

26.5.1. Exercise 1

Modify the criteria in the search to sort the talks in the order of their modification date.

Solution

 1React.useEffect(() => {
 2  dispatch(
 3    searchContent('/', {
 4      portal_type: ['talk'],
 5      sort_on: 'modified',
 6      fullobjects: true,
 7    },
 8    'conferencetalks',
 9  ),
10  );
11}, [dispatch]);

26.5.2. Exercise 2

Change TalkListView to show the keynote speakers (name, biography and foto) and with a link to their keynote. Remember that you cannot search for a specific value in type_of_talk yet so you’ll have to filter the results.

For bonus points create and register it as a separate view Keynotes

Solution

Write the view:

 1import React from 'react';
 2import { Container, Segment, Image } from 'semantic-ui-react';
 3import { Helmet } from '@plone/volto/helpers';
 4import { Link } from 'react-router-dom';
 5import { flattenToAppURL } from '@plone/volto/helpers';
 6import { searchContent } from '@plone/volto/actions';
 7import { useDispatch, useSelector } from 'react-redux';
 8
 9const TalkListView = ({ content }) => {
10    const talks = useSelector(
11      (state) => state.search.subrequests.conferencetalks?.items,
12    );
13  const dispatch = useDispatch();
14
15  React.useEffect(() => {
16    dispatch(
17      searchContent('/', {
18        portal_type: ['talk'],
19        review_state: 'published',
20        fullobjects: true,
21      },
22      'conferencetalks',
23    ),
24    );
25  }, [dispatch]);
26
27  return (
28    <Container className="view-wrapper">
29      <Helmet title={content.title} />
30      <article id="content">
31        <header>
32          <h1 className="documentFirstHeading">Our Keynote Speakers</h1>
33        </header>
34        <section id="content-core">
35          {talks &&
36            talks.map(
37              item =>
38                item.type_of_talk.title === 'Keynote' && (
39                  <Segment padded>
40                    <h2>{item.speaker}</h2>
41                    {item.image && (
42                      <Image
43                        src={flattenToAppURL(
44                          item.image.scales.preview.download,
45                        )}
46                        size="medium"
47                        centered
48                        alt={item.speaker}
49                      />
50                    )}
51                    {item.speaker_biography && (
52                      <div
53                        dangerouslySetInnerHTML={{
54                          __html: item.speaker_biography.data,
55                        }}
56                      />
57                    )}
58                    <h3>
59                      Keynote:{' '}
60                      <Link to={item['@id']} title={item['@type']}>
61                        {item.title}
62                      </Link>
63                    </h3>
64                  </Segment>
65                ),
66            )}
67        </section>
68      </article>
69    </Container>
70  );
71};
72export default TalkListView;

Note

  • The query uses review_state: 'published'

  • Filtering is done using item.type_of_talk.title === 'Keynote' && (... during the iteration.

To regster it move the code to new frontend/src/components/Views/Keynotes.jsx and rename it to KeynotesView:

const KeynotesView = props => {
  [...]
}

export default KeynotesView;

Export it in frontend/src/components/index.js:

import TalkView from './Views/Talk';
import TalkListView from './Views/TalkList';
import KeynotesView from './Views/Keynotes';

export { TalkView, TalkListView, KeynotesView };

Register the component as layout view for folderish types in frontend/src/config.js.

import { TalkListView, TalkView, KeynotesView } from './components';

[...]

config.views = {
  ...config.views,
  layoutViews: {
    ...config.views.layoutViews,
    talklist_view: TalkListView,
    keynotes_view: KeynotesView,
  },
  contentTypesViews: {
    ...config.views.contentTypesViews,
    talk: TalkView,
  },
};