Step 15 of 21 (71% complete)

Visual Builder

Step Code

The code for this specific step can be found on the following branch:

Click on a link to view the code for this step on GitHub.

Introduction

Visual Builder is an intuitive editor interface in Optimizely Content Management System (SaaS) that empowers non-technical users to create and manage content without developer involvement. It allows for designing, modifying, and reusing blueprints (layout templates) directly within the CMS interface, enabling content creators to build adaptable experiences across multiple channels.

While Visual Builder simplifies content creation for non-technical users, integrating it into a project requires development effort. This guide outlines the necessary steps for effective integration.

Visual Builder Structure

Hierarchy in Optimizely Graph for SaaS CMS: Content Structure Source

The key components in this structure are:

  1. Experience - Contains all properties like Page Types and includes a built-in ContentArea for blocks. In Graph Schema, these blocks are named composition.
  2. Section - Functions like a Block in Content Area. You can specify if a block can be a section in the Admin UI. The built-in "Blank Section" includes rows and columns. content
  3. Element - Used in "Blank Section" and displayed in a row structure: SectionRowColumnElement
  4. Blueprints - Reusable layout templates that include shared blocks. For example, if all News pages have the same structure (HeroBlock, InfoBlock, ContactBlock), you can create a blueprint with these blocks and simply change the content for each new page.

Integration Steps

1. Define Custom Types

The types generated by the SDK are not sufficient, so we need to create custom types for handling Visual Builder experiences.

// lib/optimizely/types/experience.ts
import type { SeoExperience } from '@/lib/optimizely/types/generated'

export interface Row {
  key: string
  columns?: Column[]
}

export interface Column {
  key: string
  elements?: ExperienceElement[]
}

export interface ExperienceElement {
  key: string
  displaySettings?: {
    value: string
    key: string
  }[]
  component?: any
}

export interface VisualBuilderNode {
  nodeType: 'section' | 'component'
  key: string
  component?: any
  rows?: Row[]
}

export type SafeVisualBuilderExperience = {
  composition?: {
    nodes?: VisualBuilderNode[]
  }
} & SeoExperience

Create a type that serves as the foundation for all blocks, which will be passed into the block factory mapper.

// lib/optimizely/types/block.ts

export interface BlockBase {
  isFirst: boolean
  preview: boolean
  displaySettings?: {
    value: string
    key: string
  }[]
}

2.Create a Wrapper Component

This component acts as the layout manager and integrates a Content Area Mapper to handle block rendering.

// components/visual-builder/wrapper.tsx
import ContentAreaMapper from '../content-area/mapper'
import type {
  Column,
  Row,
  VisualBuilderNode,
  SafeVisualBuilderExperience,
} from '@/lib/optimizely/types/experience'

export default function VisualBuilderExperienceWrapper({
  experience,
}: {
  experience?: SafeVisualBuilderExperience
}) {
  if (!experience?.composition?.nodes) {
    return null
  }

  const { nodes } = experience.composition

  return (
    <div className="vb:outline relative w-full flex-1">
      <div className="vb:outline relative w-full flex-1">
        {nodes.map((node: VisualBuilderNode) => {
          if (node.nodeType === 'section') {
            return (
              <div
                key={node.key}
                className="vb:grid relative flex w-full flex-col flex-wrap"
                data-epi-block-id={node.key}
              >
                {node.rows?.map((row: Row) => (
                  <div
                    key={row.key}
                    className="vb:row flex flex-1 flex-col flex-nowrap md:flex-row"
                  >
                    {row.columns?.map((column: Column) => (
                      <div
                        className="vb:col flex flex-1 flex-col flex-nowrap justify-start"
                        key={column.key}
                      >
                        <ContentAreaMapper
                          experienceElements={column.elements}
                          isVisualBuilder
                        />
                      </div>
                    ))}
                  </div>
                ))}
              </div>
            )
          }

          if (node.nodeType === 'component' && node.component) {
            return (
              <div
                key={node.key}
                className="vb:node relative w-full"
                data-epi-block-id={node.key}
              >
                <ContentAreaMapper blocks={[node.component]} />
              </div>
            )
          }

          return null
        })}
      </div>
    </div>
  )
}

3. Update the Block Factory Mapper

Modify the block factory mapper to accommodate blocks that use a different format from standard Optimizely page types. (diffrent GraphQL schema)

// components/content-area/mapper.tsx
import { ExperienceElement } from '@/lib/optimizely/types/experience'
import Block from './block'

function ContentAreaMapper({
  blocks,
  preview = false,
  isVisualBuilder = false,
  experienceElements,
}: {
  blocks?: any[] | null
  preview?: boolean
  isVisualBuilder?: boolean
  experienceElements?: ExperienceElement[] | null
}) {
  if (isVisualBuilder) {
    if (!experienceElements || experienceElements.length === 0) return null

    return (
      <>
        {experienceElements?.map(
          ({ displaySettings, component, key }, index) => (
            <div
              data-epi-block-id={key}
              key={`${component?.__typename satisfies string}--${index}`}
            >
              <Block
                typeName={component?.__typename}
                props={{
                  ...component,
                  displaySettings,
                  isFirst: index === 0,
                  preview,
                }}
              />
            </div>
          )
        )}
      </>
    )
  }
  if (!blocks || blocks.length === 0) return null

  return (
    <>
      {blocks?.map(({ __typename, ...props }, index) => (
        <Block
          key={`${__typename satisfies string}--${index}`}
          typeName={__typename}
          props={{
            ...props,
            isFirst: index === 0,
            preview,
          }}
        />
      ))}
    </>
  )
}

export default ContentAreaMapper

4. Modify the CMS Page to Fetch Visual Experiences

Adjust the CMS page logic to retrieve Visual Builder experiences when a standard page is not found. We need to also modify generating proper Metadata and generateStaticParams to include generating static pages for Visual Builder during production build.

// app/(site)/[locale]/[slug]/page.tsx

export async function generateMetadata(props: {
  params: Promise<{ locale: string; slug?: string }>
}): Promise<Metadata> {
  const { locale, slug = '' } = await props.params
  const locales = getValidLocale(locale)
  const formattedSlug = `/${slug}`
  const { data, errors } = await optimizely.getPageByURL({
    locales: [locales],
    slug: formattedSlug,
  })

  if (errors) {
    return {}
  }

  const page = data?.CMSPage?.items?.[0]
  if (!page) {
    const experienceData = await optimizely.GetVisualBuilderBySlug({
      locales: [locales],
      slug: formattedSlug,
    })

    const experience = experienceData.data?.SEOExperience?.items?.[0]

    if (experience) {
      return {
        title: experience?.title,
        description: experience?.shortDescription || '',
        keywords: experience?.keywords ?? '',
        alternates: generateAlternates(locale, formattedSlug),
      }
    }

    return {}
  }

  return {
    title: page.title,
    description: page.shortDescription || '',
    keywords: page.keywords ?? '',
    alternates: generateAlternates(locale, formattedSlug),
  }
}


export async function generateStaticParams() {
  try {
    const pageTypes = ['CMSPage', 'SEOExperience']
    const pathsResp = await optimizely.AllPages({ pageType: pageTypes })
 }
}

export default async function CmsPage(props: {
  params: Promise<{ locale: string; slug?: string }>
}) {
  const { locale, slug = '' } = await props.params
  const locales = getValidLocale(locale)
  const formattedSlug = `/${slug}`

  const { data, errors } = await optimizely.getPageByURL({
    locales: [locales],
    slug: formattedSlug,
  })

  if (errors || !data?.CMSPage?.items?.[0]) {
    const experienceData = await optimizely.GetVisualBuilderBySlug({
      locales: [locales],
      slug: formattedSlug,
    })

    const experience = experienceData.data?.SEOExperience?.items?.[0] as
      | SafeVisualBuilderExperience
      | undefined

    if (experience) {
      return (
        <Suspense>
          <VisualBuilderExperienceWrapper experience={experience} />
        </Suspense>
      )
    }

    return notFound()
  }
  const page = data.CMSPage.items[0]
  const blocks = (page?.blocks ?? []).filter(
    (block) => block !== null && block !== undefined
  )

  return (
    <>
      <Suspense>
        <ContentAreaMapper blocks={blocks} />
      </Suspense>
    </>
  )
}

5. GraphQL Query to Fetch Visual Builder Data

# lib/optimizely/queries/GetVisualBuilderBySlug.graphql
query GetVisualBuilderBySlug($locales: [Locales], $slug: String) {
  SEOExperience(
    locale: $locales
    where: { _metadata: { url: { default: { eq: $slug } } } }
  ) {
    items {
      title
      shortDescription
      keywords
      composition {
        nodes {
          nodeType
          key
          displaySettings {
            value
            key
          }
          ... on CompositionComponentNode {
            component {
              ...ItemsInContentArea
            }
          }
          ... on CompositionStructureNode {
            key
            rows: nodes {
              ... on CompositionStructureNode {
                key
                columns: nodes {
                  ... on CompositionStructureNode {
                    key
                    elements: nodes {
                      key
                      displaySettings {
                        value
                        key
                      }
                      ... on CompositionComponentNode {
                        component {
                          ...ItemsInContentArea
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

6. Create Display Templates via API

Visual Bulder introduces new way for adding styles. For exaxmple styles for block, way of displaying the block. Place for adding content is on another page, and if button should be primary, secondary etc is now added in Styles place. Currently there is no UI for adding this values, so we need use API to add those styles

Documenation

Example CURL for that: Change URL and add Bearer token

curl --location 'https://app-test.cms.optimizely.com/_cms/preview2/displaytemplates' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
   "key": "ProfileBlock",
   "displayName": "Profile Block",
   "baseType": "component",
   "isDefault": true,
   "settings": {
      "colorScheme": {
         "displayName": "Color scheme",
         "editor": "select",
         "sortOrder": 10,
         "choices": {
            "default": {
               "displayName": "Default",
               "sortOrder": 10
            },
            "primary": {
               "displayName": "Primary",
               "sortOrder": 20
            },
            "secondary": {
               "displayName": "Secondary",
               "sortOrder": 30
            }
         }
      }
   }
}'

7. Handle Styles tab in code, from GraphQL this settings will be passed as displaySettings

Note: Best practice for handling that in taiwlind is to use class-variance-authority We can easily define new variants and it is easy to menage this

import Image from 'next/image'
import { Card, CardContent } from '@/components/ui/card'
import { ProfileBlock as ProfileBlockProps } from '@/lib/optimizely/types/generated'
import { BlockBase } from '@/lib/optimizely/types/block'
import { cva } from 'class-variance-authority'

type ProfileBlockPropsV2 = ProfileBlockProps & BlockBase

const backgroundVariants = cva('container mx-auto px-4 py-16', {
  variants: {
    colorScheme: {
      default: 'border-none bg-[#f9e6f0] text-[#2d2d2d]',
      primary: 'border-none bg-primary text-white',
      secondary: 'border-none bg-secondary text-secondary-foreground',
    },
  },
  defaultVariants: {
    colorScheme: 'default',
  },
})

export default function ProfileBlock({
  imageSrc,
  name,
  title,
  bio,
  isFirst,
  displaySettings,
}: ProfileBlockPropsV2) {
  const colorScheme =
    displaySettings?.find((setting) => setting.key === 'colorScheme')?.value ||
    'default'

  return (
    <section className="container mx-auto px-4 py-16">
      <Card
        className={backgroundVariants({
          colorScheme: colorScheme as 'default' | 'primary' | 'secondary',
        })}
      >
        <CardContent className="p-8">
          <div className="grid items-start gap-12 md:grid-cols-2">
            <div className="relative mx-auto aspect-square w-full max-w-md">
              <Image
                src={imageSrc || '/placeholder.svg'}
                alt={title ?? ''}
                fill
                className="rounded-lg object-cover"
                priority={isFirst}
              />
            </div>
            <div className="space-y-4">
              <h1 className="text-3xl font-bold" data-epi-edit="name">
                {name}
              </h1>
              <p className="text-xl" data-epi-edit="title">
                {title}
              </p>
              <div className="mt-6">
                <h2 className="mb-2 text-lg font-semibold">Bio:</h2>
                <p className="leading-relaxed" data-epi-edit="bio">
                  {bio}
                </p>
              </div>
            </div>
          </div>
        </CardContent>
      </Card>
    </section>
  )
}

Preview Mode

Visual Builder is much more powerfull in preview mode than normal pages, because of user expiernce, how easy to manage the content.

1. Add Page

In order to handle preview mode let add new page in (draft) route group.

// app/(draft)/[locale]/draft/[version]/experience/[key]/page.tsx
import OnPageEdit from '@/components/draft/on-page-edit'
import VisualBuilderExperienceWrapper from '@/components/visual-builder/wrapper'
import { optimizely } from '@/lib/optimizely/fetch'
import { SafeVisualBuilderExperience } from '@/lib/optimizely/types/experience'
import { getValidLocale } from '@/lib/optimizely/utils/language'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'

export const revalidate = 0
export const dynamic = 'force-dynamic'

export default async function Page(props: {
  params: Promise<{ key: string; locale: string; version: string }>
}) {
  const { isEnabled: isDraftModeEnabled } = await draftMode()
  if (!isDraftModeEnabled) {
    return notFound()
  }

  const { locale, version, key } = await props.params
  const locales = getValidLocale(locale)

  const experienceData = await optimizely.VisualBuilder(
    { key, version, locales },
    { preview: true }
  )
  const experience = experienceData.data?.SEOExperience?.items?.[0] as
    | SafeVisualBuilderExperience
    | undefined

  if (!experience) {
    return notFound()
  }

  return (
    <Suspense>
      <OnPageEdit
        version={version}
        currentRoute={`/${locale}/draft/${version}/experience/${key}`}
      />
      <VisualBuilderExperienceWrapper experience={experience} />
    </Suspense>
  )
}

2. Create GraphQL query

query VisualBuilder($locales: [Locales], $key: String, $version: String) {
  SEOExperience(
    locale: $locales
    where: {
      _metadata: { key: { eq: $key } }
      _or: { _metadata: { version: { eq: $version } } }
    }
  ) {
    items {
      composition {
        nodes {
          nodeType
          key
          displaySettings {
            value
            key
          }
          ... on CompositionComponentNode {
            component {
              ...ItemsInContentArea
            }
          }
          ... on CompositionStructureNode {
            key
            rows: nodes {
              ... on CompositionStructureNode {
                key
                columns: nodes {
                  ... on CompositionStructureNode {
                    key
                    elements: nodes {
                      key
                      displaySettings {
                        value
                        key
                      }
                      ... on CompositionComponentNode {
                        component {
                          ...ItemsInContentArea
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
      _metadata {
        key
        version
      }
    }
  }
}

Summary

The key components of Visual Builder (Experience, Section, Element, and Blueprints) work together to provide a flexible and intuitive content creation experience. With proper implementation of custom types, wrapper components, and GraphQL queries, developers can unlock the full potential of Visual Builder for their Optimizely CMS projects.

Have questions? I'm here to help!

Contact Me