HomeDev guideAPI Reference
Dev guideUser GuideGitHubNuGetDev CommunitySubmit a ticketLog In
GitHubNuGetDev CommunitySubmit a ticket

On-page editing and Optimizely Graph

How to implement on-page editing integration in with a SPA client site using Optimizely Graph (for CMS 12 only).

Prerequisites

Example code

This article's example site is Music Festival and hosted on GitHub. Follow the Readme file to run the sample site and check the implementation.

How OPE works with decoupled sites using Optimizely Graph

There are a few components working together to make the editor preview feature possible.

  1. The Optimizely Content Management System (CMS) management site – The source of data, also provides the editor UI.
  2. The Optimizely Graph .NET client – Installed as a module in the CMS management site.
  3. The decoupled frontend site – A JavaScript Single Page Application (SPA) that retrieves content from Optimizely Graph and displays it to visitors in view mode and editors in edit mode.
  4. The preview token – Every time the CMS editor shows the frontend site in an iframe, it will generate a JWT token, which Optimizely calls a preview token. The token is added to the iframe URL as a query string parameter. The frontend site can then use this token to query for draft content from Optimizely Graph.

When CMS editors log in to the CMS management site to edit the frontend site, the frontend site is displayed in an iframe in the editor UI.

The CMS management site and the frontend site communicate with each other using a JavaScript script called communicationinjector.js which is hosted in the CMS management site. This mechanism is needed because the two sites are not on the same domain, so the CMS management site cannot check the iframe of the frontend site directly to facilitate OPE.

The frontend site will determine if it is being browsed in edit mode (by editors using the management site) or view mode (by visitors visiting the site). This is currently accomplished by checking the existence of a query string parameter in the URL of the frontend site.

When the site is in view mode, it sends a GraphQL query to Optimizely Graph to get published content to display to visitors using the HMAC single key as the authentication method, which lets you query only published versions of content.

When the site is in edit mode, it sends GraphQL queries to Optimizely Graph. It also sends along the preview token injected to the URL as a query string parameter by CMS. Optimizely Graph then retrieves requested content versions (published or not), filtered on the editor's access rights and permissions.

Every time you modify content in the CMS management site, the Optimizely Graph client synchronizes that content to Optimizely Graph, then the management site sends content-saved events to the frontend site through the communicationinjector.js script. The frontend site will then retrieve the updated data from Optimizely Graph with the preview token and re-render itself in the iframe.

The rendered DOM of the frontend site needs to contain some HTML attributes to tell the CMS management site which fields are editable and where they are in its rendered HTML.

To implement this editor preview feature, you must understand the roles and interactions between components and design your frontend site to support integration with the CMS management site editor.

Prerequistes

This example uses a client site (a decoupled site that is hosted independently from the CMS site) written in React that retrieves content from Optimizely Graph. The data is synchronized from the CMS management site to Optimizely Graph, and the client site creates queries to Optimizely Graph to retrieve the data to display.

📘

Note

The client site can be developed using Vue, Angular, or any other SPA framework.

For your React site, follow the Create a React site using data from Optimizely Graph document for a guide on creating a React site. The guide also includes configuring the backend site (Optimizely CMS).

Setup backend site (Optimizely CMS)

  1. Update app settings. The OPE feature needs a full set of Turnstile keys that include SingleKey, AppKey, and Secret. You must also set "AllowSyncDraftContent": true and "EnablePreviewTokens": true. All the configs should be like the following:
    {
      "Optimizely": {
        "ContentGraph": {
          "GatewayAddress": "https://cg.optimizely.com",
          "AppKey": "INPUT_APP_KEY_HERE",
          "Secret": "INPUT_SECRET_KEY_HERE",
          "SingleKey": "INPUT_SINGLE_KEY_HERE",
          "AllowSyncDraftContent": true,
          "EnablePreviewTokens": true,
          ...
        } 
      },
      "FRONT_END_URI": "http://localhost:3000"
    }
    
    • "AllowSyncDraftContent": true – Allows the .NET Optimizely Graph sync package to sync all the versions of all contents to Optimizely Graph. Otherwise only publicly available content (Published and can be read by Everyone) is synced to Optimizely Graph.
    • "EnablePreviewTokens": true – Enables preview tokens. CMS will generate preview tokens and inject them into the URL of the frontend site on every page load. The frontend site can then use the preview token to get draft content from Optimizely Graph.
  2. Configure the CMS site. Add the decoupled frontend host into the website in Admin > Config > Manage Websites. It should be like the following:
    Name: MusicFestival.Backend  
        URL: Backend_HOST  
        Start page: Root > Start  
        Host names  
            Backend_HOST - Edit  
            Client_HOST - Primary
    
  3. Example of a local development environment
    • The backend is hosted at localhost:8082.
    • The frontend (React client) is hosted at localhost:3000.

Because the frontend site is displayed in the CMS management site user interface in an iframe and they are in different hosts, you need to configure the Cross-Origin Resource Sharing policy to allow iframing from the frontend site in the UI of the CMS site (_frontendUri is an environment variable for the frontend site URL that is set in appsetting.json).

app.UseCors(b => b
   .WithOrigins(new[] { $"{_frontendUri}" })

Setup decoupled site

After developing a client site written in React, make the site work with On-Page Editing using Optimizely Graph.

Because of step 2 of setting the backend that sets Client_HOST as the primary host, the client site is displayed on the Edit view of CMS:

  1. Import the communicationinjector.js JS library to enable communication between the frontend and the management site, the script is hosted at https://<Backend_HOST>/episerver/cms/latest/clientresources/communicationinjector.js. The script lets the frontend site listen to changes from CMS when content is updated by editors by subscribing to the contentSaved event. Try to import it when the site loads. For example, if your back end site is on localhost:8082, you can load the JavaScript script from this URL: http://localhost:8082/episerver/cms/latest/clientresources/communicationinjector.js

  2. Check edit view. You need to know if the site is being shown on the edit view, then you can make properties editable. When loading on edit view, the URL of the client site includes the request parameter epieditmode.

    • epieditmode = true – The site is being edited.
    • epieditmode = false – The site is being previewed on the CMS UI.

If the epieditmode parameter is available in the site's URL, the site is being displayed in the iframe in CMS UI, and it needs to retrieve data from Optimizely Graph using the preview token injected into the URL by CMS. The preview token is set to the query string parameter preview_token. The frontend site can get the preview token from the URL and include it in the Authorization request header in the format Bearer <preview token> when querying Optimizely Graph from https://cg.optimizely.com/content/v2 Optimizely Graph returns results that are filtered so that only content the user has access to is returned.

If the epieditmode parameter is not available in the URL, the site is being displayed publicly, and it needs to retrieve published contents directly from Optimizely Graph using your Turnstile single key: https://cg.optimizely.com/content/v2?auth=<Your single key>.

Query content using Optimizely Graph

Prerequisites

First, follow and create some GraphQL queries (*.graphql files) by using Add GraphQL query with fragments.

Query steps

  1. You can then use the contentId and workId in your GraphQL query to get the exact version of the content to display. The preview token contains the contentId and workId of the content being viewed. You can get this information by base64 decoding the token payload and extracting properties c_id and c_ver.

    const base64String = token.split('.')[1]  
      payload = JSON.parse(Buffer.from(base64String, 'base64').toString());  
      const contentId = payload.c_id  
      const contentWorkId = payload.c_ver
    
  2. When the user selects a content version, retrieve content in the editor preview mode. The management site displays the frontend site in an iframe in this mode. It will pass some information in the URL of the iframe. An example of such URL is: /EPiServer/CMS/Content/en/artists/ash-ravine,,33_127/. This URL is set to the iframe when you select a version of content in the Versions gadget.

    You can use this URL to query for the content. The information that can be extracted from the URL path are:

    • The locale and language of the contenten
    • The relative path of the content/en/artists/ash-ravine
    • The ID of the content – 33 (Not needed. Use the value from the preview token instead).
    • The WorkID of the content version – 127 (Not needed. Use the value from the preview token instead).
  3. You can refer to the sample code to see how to extract the values from the URL using string manipulation. To retrieve this content from Optimizely Graph, you can use a query similar to the following example query. Pass the values you extracted from the URL and the preview token to the corresponding variables. In this case, only the Id and WorkID values are needed to get the version the user is viewing or editing. If a value is null, Optimizely Graph will ignore that condition.

    Content(
            locale: [$locales]
            where: {
                ContentLink: {WorkId: {eq: $workId}, Id: {eq: $contentId}}
                RelativePath: {
                    eq: $relativePath
                }
                Language: {
                    Name: {
                        eq: $language
                    }
                }
            })
            {
              //retrieve properties or use fragments here
            }
    
  4. Retrieve the primary draft content when content is selected in the navigation panel. When a page is selected from the CMS UI navigation panel, the CMS site will select a version of the content to edit.

    In this case, the artist "Almost Up Today" has two versions in the English language, one primary draft version and one published version.

    In this example, the generated URL for the iframe is EPiServer/CMS/Content/en/artists/almost-up-today,,35/.

The following information can be extracted from the URL:

  • The content's language and localeen
  • The relative path of the content/en/artists/almost-up-today
  • The content ID – 35

Notice that the WorkID (ID of the specific version) is not provided. However, you can get the contentId and workId from the preview token and use that to get the exact common draft of the content.

Select the first version or use limit: 1.

The query is like the following example. It will use the values of content ID, relative path, locale or language, and filter IsCommonDraft = true.

Content(
        locale: [$locales]
        where: {
            ContentLink: {Id: {eq: $contentId}}
            RelativePath: {
                eq: $relativePath
            }
            Language: {
                Name: {
                    eq: $language
                }
            },
            IsCommonDraft: {
                eq: $isCommonDraft
            }
        },
        orderBy: {Saved: DESC},
        // need to sort by Saved time in descending order
        // otherwise you may get the Published version instead of the primary draft version.
        limit: 1
    ) {
      //use fragments or select fields to retrieve here
    }

Retrieve published content when the site is viewed by visitors outside the CMS management site

When the epieditmode parameter is not in the URL, the site is being viewed by visitors and not being displayed inside the CMS management site.

In this case, query for published contents directly to Optimizely Graph search endpoint. The full URL may look something like this: http://localhost:3000/en/artists.

You can detect the following information from the URL:

  • The locale and languageen
  • The relative path of the content/en/artists

You can make a GraphQL query to Optimizely Graph search endpoint and retrieve the published content by:

  • Filtering by Relative path.
  • Filtering by locales or language.

The GraphQL query may look something like this:

Content(
        locale: [$locales]
        where: {
            RelativePath: {
                eq: $relativePath
            }
            Language: {
                Name: {
                    eq: $language
                }
            }
        }) {
          //use fragments or select your fields here
        }

Use a single GraphQL query

To summarize:

  • If the frontend site URL contains the query string parameter epieditmode – The site is being viewed inside an iframe. Make GraphQL queries to Optimizely Graph using the injected preview token to retrieve unpublished versions of contents to preview in the Editor.
    • Get both the content ID and WorkID from the payload of the preview token.
  • If the frontend site URL does not contain the epieditmode parameter – The site is being viewed by a visitor. You need to make GraphQL requests to the Optimizely Graph search endpoint using the HMAC single key to get the published versions of content.

Some filter parameters are used in these scenarios, and some are not. Because Optimizely Graph is designed to ignore some filter conditions if the value is not provided (variable set to null), you can use a single GraphQL query to cover all scenarios instead of using separate queries for each situation.

An example single GraphQL query looks something like this:

query Start(
    $relativePath: String
    $locales: Locales
    $language: String
    $stageName: String
    $artistGenre: String
    $contentId: Int
    $workId: Int
    $statusEqual: String
    $isCommonDraft: Boolean
) {
    Content(
        locale: [$locales]
        where: {
            ContentLink: {WorkId: {eq: $workId}, Id: {eq: $contentId}}
            RelativePath: {
                eq: $relativePath
            }
            Language: {
                Name: {
                    eq: $language
                }
            },
            Status: {
                eq: $statusEqual
            },
            IsCommonDraft: {
                eq: $isCommonDraft
            }
        },
        orderBy: {Saved: DESC},
        limit: 1
    ) {
        items {
            Name
            ParentLink {
                Url
            }
            Url
            __typename
            RelativePath
            ....
        }
    }
}

Subscribe to CMS contentSaved to be notified when content is changed.

After loading communicationinjector.js, the CMS management and frontend sites loaded in the iframe can communicate with each other. Whenever the editor modifies content, it will notify the frontend site and trigger a contentSaved event.

Subscribing to this event notifies the site of changes in content from CMS. When the changes are successful, the event is raised. You should update the property on the UI to display the changes.

window.addEventListener("load", function () {
  (window as any).epi?.subscribe('contentSaved', function(eventData: any){
		// TODO: Re-update the changed property on the UI here
	});
});

The eventData looks like this. In this example, the user modified the title property of the current content.

{
    "contentLink": "5_122",
    "properties": [
        {
            "name": "title",
            "value": "music festival 2018",
            "successful": true,
            "validationErrors": null
        }
    ],
    "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///5_122",
    "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/,,5_122/?epieditmode=true"
}

If the value in properties is

  • A simple type like string, number, boolean, and so on – You need to replace it on the UI that matches the changed property, update the React state, and React will automatically update the DOM.
  • A complex type, such as an object likeMainContentArea– Refresh the page to update the changes or invalidate the query result so the React site will run the GraphQL query again to get fresh data. You must do this because the data structure in the eventData does not match the data format in Optimizely Graph, so extracting such data and updating the UI is difficult. The eventData looks like this:
    {
        "contentLink": "5_122",
        "properties": [
            {
                "name": "mainContentArea",
                "value": [
                    {
                        "name": "Music Festival",
                        "contentGroup": "",
                        "contentLink": "41",
                        "typeIdentifier": "episerver.core.blockdata",
                        "contentTypeName": "",
                        "roleIdentities": [],
                        "attributes": {
                            "data-contentgroup": ""
                        },
                        "contentGuid": "6d559290-a37f-4741-a0de-944efba547ee"
                    },
                    {
                        "name": "Lineup",
                        "contentGroup": "",
                        "contentLink": "42",
                        "typeIdentifier": "episerver.core.blockdata",
                        "contentTypeName": "",
                        "roleIdentities": [],
                        "attributes": {
                            "data-contentgroup": ""
                        },
                        "contentGuid": "c2136e2a-0520-4c53-9358-7e32997634de"
                    }
                ],
                "successful": true,
                "validationErrors": null
            }
        ],
        "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///5_122",
        "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/,,5_122/?epieditmode=true"
    }
    

📘

Note

In some special cases, the name of a property in properties has one of the prefixes ["icontent_", "ichangetrackable_", "iversionable_", "iroutable_"]. Remove the prefix to get the correct property name. The eventData would be like this:

{
    "contentLink": "6_121",
    "properties": [
        {
            "name": "icontent_name",
            "value": "Artists 123",
            "successful": true,
            "validationErrors": null
        }
    ],
    "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///6_121",
    "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/artists,,6_121/?epieditmode=true"
}

Make a property editable

To make a property editable in edit mode, add the attribute data-epi-edit="INPUT_PROPERTY_NAME_HERE" into the tag wrapping the property value. The CMS management site would then know that the DOM element contains the value for that property and produces UI controls to make the property editable in edit mode like the following

📘

Note

At this time, you can click on the property and a dialog will show up for you to modify the content. Inline editing is not supported.

For example:

  • The simple properties:

    <div className='top'>
      <h1 data-epi-edit="ArtistName">{content?.ArtistName}</h1>
    </div>
    <div className="artist-information">
      <p data-epi-edit="StageName">{content?.StageName}</p>
    <p><span data-epi-edit="PerformanceStartTime">{content?.PerformanceStartTime}</span> - <span data-epi-edit="PerformanceEndTime">{content?.PerformanceEndTime}</span></p>
    </div>
    <div className="artist-description" data-epi-edit="ArtistDescription">
      {parse(content?.ArtistDescription ?? '')}
    </div>
    
  • The complex property like MainContentArea:

    <main className='Page-container'>
        <div>
            <section data-epi-edit="MainContentArea" className='Grid Grid--alignMiddle Grid--gutterA ContentArea'>
                {content?.MainContentArea?.map((mainContentAreaItem: any, mainContentAreaItemIdx: number) => {
                    return (
                        (() => {
                            const contentItem = mainContentAreaItem?.ContentLink?.Expanded
                            ......
                            return (
                                <div key={mainContentAreaItemIdx}>
                                    {GetBlockComponent(contentItem)}
                                </div>
                            )
                        })()
                    )
                })}
            </section>
        </div>
    </main>
    

Limitations and workarounds

📘

Note

The contentSaved event is triggered multiple times from the backend.

Whenever the CMS editor changes a field of the current page from the management site, the backend site sends a contentSaved event to the frontend site using the communicationinjector.js script. However, the same event is triggered multiple times for each change.

To work around this limitation, in the event handler of the contentSaved event, you must store the event message in a variable. Doing so lets you check if the same message is sent multiple times later, ensuring that the same event is only handled once.

let previousSavedMessage: any = null;

const { mutate } = useMutation((obj: any) => obj, {
  onSuccess: (message: ContentSavedMessage) => {
    if (previousSavedMessage !== message) {
      previousSavedMessage = message;
      updateStartQueryCache(queryClient, data, variables, message)
    }
  }
});

In the preceding example, you receive a ContentSavedMessage from the contentSaved event. Because this same event might be raised multiple times, compare it to a variable called previousSavedMessage that is set to null initially. If the event is not the same as previousSavedMessage, it means that the event is new, and you must handle it and update the changed property.

After updating the changed content property, set the value of message to previousSavedMessage, so that if you encounter the same message again, you will skip it, ensuring you handle contentSaved events once.