41. Extending Volto With a FAQ Block Type

We want to provide some information for speakers of the conference: Which topics are possible? What do I have to consider speaking at an online conference? FAQ section would come in handy. This could be done by creating a block type that offers a form for question and answer pairs and displays an accordion.

Let's start with our fresh add-on we created in the last chapter Extending Volto With Custom Add-on Package.

Volto add-on volto-accordion-block
Editing Volto add-on volto-accordion-block

We need a view and an edit form for the block. Create a src/FAQ/BlockView.jsx src/FAQ/BlockEdit.jsx.

The BlockView is a simple function component that displays a FAQ component with the data stored on the block.

 1import React from 'react';
 2import FAQ from './FAQ';
 3
 4const View = ({ data }) => {
 5  return (
 6    <div className="block faq">
 7      <FAQ data={data} />
 8    </div>
 9  );
10};
11
12export default View;

We outsource the FAQ component to file srch/FAQ/FAQ.jsx and make heavy use of Semantic UI components especially of an accordion with its respective behavior of expanding and collapsing.

1const FAQ = ({ data }) => {
2  const [activeIndex, setActiveIndex] = useState(new Set());
3
4  return data.faq_list?.faqs ? (
5    {data.faq_list.faqs.map((id_qa) => (

We primarily loop over the accordion elements and we remember the extended (not collapsed) elements.

Complete code of the FAQ component

 1import React, { useState } from 'react';
 2
 3import { Icon } from '@plone/volto/components';
 4import rightSVG from '@plone/volto/icons/right-key.svg';
 5import downSVG from '@plone/volto/icons/down-key.svg';
 6import AnimateHeight from 'react-animate-height';
 7
 8import { Accordion, Grid, Divider, Header } from 'semantic-ui-react';
 9
10const FAQ = ({ data }) => {
11  const [activeIndex, setActiveIndex] = useState(new Set());
12
13  return data.faq_list?.faqs ? (
14    <>
15      <Divider section />
16      {data.faq_list.faqs.map((id_qa) => (
17        <Accordion key={id_qa} fluid exclusive={false}>
18          <Accordion.Title
19            index={id_qa}
20            className="stretched row"
21            active={activeIndex.has(id_qa)}
22            onClick={() => {
23              const newSet = new Set(activeIndex);
24              activeIndex.has(id_qa) ? newSet.delete(id_qa) : newSet.add(id_qa);
25              setActiveIndex(newSet);
26            }}
27          >
28            <Grid>
29              <Grid.Row>
30                <Grid.Column width="1">
31                  {activeIndex.has(id_qa) ? (
32                    <Icon name={downSVG} size="20px" />
33                  ) : (
34                    <Icon name={rightSVG} size="20px" />
35                  )}
36                </Grid.Column>
37                <Grid.Column width="11">
38                  <Header as="h3">{data.faq_list.faqs_layout[id_qa][0]}</Header>
39                </Grid.Column>
40              </Grid.Row>
41            </Grid>
42          </Accordion.Title>
43          <div>
44            <Accordion.Content
45              className="stretched row"
46              active={activeIndex.has(id_qa)}
47            >
48              <Grid>
49                <Grid.Row>
50                  <Grid.Column width="1"></Grid.Column>
51                  <Grid.Column width="11">
52                    <div>
53                      <AnimateHeight
54                        key={id_qa}
55                        duration={300}
56                        height={activeIndex.has(id_qa) ? 'auto' : 0}
57                      >
58                        <div
59                          dangerouslySetInnerHTML={{
60                            __html: data.faq_list.faqs_layout[id_qa][1].data,
61                          }}
62                        />
63                      </AnimateHeight>
64                    </div>
65                  </Grid.Column>
66                </Grid.Row>
67              </Grid>
68            </Accordion.Content>
69          </div>
70          <Divider section />
71        </Accordion>
72      ))}
73    </>
74  ) : (
75    ''
76  );
77};
78
79export default FAQ;

Let's see how the data is stored on the block. Open your BlockEdit. See the helper component SidebarPortal. Everything inside is displayed in the Sidebar.

 1import React from 'react';
 2import { SidebarPortal } from '@plone/volto/components';
 3
 4import FAQSidebar from './FAQSidebar';
 5import FAQ from './FAQ';
 6
 7const Edit = ({ data, onChangeBlock, block, selected }) => {
 8  return (
 9    <div className={'block faq'}>
10      <SidebarPortal selected={selected}>
11        <FAQSidebar data={data} block={block} onChangeBlock={onChangeBlock} />
12      </SidebarPortal>
13
14      <FAQ data={data} />
15    </div>
16  );
17};
18
19export default Edit;

We outsource the edit form in a file FAQSidebar.jsx which displays the form according a schema of question and answers. The onChangeBlock event handler is inherited, it stores the value on the block.

 1import React from 'react';
 2import { FAQSchema } from './schema';
 3import InlineForm from '@plone/volto/components/manage/Form/InlineForm';
 4
 5const FAQSidebar = ({ data, block, onChangeBlock }) => {
 6  return (
 7    <InlineForm
 8      schema={FAQSchema}
 9      title={FAQSchema.title}
10      onChangeField={(id, value) => {
11        onChangeBlock(block, {
12          ...data,
13          [id]: value,
14        });
15      }}
16      formData={data}
17    />
18  );
19};
20
21export default FAQSidebar;

We define the schema in schema.js.

 1export const FAQSchema = {
 2  title: 'FAQ',
 3  fieldsets: [
 4    {
 5      id: 'default',
 6      title: 'Default',
 7      fields: ['faq_list'],
 8    },
 9  ],
10  properties: {
11    faq_list: {
12      title: 'Question and Answers',
13      type: 'faqlist',
14    },
15  },
16  required: [],
17};

The field faq_list has a type 'faqlist'. This has to be registered as a widget in src/config.js. This configuration is the central place where your add-on can customize the hosting Volto app. It's the place where we later also register our new block type with information about its view and edit form.

1import FAQListEditWidget from './FAQ/FAQListEditWidget';
2
3export default function applyConfig(config) {
4  config.widgets.type.faqlist = FAQListEditWidget;
5
6  return config;
7}

Now we will code the important part of the whole block type: the widget FAQListEditWidget. We need a form that consists of a list of existing questions and answers. The text should be editable. Additional pairs of questions and answers should be addable. Next step will be to let the list be drag- and droppable to reorder the items. Also should an item be deletable. That's a lot. Let's start with the list of fields displaying the existing values.

Create a FAQListEditWidget.jsx.

 1import { Form as VoltoForm } from '@plone/volto/components';
 2
 3const FAQListEditWidget = (props) => {
 4  const { value = {}, id, onChange } = props;
 5  // id is the field name: faq_list
 6  // value is the form data (see example in schema.js)
 7
 8  // qaList: array of [id_question, [question, answer]]
 9  const qaList = (value.faqs || []).map((key) => [key, value.faqs_layout[key]]);
10
11  return (
12    // loop over question answer pairs *qaList*
13      <VoltoForm
14        onSubmit={({ question, answer }) => {
15          onSubmitQAPair(childId, question, answer);
16        }}
17        formData={{
18          question: value.faqs_layout[childId][0],
19          answer: value.faqs_layout[childId][1],
20        }}
21        schema={QuestionAnswerPairSchema(
22          props.intl.formatMessage(messages.question),
23          props.intl.formatMessage(messages.answer),
24        )}
25      />

You see the Volto Form component with its onSubmit event, the form data and the schema to be used.

Complete code of the FAQListEditWidget component

  1import React from 'react';
  2import { defineMessages, injectIntl } from 'react-intl';
  3import { v4 as uuid } from 'uuid';
  4import { omit, without } from 'lodash';
  5import move from 'lodash-move';
  6import { Icon, FormFieldWrapper } from '@plone/volto/components';
  7import { Form as VoltoForm } from '@plone/volto/components';
  8import { DragDropList } from '@eeacms/volto-blocks-form/components';
  9
 10import dragSVG from '@plone/volto/icons/drag.svg';
 11import trashSVG from '@plone/volto/icons/delete.svg';
 12import plusSVG from '@plone/volto/icons/circle-plus.svg';
 13
 14import { QuestionAnswerPairSchema } from './schema.js';
 15
 16const messages = defineMessages({
 17  question: {
 18    id: 'Question',
 19    defaultMessage: 'Question',
 20  },
 21  answer: {
 22    id: 'Answer',
 23    defaultMessage: 'Answer',
 24  },
 25  add: {
 26    id: 'add',
 27    defaultMessage: 'add',
 28  },
 29});
 30
 31export function moveQuestionAnswerPair(formData, source, destination) {
 32  return {
 33    ...formData,
 34    faqs: move(formData.faqs, source, destination),
 35  };
 36}
 37
 38const empty = () => {
 39  return [uuid(), ['', {}]];
 40};
 41
 42const FAQListEditWidget = (props) => {
 43  const { value = {}, id, onChange } = props;
 44  // id is the field name: faq_list
 45  // value is the form data (see example in schema.js)
 46
 47  const onSubmitQAPair = (id_qa, question, answer) => {
 48    onChange(id, {
 49      ...value,
 50      faqs_layout: {
 51        ...(value.faqs_layout || {}),
 52        [id_qa]: [question, answer],
 53      },
 54    });
 55  };
 56
 57  const addQA = () => {
 58    const [newId, newData] = empty();
 59    onChange(id, {
 60      ...value,
 61      faqs: [...(value.faqs || []), newId],
 62      faqs_layout: {
 63        ...(value.faqs_layout || {}),
 64        [newId]: newData,
 65      },
 66    });
 67  };
 68
 69  // qaList array of [id_question, [question, answer]]
 70  const qaList = (value.faqs || []).map((key) => [key, value.faqs_layout[key]]);
 71
 72  const showAdd = true;
 73  return (
 74    <FormFieldWrapper
 75      {...props}
 76      draggable={false}
 77      columns={1}
 78      className="drag-drop-list-widget"
 79    >
 80      <div className="columns-area">
 81        <DragDropList
 82          childList={qaList}
 83          onMoveItem={(result) => {
 84            const { source, destination } = result;
 85            if (!destination) {
 86              return;
 87            }
 88            const newFormData = moveQuestionAnswerPair(
 89              value,
 90              source.index,
 91              destination.index,
 92            );
 93            onChange(id, newFormData);
 94            return true;
 95          }}
 96        >
 97          {(dragProps) => {
 98            const { childId, draginfo } = dragProps;
 99            return (
100              <div ref={draginfo.innerRef} {...draginfo.draggableProps}>
101                <div style={{ position: 'relative' }}>
102                  <div
103                    style={{
104                      visibility: 'visible',
105                      display: 'inline-block',
106                    }}
107                    {...draginfo.dragHandleProps}
108                    className="drag handle wrapper"
109                  >
110                    <Icon name={dragSVG} size="18px" />
111                  </div>
112                  <div className="column-area">
113                    <VoltoForm
114                      onSubmit={({ question, answer }) => {
115                        onSubmitQAPair(childId, question, answer);
116                      }}
117                      formData={{
118                        question: value.faqs_layout[childId][0],
119                        answer: value.faqs_layout[childId][1],
120                      }}
121                      schema={QuestionAnswerPairSchema(
122                        props.intl.formatMessage(messages.question),
123                        props.intl.formatMessage(messages.answer),
124                      )}
125                    />
126                    {qaList?.length > 1 ? (
127                      <button
128                        onClick={() => {
129                          onChange(id, {
130                            faqs: without(value.faqs, childId),
131                            faqs_layout: omit(value.faqs_layout, [childId]),
132                          });
133                        }}
134                      >
135                        <Icon name={trashSVG} size="18px" />
136                      </button>
137                    ) : (
138                      ''
139                    )}
140                  </div>
141                </div>
142              </div>
143            );
144          }}
145        </DragDropList>
146        {showAdd ? (
147          <button
148            aria-label={props.intl.formatMessage(messages.add)}
149            onClick={addQA}
150          >
151            <Icon name={plusSVG} size="18px" />
152          </button>
153        ) : (
154          ''
155        )}
156      </div>
157    </FormFieldWrapper>
158  );
159};
160
161export default injectIntl(FAQListEditWidget);

The form is fructified by the schema QuestionAnswerPairSchema. It's simple, just a string field with a textarea widget for the question and a such for the answer, but with a richtext widget to have some editing and styling tools available.

src/FAQ/schema.js

 1export const QuestionAnswerPairSchema = (title_question, title_answer) => {
 2  return {
 3    title: 'Question and Answer Pair',
 4    fieldsets: [
 5      {
 6        id: 'default',
 7        title: 'QA pair',
 8        fields: ['question', 'answer'],
 9      },
10    ],
11    properties: {
12      question: {
13        title: title_question,
14        type: 'string',
15        widget: 'textarea',
16      },
17      answer: {
18        title: title_answer,
19        type: 'string',
20        widget: 'richtext',
21      },
22    },
23    required: ['question', 'answer'],
24  };
25};

What's left to do? You created a block type with view and edit form and even a nice widget for the editor to fill in questions and answers. Register the block type and you are good to start your app and create an FAQ for the conference speakers.

Go to config.js and register your block type.

 1import icon from '@plone/volto/icons/list-bullet.svg';
 2
 3import FAQBlockEdit from './FAQ/BlockEdit';
 4import FAQBlockView from './FAQ/BlockView';
 5import FAQListEditWidget from './FAQ/FAQListEditWidget';
 6
 7export default function applyConfig(config) {
 8  config.blocks.blocksConfig.faq_viewer = {
 9    id: 'faq_viewer',
10    title: 'FAQ',
11    edit: FAQBlockEdit,
12    view: FAQBlockView,
13    icon: icon,
14    group: 'text',
15    restricted: false,
16    mostUsed: false,
17    sidebarTab: 1,
18    security: {
19      addPermission: [],
20      view: [],
21    },
22  };
23
24  config.widgets.type.faqlist = FAQListEditWidget;
25
26  return config;
27}

As we now apply our configuration of the new block type, the app is enriched with an accordion block.

index.js

1import applyConfig from './config';
2
3export default applyConfig;

Run

yarn start

You see

Module not found: Can't resolve '@eeacms/volto-blocks-form/components'

Why is this? We want the accordion to be reorderable and use the DragDropList component of another add-on: @eeacms/volto-blocks-form. Add it to the dependencies of your add-on.

package.json

"dependencies": {
  "@eeacms/volto-blocks-form": "@eeacms/volto-blocks-form"
},

The following might change the next time:

Add to your apps package.json:

"addons": ["@greenthumb/volto-custom-addon", "@eeacms/volto-blocks-form"],

Compile and start your project's app:

yarn
yarn start
@rohberg/volto-accordion-block

See the complete add-on code @rohberg/volto-accordion-block [^id3]

41.1. Save your work to Github

Your add-on is ready to use. As by now your repository is on Github. As long as it is published, you can share it with others.

A Volto project uses this add-on via 'mrs.developer' 1

Install mrs.developer to let the project know about the source of your add-on.

yarn add mrs-developer -WD

The configuration file mrs.developer.json instructs mrs.developer from where it has to pull the package. So, create mrs.developer.json and add:

{
    "greenthumb-volto-custom-addon": {
        "package": "@greenthumb/volto-custom-addon",
        "url": "git@github.com:greenthumb/volto-custom-addon.git",
        "path": "src"
    }
}

Run

yarn develop

An official release is done on npm. Switch to section Release a Volto add-on.

[^id3]: Volto accordion block Started as an example for the training it is ready to use for creating a questions and answer sections.


1

mrs.developer Pull a package from git and set it up as a dependency for the current project codebase.