Render a form with Optimizely Graph
Note
This topic is for front-end developers. See episerver/cms-saas-vercel-demo for a complete demo site for Optimizely Content Management System (SaaS), taking all content from Optimizely Graph.
The repository contains a Hello World example of the Optimizely Visual Builder Forms. Ensure you have an Optimizely Optimizely Content Management System (SaaS) instance running.
Configure a form element
- Go to Settings > Forms Settings.
- Click Activate to activate the form elements.
Important
When you activate form elements, you cannot deactivate them because that would break the forms that use those elements.
- Go to Settings > Content Types.
- Click Create New and select Block Type from the drop-down list.
- Enter
ParagraphElement
for the Name and Display name fields. - Click Create.
- Click Add Property and select Text from the drop-down list.
- Enter Text for the Name in the Configure Property page.
- Select Text Type from the drop-down menu and select XHTML string (>255).
- Click Save.
- Go to Settings.
- Select Available for composition in Visual Builder and Display as Element.
- Click Save.
Run the sample
-
Create a file named
.env.local
. -
Copy the Single key from the Render Content section from the Optimizely CMS (SaaS) dashboard.
-
Enter
"GRAPH_SINGLE_KEY="
and paste your Single key from step 3 in the.env.local
file. -
Enter
"CMS_URL="
and paste your CMS URL, such asapp-mysuperapp.cms.optimizely.com
. -
Run
yarn install
. -
Run
yarn codegen
to generate GraphQL queries. -
Run
yarn dev
to start the site. It run onhttps://localhost
. -
Configure the site preview in Settings > Applications to see it in Edit view of your CMS (SaaS) instance.
-
Click Create Website Application.
-
Add an application website pointing to your local NextJS application running on
https://localhost:3000
. It should look similar to the following example. Click Create Website.Note
See episerver / cms-saas-netlify-demo for information.
-
Go to Edit view and select Add > Create Shared Block.
-
Select Form Container type.
-
Add a form step, row, column, and some form elements (such as Textbox and Submit).
-
Select More > Create Experience from the Edit view.
-
Enter a name and select Blank Experience.
-
Drag the form container to the experience.
-
Add a section, row, column, and an element of ParagraphElement type. Fill in the text
Hello world
and it displays in the preview. -
Click Outline to go back to the experience preview.
See also Create a form.
Hello World app details for developers
Prerequisites
-
Create a simple
Next.js
app based on the Hello World example template to consume data from the instance.
npx create-next-app@latest vb-test --use-yarn --example hello-world vb-test
-
Add GraphQL support by installing the following dependencies:
yarn add @apollo/client graphql
-
Install development tools to generate objects based on your GraphQL schema.
yarn add --dev @graphql-codegen/cli @graphql-codegen/client-preset @parcel/watcher
-
Add a
codegen.ts
configuration file for the codegen plugin in the root folder and paste the following code:import { CodegenConfig } from "@graphql-codegen/cli"; import { loadEnvConfig } from "@next/env"; loadEnvConfig(process.cwd()); const graphUrl = process.env.GRAPH_URL const graphSingleKey = process.env.GRAPH_SINGLE_KEY const config : CodegenConfig = { schema: `https://${graphUrl}/content/v2?auth=${graphSingleKey}`, documents: ["src/**/*.{ts,tsx}"], ignoreNoDocuments: true, generates: { './src/graphql/': { preset: 'client', plugins: \[], } } } export default config
-
Add a script to
package.json
to generate types based on your GraphQL schema.
"codegen": "graphql-codegen"
Activate forms
Before you run the codegen
, activate forms in your CMS (SaaS) instance.
-
Go to Settings > Forms Settings.
-
Click Activate to activate forms.
Important
When you activate form elements, you cannot deactivate them because that would break the forms that use those elements.
Add a paragraph element component
Create a React component to display a ParagraphElement with the following code (or similar).
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
export const ParagraphElementFragment = graphql(/* GraphQL */ `
fragment paragraphElement on ParagraphElement {
Text {
html
}
}
`)
const ParagraphElementComponent = (props: {
paragraphElement: FragmentType<typeof ParagraphElementFragment>
}) => {
const paragraphElement = useFragment(ParagraphElementFragment, props.paragraphElement)
// @ts-ignore
return <div dangerouslySetInnerHTML={{ __html: paragraphElement.Text?.html }}></div>
}
export default ParagraphElementComponent
Add a Textbox form element
Create a React component to display a TextboxElementComponent with the following code (or similar).
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { isRequiredValidator } from '@/helpers/validatorHelper'
export const TextboxComponentNodeFragment = graphql(/* GraphQL */ `
fragment textboxElement on OptiFormsTextboxElement {
Label
Tooltip
Placeholder
AutoComplete
PredefinedValue
Validators
}
`)
const TextboxElementComponent = (props: {
textboxElement: FragmentType<typeof TextboxComponentNodeFragment>,
formState?: any
}) => {
const node = useFragment(TextboxComponentNodeFragment, props.textboxElement)
return (
<div>
<Label>{node.Label} <span className='form-element-required'>{isRequiredValidator(node.Validators) ? "*" : ""}</span></Label>
<Input
type='text'
autoComplete={node.AutoComplete ? 'on' : 'off'}
placeholder={node.Placeholder ?? ''}
onChange={(e) => props.formState[node.Label!] = e.target.value }
/>
</div>
)
}
export default TextboxElementComponent
Add a Submit form element
Create a React component to display a SubmitElementComponent with the following code (or similar).
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import axios from 'axios'
export const SubmitElementComponentNodeFragment = graphql(/* GraphQL */ `
fragment submitElement on OptiFormsSubmitElement {
Label
Tooltip
}
`)
const SubmitElementComponent = (props: {
submitElement: FragmentType<typeof SubmitElementComponentNodeFragment>,
formState?: any
}) => {
const node = useFragment(SubmitElementComponentNodeFragment, props.submitElement)
return (
<>
<div><br /></div>
<Button onClick={(e) => {
console.log(props.formState)
axios.post((window as any).submitUrl, props.formState)
.then(response => {
alert('Form submitted successfully!');
})
.catch(error => {
console.error('Error submitting form:', error);
});
}}>
{node.Label}
</Button>
</>
)
}
export default SubmitElementComponent
Add the layout component
Add the master component to render the layout (section, row, and columns):
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import CompositionNodeComponent from './CompositionNodeComponent'
import { onContentSaved } from "@/helpers/onContentSaved";
import FormsComponent from './FormsComponent';
export const VisualBuilder = graphql(/* GraphQL */ `
query VisualBuilder($key: String, $version: String) {
_Experience(where: {
_metadata: { key: { eq: $key } }
_or: { _metadata: { version: { eq: $version } } }
}) {
items {
composition {
grids: nodes {
... on CompositionStructureNode {
key
__typename
displayName
nodeType
layoutType
component {
..._IComponent
}
nodes: nodes {
... on CompositionStructureNode {
key
__typename
displayName
nodeType
layoutType
nodes: nodes {
... on CompositionStructureNode {
key
__typename
displayName
nodeType
layoutType
nodes: nodes {
...compositionComponentNode
... on CompositionStructureNode {
key
__typename
displayName
nodeType
layoutType
nodes: nodes {
...compositionComponentNode
}
}
}
}
}
}
}
}
}
}
_metadata {
key
version,
}
}
}
}
fragment _IComponent on _IComponent {
__typename
...FormContainerData
}
fragment FormContainerData on OptiFormsContainerData {
SubmitConfirmationMessage
ResetConfirmationMessage
SubmitUrl {
type
default
hierarchical
internal
graph
base
}
Title
Description
ShowSummaryMessageAfterSubmission
}
`)
interface VisualBuilderProps {
contentKey?: string;
version?: string;
}
const VisualBuilderComponent: FC<VisualBuilderProps> = ({ version, contentKey }) => {
const formState: any = {};
const variables: Record<string, unknown> = {};
if (version) {
variables.version = version;
}
if (contentKey) {
variables.key = contentKey;
}
const { data, refetch } = useQuery(VisualBuilder, {
variables: variables,
notifyOnNetworkStatusChange: true,
});
useEffect(() => {
onContentSaved(_ => {
const contentIdArray = _.contentLink.split('_')
if (contentIdArray.length > 1) {
version = contentIdArray[contentIdArray.length - 1]
variables.version = version;
}
refetch(variables);
})
}, []);
const experiences = data?._Experience?.items;
if (!experiences) {
return null;
}
if (data?._Experience?.items?.length === 0) {
return <FormsComponent version={version} contentKey={contentKey} />;
}
const experience: any = experiences[experiences.length - 1];
if (!experience) {
return null;
}
return (
<div className="relative w-lg flex-1 vb:outline">
<div className="relative w-lg flex-1 vb:outline">
{experience?.composition?.grids?.map((grid: any) =>
<div key={grid.key} className="relative w-lg flex flex-col flex-nowrap justify-start vb:grid"
data-epi-block-id={grid.key}>
{RenderCompositionNode(grid, formState)}
</div>
)}
</div>
</div>
)
}
export default VisualBuilderComponent
export const RenderCompositionNode = (node: any, formState?: any): JSX.Element | null => {
if (!node || !node.__typename) {
return null;
}
const { layoutType } = node;
if (layoutType === "form") {
const w = window as any;
w.submitUrl = node.component.SubmitUrl.default;
}
// Handle CompositionStructureNode with different nodeTypes
if (node.__typename === "CompositionStructureNode") {
const { key, nodeType, nodes } = node;
// Switch based on nodeType
switch (nodeType) {
case "section":
return (
<div key={key} className="flex flex-col vb:section" data-epi-block-id={key}>
{nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
</div>
);
case "step":
return (
<div key={key} className="flex flex-col vb:step" data-epi-block-id={key}>
{nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
</div>
);
case "row":
return (
<div key={key} className="flex flex-row flex-wrap justify-start vb:row gap-4" data-epi-block-id={key}>
{nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
</div>
);
case "column":
return (
<div key={key} className="flex-1 flex flex-col flex-nowrap justify-start vb:col" data-epi-block-id={key}>
{nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
</div>
);
default:
// Handle any other nodeType or fallback to generic structure
return (
<div key={key} className="flex flex-col vb:generic" data-epi-block-id={key}>
{nodes?.map((childNode: any) => RenderCompositionNode(childNode, formState))}
</div>
);
}
}
// Handle CompositionComponentNode (leaf elements)
if (node.__typename === "CompositionComponentNode" || node.component) {
return (
<div key={node.key} data-epi-block-id={node.key}>
<CompositionNodeComponent compositionComponentNode={node} formState={formState}/>
</div>
);
}
// Fallback for unknown node types
return null;
};
Optimizely supports rendering both forms and sections. For sections, you first iterate over sections, then rows, then columns, and finally elements. For forms, you iterate over steps, then rows, then columns, and finally form elements. You wrap each of these layout items into basic Tailwind grid classes.
The following example shows just one element type. You do not want to hard-code anything so this has a pattern that you can use to use a different element component per nodeType
.
import { FragmentType, useFragment } from '../../graphql/fragment-masking'
import { graphql } from '@/graphql'
import TextboxElementComponent from '../elements/TextboxElementComponent'
import SubmitElementComponent from '../elements/SubmitElementComponent'
import ParagraphElementComponent from '../elements/ParagraphElementComponent'
export const CompositionComponentNodeFragment = graphql(/* GraphQL */ `
fragment compositionComponentNode on CompositionComponentNode {
key
component {
_metadata {
types
}
...textboxElement
...submitElement
...paragraphElement
}
}
`)
const CompositionComponentNodeComponent = (props: {
compositionComponentNode: FragmentType<typeof CompositionComponentNodeFragment>,
formState?: any
}) => {
const compositionComponentNode = useFragment(CompositionComponentNodeFragment, props.compositionComponentNode)
const component = compositionComponentNode.component
switch (component?.__typename) {
case "OptiFormsTextboxElement":
return <TextboxElementComponent textboxElement={component} formState={props.formState}/>
case "OptiFormsSubmitElement":
return <SubmitElementComponent submitElement={component} formState={props.formState}/>
case "ParagraphElement":
return <ParagraphElementComponent paragraphElement={component} />
default:
console.log(`Unknown component type: ${component?.__typename}`);
return <>NotImplementedException</>
}
}
export default CompositionComponentNodeComponent
Based on component.__typename
, you can use different components such as OptiFormsTextboxElement
and OptiFormsSubmitElement
.
Generate GraphQL
-
Go to your
Next.js
application and fill in yourGRAPH_SINGLE_KEY
andGRAPH_URL
into.env.local
file.CMS_URL
is also required to communicate with Visual Builder. -
Run the script
yarn codegen
. It generates the schema in thesrc/graphql
folder.
After that, src/graphql
contains files that let you write GraphQL queries.
Note
Every time you change the query inside the
graphql()
function, you need to runyarn codegen
again.
Subscribe to content changes
Subscribe to a special event to know when content is updated. In this repo, the subscription is already done in onContentSaved.ts
.
window.addEventListener("optimizely:cms:contentSaved", (event: any) => {
const message = event.detail as ContentSavedEventArgs;
});
It is defined as follows:
interface ContentSavedEventArgs {
contentLink: string;
previewUrl: string;
isIndexed: boolean;
properties: PropertySaved\[];
parentId?: string;
sectionId?: string;
}
See also Refresh the application's view when content has changed
Updated 5 days ago