Disclaimer: This website requires Please enable JavaScript in your browser settings for the best experience.

HomeDev GuideRecipesAPI Reference
Dev GuideAPI ReferenceUser GuideGitHubNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev Guide

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

  1. Go to Settings > Forms Settings.
  2. 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.

  3. Go to Settings > Content Types.
  4. Click Create New and select Block Type from the drop-down list.
  5. Enter ParagraphElement for the Name and Display name fields.
  6. Click Create.
  7. Click Add Property and select Text from the drop-down list.
  8. Enter Text for the Name in the Configure Property page.
  9. Select Text Type from the drop-down menu and select XHTML string (>255).
  10. Click Save.
  11. Go to Settings.
  12. Select Available for composition in Visual Builder and Display as Element.
  13. Click Save.

Run the sample

  1. Clone the cms-visual-builder-forms repository.

  2. Create a file named .env.local.

  3. Copy the Single key from the Render Content section from the Optimizely CMS (SaaS) dashboard.

  4. Enter "GRAPH_SINGLE_KEY=" and paste your Single key from step 3 in the .env.local file.

  5. Enter "CMS_URL=" and paste your CMS URL, such as app-mysuperapp.cms.optimizely.com.

  6. Run yarn install.

  7. Run yarn codegen to generate GraphQL queries.

  8. Run yarn dev to start the site. It run on https://localhost.

  9. Configure the site preview in Settings > Applications to see it in Edit view of your CMS (SaaS) instance.

  10. Click Create Website Application.

  11. 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.

  12. Go to Edit view and select Add > Create Shared Block.

  13. Select Form Container type.

  14. Add a form step, row, column, and some form elements (such as Textbox and Submit).

  15. Select More > Create Experience from the Edit view.

  16. Enter a name and select Blank Experience.

  17. Drag the form container to the experience.

  18. Add a section, row, column, and an element of ParagraphElement type. Fill in the text Hello world and it displays in the preview.

  19. Click Outline to go back to the experience preview.

    See also Create a form.

Hello World app details for developers

Prerequisites

  1. 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

  2. Add GraphQL support by installing the following dependencies:
    yarn add @apollo/client graphql

  3. Install development tools to generate objects based on your GraphQL schema.
    yarn add --dev @graphql-codegen/cli @graphql-codegen/client-preset @parcel/watcher

  4. 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
    
  5. 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.

  1. Go to Settings > Forms Settings.

  2. 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

  1. Go to your Next.js application and fill in your GRAPH_SINGLE_KEY and GRAPH_URL into .env.local file. CMS_URL is also required to communicate with Visual Builder.

  2. Run the script yarn codegen. It generates the schema in the src/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 run yarn 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