Edit on-page using Optimizely Graph
How to implement on-page editing integration in with a SPA client site using Optimizely Graph (for CMS 12 only).
Prerequisites
- Editing user interface
- On-page editing with client-side rendering.
- Create a React site using data from Optimizely Graph.
- Optimizely Content Delivery - Decoupled.
Example code
This article's example site is Music Festival and is 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.
- The Optimizely Content Management System (CMS) management site – The source of data, also provides the editor UI.
- The Optimizely Graph .NET client – Installed as a module in the CMS management site.
- 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.
- The preview token – Every time the CMS editor shows the frontend site in an iframe, it generates 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 frontend site's URL.
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 frontend site's rendered DOM 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.
Example information
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 single page application (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)
- Update app settings. The OPE feature needs a full set of Turnstile keys that include
SingleKey
,AppKey
, andSecret
. You must also set"AllowSyncDraftContent": 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 ... } }, "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.
- Enable preview tokens in your startup code for the application. Since 3.8.0, preview tokens are enabled in CMS UI options. When preview tokens are used, 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.
services.Configure<UIOptions>(uiOptions => { uiOptions.UsePreviewTokens = true; });
- 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
- Example of a local development environment
- The backend is hosted at
localhost:8082
. - The frontend (React client) is hosted at
localhost:3000
.
- The backend is hosted at
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:
-
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 thecontentSaved
event. Try to import it when the site loads. For example, if your back end site is onlocalhost:8082
, you can load the JavaScript script from this URL:http://localhost:8082/episerver/cms/latest/clientresources/communicationinjector.js
-
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.
Note
The preview token is only meant to be used to query data from Optimizely Graph. Do not depend on the contents of the token itself as they are subject to change in the future.
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
Prerequisite
First, follow and create some GraphQL queries (*.graphql files) by using Add GraphQL query with fragments.
Query steps
-
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 content –
en
- The relative path of the content –
/en/artists/ash-ravine
- The ID of the content – 33
- The
WorkID
of the content version – 127
- The locale and language of the content –
-
See the sample code to learn 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. When the
WorkID
is available in draft versions, pass the values you extracted from the URL to the corresponding variables. In this case, only theId
andWorkID
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 }
Note
The preview token has a short lifetime for security reason, so after some time you cannot use the same token to query content from Optimizely Graph to refresh the frontend site.
However, whenever the content is updated, a
contentSaved
event is triggered by the backend and the event message includes a regenerated preview URL that contains a new preview token. See the Subscribe to CMScontentSaved
to be notificied when content is changed and Get new preview token fromcontentSaved
event message sections for information on how to fetch the new token.
-
When user is viewing the published version of the content, there will be no
WorkID
, in this case you can get the published content by filtering on fieldStatus
, get contents which haveStatus
=Published
.Content( locale: [$locales] where: { ContentLink: { Id: {eq: $contentId}}, Status: { eq: "Published" }, RelativePath: { eq: $relativePath } Language: { Name: { eq: $language } } }) { //retrieve properties or use fragments here }
-
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 English versions: one primary draft and one published.
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 locale –
en
- 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. You can query for main draft content using the isCommonDraft
property in this case to get the exact main common draft.
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 language –
en
- 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 the Content ID and Work ID from the URL.
- 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
contentSaved
to be notified when content is changedAfter 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 like
MainContentArea
– 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 theeventData
does not match the data format in Optimizely Graph, so extracting such data and updating the UI is difficult. TheeventData
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 inproperties
has one of the prefixes["icontent_", "ichangetrackable_", "iversionable_", "iroutable_"]
. Remove the prefix to get the correct property name. TheeventData
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" }
Get new Preview Token from ContentSaved
event message
ContentSaved
event messageThe preview token has a short lifetime for security reason, so the frontend site cannot use the same token to query for contents after it has expired.
However, you can get a fresh new preview token from the event message of ContentSaved
event. The event message includes a new previewUrl
field that includes the new preview token.
onSuccess: (message: ContentSavedMessage) => {
//...
const newPreviewUrl = message.previewUrl
const urlParams = new URLSearchParams(newPreviewUrl);
const newPreviewToken = urlParams.get('preview_token');
//...
}
This new preview token can be used to query from Optimizely Graph to refresh the frontend page. However please note that it will expire after some time again.
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.
Updated 3 months ago