<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blog posts by Binh-Nguyen</title><link href="http://world.optimizely.com" /><updated>2025-11-15T11:26:16.0000000Z</updated><id>https://world.optimizely.com/blogs/binh-nguyen/</id> <generator uri="http://world.optimizely.com" version="2.0">Optimizely World</generator> <entry><title>Full implementation - Fallback languages with Optimizely Graph</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2025/11/full-implementation---fallback-languages-with-optimizely-graph/" /><id>&lt;p&gt;Nowadays, many people choose a headless approach when developing Optimizely CMS/Commerce projects using Opti Graph.&lt;br /&gt;One challenge we may face is implementing language fallback, as it is not supported by default. There are a few tips available, and today I want to share my complete implementation. I hope it will help others who need to achieve the same thing.&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;&lt;strong&gt;1. In back-end code, add more fallback language property for Graph model:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class FallbackLanguageContentsApiModelProperty(
    ILanguageBranchRepository languageBranchRepository,
    ICustomUrlService customUrlService,
    IContentLanguageSettingsHandler contentLanguageSettingsHandler) : IContentApiModelProperty
{
    public object GetValue(ContentApiModel contentApiModel)
    {
        if (contentApiModel.ContentLink != null)
        {
            var enabledLanguages = languageBranchRepository.ListEnabled();
            var pagesThatFallBackContentToCurrentPage = new List&amp;lt;FallbackLanguageContent&amp;gt;();
            var cRef = contentApiModel.ContentLink.ToContentReference();

            foreach (var enabledLanguage in enabledLanguages)
            {
                var fallbackLanguages = contentLanguageSettingsHandler.GetFallbackLanguages(cRef, enabledLanguage.Culture.Name);
                if (fallbackLanguages != null &amp;amp;&amp;amp; fallbackLanguages.Any() &amp;amp;&amp;amp; fallbackLanguages.Contains(contentApiModel.Language.Name))
                {
                    var lang = enabledLanguage.Culture.Name;

                    pagesThatFallBackContentToCurrentPage.Add(new FallbackLanguageContent()
                    {
                        LanguageName = lang,
                        RelativePath = !ContentReference.IsNullOrEmpty(cRef) ? customUrlService.GetRelativeUrl(cRef, lang) : string.Empty
                    });
                }
            }

            return pagesThatFallBackContentToCurrentPage;
        }
        else
        {
            return new List&amp;lt;FallbackLanguageContent&amp;gt;();
        }
    }

    public string Name =&amp;gt; &quot;FallbackLanguageContents&quot;;
}

internal class FallbackLanguageContent
{
    public required string LanguageName { get; set; }
    public required string RelativePath { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. In the front-end code, we need to query content using a relative path and locale, including the fallback logic. Here is the query:&lt;/strong&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-html&quot;&gt;&lt;code&gt;query getContentByPathWithinFallback($path: [String!]!, $locale: String, $siteId: String) {
  content: Content(
    where: {
      SiteId: { eq: $siteId }
      _or: [
        { _and: [{ Language: { Name: { eq: $locale, boost: 2 } } }, { RelativePath: { in: $path } }] }
        { _and: [{ FallbackLanguageContents: { LanguageName: { eq: $locale, boost: 1 } } }, { FallbackLanguageContents: { RelativePath: { in: $path } } }] }
      ]
    }
    locale: ALL
  ) {
    items: item {
      ...IContentData
      ...PageData
    }
  }
}
fragment PageData on IContent {
  ...IContentData
}
fragment IContentData on IContent {
  contentType: ContentType
  _metadata: ContentLink {
    id: Id
    version: WorkId
    key: GuidValue
  }
  locale: Language {
    name: Name
  }
  path: RelativePath
  _type: __typename
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;By using boost values in the query, we can indicate which conditions should have higher priority. As a result, the fallback content is returned only when no content exists for the exact locale.&lt;/p&gt;
&lt;p&gt;If you are using Optimizely SaaS Starter for your headless solution, you can call your custom content query in &lt;code&gt;src/app/[[...path]]/page.tsx&lt;/code&gt; by replacing the following section:&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;&lt;img src=&quot;/link/86bcc904fdcd4054aa19b44e4160c3a0.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;That&#39;s all. Hope this makes your multilingual setup a bit easier. Happy coding!&lt;/p&gt;</id><updated>2025-11-15T11:26:16.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>How to have a link plugin with extra link id attribute in TinyMce</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2024/7/how-to-have-a-link-plugin-with-extra-link-id-attribute-in-tinymce/" /><id>&lt;p&gt;&lt;strong&gt;Introduce&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Optimizely CMS Editing is using TinyMce for editing rich-text content. We need to use this control a lot in CMS site for kind of WYSWYG content.&lt;/p&gt;
&lt;p&gt;I feel quite happy to use it and one of reason that I like to use tinymce is easy to customization. We can add available plug-ins that we want into toolbar only via configuration in server code. We also can build new plug-ins via javascript and register them in server code to use.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Today, I want to share the way to add a new plug-in as same epi link plug-in with adding more extra id attribute.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1: &lt;/strong&gt;Create new Link Model in server code with Link Id attribute. Currenty, Link Editor control is using kind of this model type to render to corresponding user interface.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Cms.Shell.UI.ObjectEditing.InternalMetadata;
using System.ComponentModel;

namespace Sample_Sites.Models
{
    public class CustomLinkModel : LinkModel
    {
        [DisplayName(&quot;Link Id&quot;)]
        public string LinkId { get; set; }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&amp;nbsp;&lt;/strong&gt;Create TinyMce plug-in via javascript named customLink.js under folder &quot;wwwroot/ClientResources/Scripts/tinymce-plugins&quot;. In this example, I use Dojo module to do it because I want to re-use code of epi link plug-in. But&amp;nbsp; you can completely use vanilla javascript to create plug-in only with using &amp;nbsp;tinymce.PluginManager.add to add new button in TinyMce toolbar&lt;/p&gt;
&lt;p&gt;Here is the place that I change to indicate using our custom link model for Link Editor instead of default one&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt; var linkEditor = new LinkEditor({
     baseClass: &quot;epi-link-item&quot;,
     modelType: &quot;Sample_Sites.Models.CustomLinkModel&quot;,
     hiddenFields: [&quot;text&quot;] // hide text field from UI
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the place that I change to read Id value from a element&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;if (href.length) {
    linkObject.href = href;
    linkObject.targetName = dom.getAttrib(selectedLink, &quot;target&quot;);
    linkObject.title = dom.getAttrib(selectedLink, &quot;title&quot;);
    linkObject.linkId = dom.getAttrib(selectedLink, &quot;id&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the place that I change to set Id attribute for a element&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;  var callbackMethod = function (value) {
      if (value &amp;amp;&amp;amp; value.href) {
          var linkAttributes = {
              href: value.href,
              title: value.title,
              target: value.target ? value.target : null,
              id: value.linkId ? value.linkId : null
          };&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is full javascript file for custom link plug-in:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;define(&quot;alloy/tinymce-plugins/customLink&quot;, [
    &quot;dojo/_base/lang&quot;,
    &quot;dojo/on&quot;,
    &quot;epi/shell/widget/dialog/Dialog&quot;,
    &quot;epi-cms/ApplicationSettings&quot;,
    &quot;epi-cms/widget/LinkEditor&quot;,
    &quot;epi-addon-tinymce/tinymce-loader&quot;,
    &quot;epi-addon-tinymce/plugins/epi-link/linkViewModel&quot;,
    &quot;epi/i18n!epi/cms/nls/episerver.cms.widget.editlink&quot;,
    &quot;epi/i18n!epi/cms/nls/episerver.cms.tinymce.plugins.epilink&quot;
], function (lang, on, Dialog, ApplicationSettings, LinkEditor, tinymce, linkViewModel, resource, pluginResource) {

    tinymce.PluginManager.add(&quot;custom-link&quot;, function (editor) {
        function mceEPiLink() {
            var href = &quot;&quot;,
                s = editor.selection,
                dom = editor.dom,
                linkObject = {};

            // CMS-20837: when users use the search function of Chrome (ctrl+f), the highlighted text will be un-highlighted
            // clone the selection here so it will not be affected by Chrome.
            var originalSelection = editor.selection.getRng().cloneRange();

            // When link is at the beginning of a paragraph, then IE (and FF?) returns the paragraph from getNode,
            // the getStart() and getEnd() however returns the anchor.
            var node = s.getStart() === s.getEnd() ? s.getStart() : s.getNode(),
                selectedLink = linkViewModel.getAnchorElement(editor, node);

            // No selection and not in link
            if (s.isCollapsed() &amp;amp;&amp;amp; !selectedLink) {
                return;
            }

            if (selectedLink) {
                href = dom.getAttrib(selectedLink, &quot;href&quot;);
            }

            if (href.length) {
                linkObject.href = href;
                linkObject.targetName = dom.getAttrib(selectedLink, &quot;target&quot;);
                linkObject.title = dom.getAttrib(selectedLink, &quot;title&quot;);
                linkObject.linkId = dom.getAttrib(selectedLink, &quot;id&quot;);
            }

            var callbackMethod = function (value) {
                if (value &amp;amp;&amp;amp; value.href) {
                    var linkAttributes = {
                        href: value.href,
                        title: value.title,
                        target: value.target ? value.target : null,
                        id: value.linkId ? value.linkId : null
                    };

                    // CMS-20837: and set the selection again if selection lost its value.
                    if (!editor.selection.getContent({ format: &quot;html&quot; })) {
                        editor.selection.setRng(originalSelection);
                    }

                    if (selectedLink) {
                        dom.setAttribs(selectedLink, linkAttributes);
                    } else {
                        if (linkViewModel._isImageFigure(node)) {
                            linkViewModel.linkImageFigure(editor, node, linkAttributes);
                        } else {
                            // When opening the link properties dialog in OPE mode an inline iframe is used rather than a popup window.
                            // When using IE clicking in this iframe causes the selection to collapse in the TinyMCE iframe which
                            // breaks the link creation immediately below. The workaround is to store the selection range before
                            // opening, and restoring it before creating the link.
                            s.setRng(s.getRng());
                            // To make sure we dont get nested links and have the same behavior as the default tiny
                            // link dialog we unlink any links in the selection before we create the new link.
                            editor.getDoc().execCommand(&quot;unlink&quot;, false, null);
                            editor.execCommand(&quot;mceInsertLink&quot;, false, &quot;#mce_temp_url#&quot;, { skip_undo: 1 });

                            var elementArray = tinymce.grep(dom.select(&quot;a&quot;), function (n) {
                                return dom.getAttrib(n, &quot;href&quot;) === &quot;#mce_temp_url#&quot;;
                            });
                            for (var i = 0; i &amp;lt; elementArray.length; i++) {
                                dom.setAttribs(elementArray[i], linkAttributes);
                            }

                            //move selection into the link content to be able to recognize it when looking at selection
                            if (elementArray.length &amp;gt; 0) {
                                var range = editor.dom.createRng();
                                range.selectNodeContents(elementArray[0]);
                                editor.selection.setRng(range);
                            }
                        }
                    }
                } else if (selectedLink) {
                    // pressed delete?
                    dom.setOuterHTML(selectedLink, selectedLink.innerHTML);
                    editor.undoManager.add();
                }
            };

            linkObject.target = linkViewModel.findFrameId(ApplicationSettings.frames, linkObject.targetName);

            var linkEditor = new LinkEditor({
                baseClass: &quot;epi-link-item&quot;,
                //TODO: hardcoded for now
                modelType: &quot;Sample_Sites.Models.CustomLinkModel&quot;,
                hiddenFields: [&quot;text&quot;] // hide text field from UI
            });

            //Find all Anchors in the document and add them to the Anchor list
            var allLinks = editor.getDoc().querySelectorAll(&quot;a[id],a[name]&quot;);

            // If the user is using IE 11 or lower we need to convert the
            // nodeList to a regular array
            // HACK: IE11
            if (tinymce.Env.ie &amp;amp;&amp;amp; tinymce.Env.ie &amp;lt; 12) {
                allLinks = Array.prototype.slice.call(allLinks);
            }

            var anchors = linkViewModel.findNamedAnchors(allLinks);

            linkEditor.on(&quot;fieldCreated&quot;, function (fieldname, widget) {
                if (fieldname === &quot;href&quot;) {
                    // in this case, widget is HyperLinkSelector
                    var hyperLinkSelector = widget;
                    var anchor = linkViewModel.getFirstAnchorWidget(hyperLinkSelector.get(&quot;wrappers&quot;));

                    if (anchor &amp;amp;&amp;amp; anchor.inputWidget) {
                        anchor.inputWidget.set(&quot;selections&quot;, anchors);
                    } else {
                        widget.on(&quot;selectorsCreated&quot;, function (hyperLinkSelector) { // when all selector have been created
                            var anchorWidget = linkViewModel.getFirstAnchorWidget(hyperLinkSelector.get(&quot;wrappers&quot;));

                            if (anchorWidget &amp;amp;&amp;amp; anchorWidget.inputWidget) {
                                anchorWidget.inputWidget.set(&quot;selections&quot;, anchors);
                                anchorWidget.domNode.style.display = &quot;block&quot;;
                            }
                        });
                    }

                    if (anchor) {
                        anchor.domNode.style.display = &quot;block&quot;;
                    }
                }
            });

            var dialogTitle = lang.replace(selectedLink ? resource.title.template.edit : resource.title.template.create, resource.title.action);

            var dialog = new Dialog({
                title: dialogTitle,
                dialogClass: &quot;epi-dialog-portrait&quot;,
                content: linkEditor,
                defaultActionsVisible: false
            });
            dialog.startup();

            //Set the value when the provider/consumer has been initialized
            linkEditor.set(&quot;value&quot;, linkObject);

            dialog.show();
            editor.fire(&quot;OpenWindow&quot;, {
                win: null
            });

            dialog.on(&quot;execute&quot;, function () {

                var value = linkEditor.get(&quot;value&quot;);
                var linkObject = lang.clone(value);

                if (linkObject &amp;amp;&amp;amp; linkObject.target) {
                    // get target frame name, instead of integer value
                    linkObject.target = linkViewModel.findFrameName(ApplicationSettings.frames, linkObject.target);
                }

                //Destroy the editor when the dialog closes
                linkEditor.destroy();
                linkEditor = null;

                callbackMethod(linkObject);
            });

            dialog.on(&quot;hide&quot;, function () {
                editor.fire(&quot;CloseWindow&quot;, {
                    win: null
                });
            });
        }

        // Register buttons
        editor.ui.registry.addToggleButton(&quot;custom-link&quot;, {
            tooltip: pluginResource.title,
            onAction: mceEPiLink,
            icon: &quot;link&quot;,
            onSetup: function (buttonApi) {
                function selectionChange(e) {
                    var anchorElement = linkViewModel.getAnchorElement(editor, e.element);
                    var invalidSelection = !linkViewModel.hasValidSelection(editor, e.element);
                    buttonApi.setEnabled(!(invalidSelection &amp;amp;&amp;amp; !anchorElement));
                    buttonApi.setActive(!editor.readonly &amp;amp;&amp;amp; !!anchorElement);
                }

                editor.on(&quot;SelectionChange&quot;, selectionChange);

                return function () {
                    editor.off(&quot;SelectionChange&quot;, selectionChange);
                };
            }
        });

        editor.shortcuts.add(&quot;ctrl+k&quot;, pluginResource.title, mceEPiLink);

        return {
            getMetadata: function () {
                return {
                    name: &quot;Link (epi)&quot;,
                    url: &quot;https://www.optimizely.com&quot;
                };
            }
        };
    });
});

dojo.require(&quot;alloy/tinymce-plugins/customLink&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Last step:&amp;nbsp;&lt;/strong&gt;Add TinyMce configuration with adding your custom plug-in in the toolbar&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.Configure&amp;lt;TinyMceConfiguration&amp;gt;(config =&amp;gt;
{
	config.InheritSettingsFromAncestor = true;
	config.Default()
		 .AddExternalPlugin(&quot;custom-link&quot;, &quot;/ClientResources/Scripts/tinymce-plugins/customLink.js&quot;)
		 .Toolbar(&quot;styles | bold italic underline | custom-link anchor | image epi-image-editor epi-personalized-content | bullist numlist outdent indent | epi-dnd-processor | removeformat | fullscreen code&quot;)
		 .AddPlugin(&quot;code&quot;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Finally, check if the plugin is displayed in the TinyMCE editor in Edit Mode. Thankfully, it works! :)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/af7f8e03f2ea4f36a3dd26ca1962827b.aspx&quot; width=&quot;1491&quot; height=&quot;793&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can see this link &lt;a href=&quot;https://tedgustaf.com/blog/2022/adding-custom-tinymce-plugin-to-the-html-editor-in-optimizely-cms/&quot;&gt;https://tedgustaf.com/blog/2022/adding-custom-tinymce-plugin-to-the-html-editor-in-optimizely-cms/&lt;/a&gt; to know how to add a new TinyMce plug-in in general&lt;/p&gt;</id><updated>2024-07-13T09:10:24.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely Search and Navigation - Part 2 - Filter Tips</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2024/6/optimizely-search-and-navigation---part-2---filter-tips/" /><id>&lt;h3&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Continuing from &lt;a href=&quot;#link-to-part-1&quot;&gt;Part 1 &amp;ndash; Search Tips&lt;/a&gt;, today I will share the next part &amp;ndash; filter tips.&lt;/p&gt;
&lt;p&gt;The platform versions used for this article are Optimizely CMS 12.27.x, Optimizely Customized Commerce 14.21.x, and EpiServer.Find 16.1.x.&lt;/p&gt;
&lt;h3&gt;How to Add a New Custom Filter&lt;/h3&gt;
&lt;p&gt;In the built-in Optimizely Search &amp;amp; Navigation, the default filters are as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;And Filter&lt;/li&gt;
&lt;li&gt;Bool Filter&lt;/li&gt;
&lt;li&gt;Exists Filter&lt;/li&gt;
&lt;li&gt;Geo Distance Filter&lt;/li&gt;
&lt;li&gt;Geo Distance Range Filter&lt;/li&gt;
&lt;li&gt;Geo Polygon Filter&lt;/li&gt;
&lt;li&gt;Has Child Filter&lt;/li&gt;
&lt;li&gt;Ids Filter&lt;/li&gt;
&lt;li&gt;Kilometers Filter&lt;/li&gt;
&lt;li&gt;Nested Filter&lt;/li&gt;
&lt;li&gt;Not Filter&lt;/li&gt;
&lt;li&gt;Or Filter&lt;/li&gt;
&lt;li&gt;Prefix Filter&lt;/li&gt;
&lt;li&gt;Query Filter&lt;/li&gt;
&lt;li&gt;Range Filter&lt;/li&gt;
&lt;li&gt;Script Filter&lt;/li&gt;
&lt;li&gt;Term Filter&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These filters generate corresponding body text when sending requests to Elasticsearch through the Search &amp;amp; Navigation framework using JSON converters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if a filter exists in Elasticsearch but not in Search &amp;amp; Navigation, or an existing filter lacks required parameters? Can you create a new filter?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes, you can add new custom filters in Search &amp;amp; Navigation. Here&#39;s how to build a new Regular Expression filter.&lt;/p&gt;
&lt;h4&gt;Step 1: Create a New DTO Based on Filter Class&lt;/h4&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[JsonConverter(typeof(RegularExpressionFilterConverter))]
public class RegularExpressionFilter : Filter
{
    [JsonIgnore]
    public string Field { get; set; }

    [JsonProperty(&quot;value&quot;)]
    public string Value { get; set; }

    public RegularExpressionFilter(string field, string value)
    { 
        Field = field;
        Value = value;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Step 2: Create a New JSON Converter for the New Filter&lt;/h4&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;internal class RegularExpressionFilterConverter : CustomWriteConverterBase&amp;lt;RegularExpressionFilter&amp;gt;
{
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        if (value is not RegularExpressionFilter)
        {
            writer.WriteNull();
            return;
        }
        var regularExpressionFilter = (RegularExpressionFilter)value;
        writer.WriteStartObject();
        writer.WritePropertyName(&quot;regexp&quot;);
        writer.WriteStartObject();
        writer.WritePropertyName(regularExpressionFilter.Field);
        serializer.Serialize(writer, regularExpressionFilter.Value);
        writer.WriteEndObject();
        writer.WriteEndObject();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Step 3: Create a New Filter Method for Your Search&lt;/h4&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static DelegateFilterBuilder MatchRegularExpression(this string value, string input)
{ 
   return new DelegateFilterBuilder((string field) =&amp;gt; new RegularExpressionFilter(field, input));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Step 4: Apply the New Filter to Your Search&lt;/h4&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;if (!string.IsNullOrEmpty(filterOptions.Q)){
    query = query.Filter(x =&amp;gt; x.Name.MatchRegularExpression(filterOptions.Q));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The keyword for search in this example could be text within regular expression syntax. You can find syntax for ElasticSearch &lt;a href=&quot;https://www.elastic.co/guide/en/elasticsearch/reference/current/regexp-syntax.html&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;How to Apply AND/OR for a Group of Sub Filters&lt;/h3&gt;
&lt;p&gt;Consider the following example:&lt;/p&gt;
&lt;p&gt;You have a product content type with these properties:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Display(Name = &quot;On sale&quot;, GroupName = SystemTabNames.Content, Order = 50)]
public virtual bool OnSale { get; set; }

[Display(Name = &quot;New arrival&quot;, GroupName = SystemTabNames.Content, Order = 55)]
public virtual bool NewArrival { get; set; }

[Display(Name = &quot;Best seller&quot;, GroupName = SystemTabNames.Content, Order = 58)]
public virtual bool BestSeller { get; set; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To find products that are on sale and/or new arrivals, you can:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use AND/OR Operator in FilterExpression:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AND operator&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.Filter(x =&amp;gt; x.OnSale.Match(true) &amp;amp; x.NewArrival.Match(true));&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; &lt;/strong&gt;&lt;strong&gt;OR operator&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.Filter(x =&amp;gt; x.OnSale.Match(true) | x.NewArrival.Match(true));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Use AND/OR Filter:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AND filter&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var andFilter = new AndFilter();
andFilter.Filters.Add(new TermFilter(searchClient.GetFullFieldName(&quot;OnSale&quot;, typeof(bool)), true));
andFilter.Filters.Add(new TermFilter(searchClient.GetFullFieldName(&quot;NewArrival&quot;, typeof(bool)), true));
query = query.Filter(andFilter);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; &lt;/strong&gt;&lt;strong&gt;OR Filter &lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var orFilter = new OrFilter();
orFilter.Filters.Add(new TermFilter(searchClient.GetFullFieldName(&quot;OnSale&quot;, typeof(bool)), true));
orFilter.Filters.Add(new TermFilter(searchClient.GetFullFieldName(&quot;NewArrival&quot;, typeof(bool)), true));
query = query.Filter(orFilter);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that the field name understood by Elasticsearch is not the same as the field name in the Optimizely Content Type Model. Use the following method to get the indexed field name:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static string GetFullFieldName(this IClient searchClient, string fieldName, Type type)
{
    if (type != null)
        return fieldName + searchClient.Conventions.FieldNameConvention.GetFieldName(Expression.Variable(type, fieldName));
    return fieldName;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pros and Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Using AND/OR Operator:&lt;/strong&gt; Short and easy to use but not suitable for dynamically building filters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using AND/OR Filter:&lt;/strong&gt; Longer code but allows for flexible filter building based on field names and input values.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;How to Sort Search Results Based on a Set of Conditions&lt;/h3&gt;
&lt;p&gt;Search &amp;amp; Navigation allows sorting based on one or more fields:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.OrderBy(x =&amp;gt; x.Name).ThenByDescending(x =&amp;gt; x.StartPublish);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Example Requirement:&lt;/strong&gt; Display best seller products at the top, followed by new arrivals, and then on sale products.&lt;/p&gt;
&lt;p&gt;You can achieve this by using boost matching:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).NewArrival.Match(true), 2);
query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).OnSale.Match(true), 3);
query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).BestSeller.Match(true), 4);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issue:&lt;/strong&gt; If a product is both on sale and a new arrival, it appears at the top even if it is not a best seller. The score of this product (5) is higher than that of a best seller (4).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Ensure best sellers are always on top by setting Boost value as following rule: Next boost value = total of all previous boost values + 1:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).NewArrival.Match(true), 2);
query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).OnSale.Match(true), 3);
query = query.BoostMatching(x =&amp;gt; (x as GenericProduct).BestSeller.Match(true), 6);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;These tips are based on my experience with Search &amp;amp; Navigation. I hope they help you implement similar features.&lt;/p&gt;
&lt;p&gt;Enjoy coding!&lt;/p&gt;</id><updated>2024-07-01T08:44:17.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely Search and Navigation – Part 1 – Search Tips</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2024/5/optimizely-search-and-navigation--part-1--search-tips/" /><id>&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Search and Navigation is a cloud service provided by Optimizely to support building search functionality for both Optimizely CMS and Optimizely Commerce sites. This platform uses Elasticsearch behind the scenes to serve search, filter, facet, and indexing functionalities with the strong capabilities of Elasticsearch.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;I have experience applying Search &amp;amp; Navigation to both CMS and Commerce projects. So today, I will share some tips for searching with you.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;Which version of Optimizely CMS and Search &amp;amp; Navigation&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The platform versions that I am using for this article are Optimizely CMS 12.27.x and EpiServer.Find 16.1.x&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;Difference between search and filter&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Search Concept:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The search concept in Elasticsearch refers to the process of querying the index to retrieve documents that match certain criteria.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;When you perform a search, Elasticsearch calculates a relevance score for each document based on how well it matches the query.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Search queries can be performed using various query DSL (Domain Specific Language) constructs such as match queries, term queries, range queries, etc.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Search results are typically sorted by relevance, ut you can also specify sorting criteria based on specific fields.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Filter Concept:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Filters, on the other hand, are used to narrow down the set of documents returned by a query without affecting the relevance scores.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Filters are typically used for exact matches or ranges and are applied to the documents after the initial search.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Filters can significantly improve the performance of queries, especially for frequently used and static criteria.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Unlike search queries, filters are not scored, so they are faster but less flexible in terms of matching documents.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;In summary, while both searches and filters are used to retrieve documents from Elasticsearch, searches are used to find documents based on relevance scores calculated against the query, while filters are used to narrow down the set of documents without affecting relevance scoring.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;How to apply free-text search with contains operator&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;We can apply the contains operator for free-text search by using wildcard as follows:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.For(searchTerm, options =&amp;gt; {
    options.Query = $&quot;*{searchTerm}*&quot;;
    options.AllowLeadingWildcard = true;
    options.AnalyzeWildcard = true;
    options.RawQuery = searchTerm;
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;em&gt;AllowLeadingWildcard&lt;/em&gt;:&lt;/span&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt; If&amp;nbsp;true, the wildcard characters&amp;nbsp;*&amp;nbsp;and&amp;nbsp;?&amp;nbsp;are allowed as the first character of the query string. Defaults to&amp;nbsp;true&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;em&gt;AnalyzeWildcard&lt;/em&gt;:&lt;/span&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt; If true, the query attempts to analyze wildcard terms in the query string. Defaults to false.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The below image&lt;/span&gt; is the result when I find products via free-text search - contains operator - In Name field:&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;img src=&quot;/link/c9616cd10f8f4d138ee31c9017392333.aspx&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;How free-text search works if a search term contains multiple words&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Actually, when we do a free-text search with For method by default then your search term will be analyzed to separated words and do search by separated words with OR operator by default. You can change the operator to AND by this setting:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.For(searchTerm, options =&amp;gt; {
    options.DefaultOperator = EPiServer.Find.Api.Querying.Queries.BooleanOperator.And;
})});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OR&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.For(searchTerm) }).WithAndAsDefaultOperator();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The below image&lt;/span&gt; is the result when I find products via free-text search - AND operator - In Name field:&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;img src=&quot;/link/10be03209e0f417780dcca3ce698c49a.aspx&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;strong&gt;Note&lt;/strong&gt;: The AND operator here means that search results should contain all words in the search term without order respect.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;How to apply the free-text search for the whole phrase&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Sometimes you want to search contents by the exact word or phrase that you input. So in order to apply the free-text search for the whole phrase, you can wrap your phrase with this format &amp;ldquo;{words}&amp;rdquo; like this:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.For($&amp;rdquo;\&amp;rdquo;{searchTerm}\&amp;rdquo;&amp;rdquo;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The below image&lt;/span&gt; is the result when I find products via free-text search - whole phrase matching - In Name field:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/69d7239382aa43bab9c3d3a5e489b6de.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;How to apply fuzzy search&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Fuzzy search can be used to search for content even when the user makes typographical errors, or when you want to return all search results that match the exact keyword as well as the approximate keyword. However, you need to consider whether you really want to use fuzzy search or not because it does not perform as well as normal free-text search.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Here is a way that I used to apply fuzzy search:&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First, adding an extension method to build a fuzzy query&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static ITypeSearch&amp;lt;TSource&amp;gt; FuzzySearch&amp;lt;TSource&amp;gt;(this ITypeSearch&amp;lt;TSource&amp;gt; search, string query, Expression&amp;lt;Func&amp;lt;TSource, string&amp;gt;&amp;gt; fieldSelector, double minSimilarity, int? prefixLength)
{
    return new Search&amp;lt;TSource, QueryStringQuery&amp;gt;(
        search,
        (ISearchContext context) =&amp;gt;
        {
            if (minSimilarity &amp;lt;= 0 || minSimilarity &amp;gt;= 1)
            {
                return;
            }

            var boolQuery = new BoolQuery();

            boolQuery.MinimumNumberShouldMatch = 1;

            boolQuery.Should.Add(context.RequestBody.Query);

            var fieldNameForSearch = search.Client.Conventions.FieldNameConvention
    .GetFieldNameForAnalyzed(fieldSelector);

            var fuzzyQuery = new FuzzyQuery(fieldNameForSearch, query)
            {
                MinSimilarity = minSimilarity,
                PrefixLength = prefixLength,
                Boost = 0.5,
            };

            boolQuery.Should.Add(fuzzyQuery);

            context.RequestBody.Query = boolQuery;
        });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;MinSimilarity&lt;/span&gt;&lt;/em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;: It means the minimum similarity is acceptable to fit the result&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;PrefixLength&lt;/span&gt;&lt;/em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;: Number of beginning characters left unchanged for fuzzy matching. Defaults to&amp;nbsp;0&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Boost&lt;/span&gt;&lt;/em&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;: Factor for relevance score of fuzzy query. The default is 1. But we can change it based on our purpose to make sure the default result order based on the score as your expectation.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, you can use this method for your search object as follows:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;query = query.FuzzySearch(searchTerm, x =&amp;gt; x.Name, 0.5, 0);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;The below image&lt;/span&gt; is the result when I find products via fuzzy search - Similarity is 0.5 - In Name field:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/76b46e1d5b0e4e11990814555958113d.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;&lt;strong&gt;Note&lt;/strong&gt;: I tried to apply fuzzy search by this guide &lt;/span&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/digital-experience-platform/v1.1.0-search-and-navigation/docs/fuzzy-search&quot;&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;https://docs.developers.optimizely.com/digital-experience-platform/v1.1.0-search-and-navigation/docs/fuzzy-search&lt;/span&gt;&lt;/a&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt; but it does not work with me. I will spend time to dig deeply to find a reason later.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;How to apply multiple search queries with AND/OR operator&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;If you have more than 1 query and need to define AND/OR operator for them then you can do by using BoolQuery.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Here is the way to use BoolQuery for OR:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var boolQuery = new BoolQuery();

boolQuery.MinimumNumberShouldMatch = 1;

boolQuery.Should.Add(subQuery);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;Here is the way to use BoolQuery for AND:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var boolQuery = new BoolQuery();

boolQuery.Must.Add(subQuery);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or AND for negative queries&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var boolQuery = new BoolQuery();

boolQuery.MustNot.Add(subQuery);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-weight: 400;&quot;&gt;In my opinion, using Search &amp;amp; Navigation is still a good choice when you need a search solution for your application. It uses Elasticsearch behind the scenes with a lot of available support for search, filtering, paging, faceting, and statistics, among other features. I hope that the search tips above can help you when applying Search &amp;amp; Navigation to your project.&lt;/span&gt;&lt;/p&gt;</id><updated>2024-05-22T03:59:03.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely Forms - How to add extra data automatically into submission</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2024/3/optimizely-form---add-more-fields-into-submission-data-automatically/" /><id>&lt;p&gt;&lt;strong&gt;Some words about Optimizely Forms&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Optimizely Forms is a built-in add-on by Optimizely development team that enables to create forms dynamically via block-based approach. Developers could install Optimizely Forms to any Optimizely CMS or Customized Commerce site via installing nuget package.&lt;/p&gt;
&lt;p&gt;It allows content editors to create forms dynamically as a normal block, dragging and dropping available form elements such as text box, text area, date time, file upload, image...etc into form container block. So editors can design easily any form with needed controls and re-use it at any pages for vary contexts such as user survey, user registration, user feedback, user support.&lt;/p&gt;
&lt;p&gt;Once users submit form then data will be saved into database permanently by default. Moreover, we also can send submission data to third parties via connectors for auto-marketing purpose.&lt;/p&gt;
&lt;p&gt;Here is illustrated image about creating form in Edit User Interface&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/aa60c69aa37b46d2b4d4a8afbf9a2f8f.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After creating form then placing form in pages then users can see it when viewing page like this below&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/1ad7ee167fcf4ef48f5df54c65e912ca.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How Optimizely Forms works&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The below image illustrates about how to apply Optimizely Forms and how it works&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; &lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp;&lt;img src=&quot;/link/e4783c35984a41b881f65fbdb61f26b0.aspx&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So as a developer, I realized that we can do a lot of customization around Optimizely Forms such as creating new form elements, adding more extra post submission actors, implementing custom connector.&lt;/p&gt;
&lt;p&gt;Recently, I got a requirement that the client want to have some extra data in all submission data such as page url, page category. These extra fields must be stored in database, showed in form submissions view and exporting files as well.&lt;/p&gt;
&lt;p&gt;This below image shows how to submission data is displayed in backend user interface.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/6d6ee420f4ed4343a3d6aa2077e34a98.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; &lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can see data in blue border is filled data by users, data in orange border is system data &amp;ndash; they are not data filled by user. Is it possible to add more data as same as default system data? Yes. We can do customization for it and here is sample code that I want to share&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How I do to add extra data to submission&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First, we need a new post submission actor to add extra data to submission data. This actor should be run first by setting order to make sure that extra data is always added to submission data before all other actors.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class MoreExtraInformationPostSubmissionActor : PostSubmissionActorBase, ISyncOrderedSubmissionActor
 {
     private readonly IUrlResolver _urlResolver;

     public MoreExtraInformationPostSubmissionActor(IUrlResolver urlResolver)
     {
         _urlResolver = urlResolver;
     }

     public int Order =&amp;gt; 1;

     public override object Run(object input)
     {
         var hostedPageId = SubmissionData.Data[&quot;SYSTEMCOLUMN_HostedPage&quot;] as string;

         if (!string.IsNullOrEmpty(hostedPageId))
         {
             var hostedPageUrl = _urlResolver.GetUrl(new ContentReference(hostedPageId));
             SubmissionData.Data.Add(&quot;PageUrl&quot;, hostedPageUrl);
         }
         return new SubmissionActorResult();
     }
 }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;In order to display extra data in view and export file here is the thing that we need to add.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class CustomFormRepository : FormRepository
{
    public override IEnumerable&amp;lt;FriendlyNameInfo&amp;gt; GetFriendlyNameInfos(FormIdentity formIden, params Type[] excludedElementBlockTypes)
    {
        var friendlyNameInfos = new List&amp;lt;FriendlyNameInfo&amp;gt;(base.GetFriendlyNameInfos(formIden, excludedElementBlockTypes));
        friendlyNameInfos.Add(new FriendlyNameInfo() { FormatType = FormatType.String, FriendlyName = &quot;Page Url&quot;, ElementId = &quot;PageUrl&quot; });
        return friendlyNameInfos;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Finally, need to override default FormRepository by new one by adding this below code in Startup.cs&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;  services.AddSingleton&amp;lt;IFormRepository, CustomFormRepository&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The result after applying all above code changes&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Form submissions UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/bd9ca8388e104ad5a00eee57f50fc559.aspx&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In exported file&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/e21224a4289946cda0d695148eb3e8a9.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;That is all thing that I want to share in this article. I hope that it is helpful for someones. Happy coding!&lt;/p&gt;</id><updated>2024-04-29T03:49:42.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>How to add more Content Area Context Menu Item in Optimizely CMS 12</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2024/2/how-to-add-a-new-menu-item-for-content-area-context-menu/" /><id>&lt;p&gt;Hey folks, today I will share something related to Context Menu customization in the Content Area of Optimizely CMS.&lt;/p&gt;
&lt;p&gt;As you know, the content area is a crucial property in Optimizely CMS. It enables editors to place blocks onto a page, allowing for flexible rendering based on the chosen blocks.&lt;/p&gt;
&lt;p&gt;You can see the Content Area Context Menu when editing a page in on-page editing mode or in properties editing mode.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/3676d5be4bc94caa813225a85cfff68d.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;With the Content Area Context Menu, you can see the following available actions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Edit: Navigate to the selected block for editing in the current view.&lt;/li&gt;
&lt;li&gt;Quick Edit: Allows editing of the block within a modal popup in the current view.&lt;/li&gt;
&lt;li&gt;Personalize: Enables customization of block visibility, specifying who can view the block&#39;s content.&lt;/li&gt;
&lt;li&gt;Move Outside Group: Moves the block out from a personalized group.&lt;/li&gt;
&lt;li&gt;Display Option: This action is displayed if configurations for display tags are available.&lt;/li&gt;
&lt;li&gt;Move Up: Shifts the block upwards.&lt;/li&gt;
&lt;li&gt;Move Down: Shifts the block downwards.&lt;/li&gt;
&lt;li&gt;Remove: Deletes a block from the Content Area.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;So question:&lt;strong&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt; &lt;/span&gt;Is it possible if you want to add more customized action for block in Content Area as known as menu item in context menu?&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;My answer is:&lt;strong&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt; &lt;/span&gt;Yes. We could&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Today, I will show you my way to do it based on my experience in Dojo framework. It may be not the best solution but it works with me.&lt;/p&gt;
&lt;p&gt;Here are all steps that you can do to add more menu item to Content Area Context Menu.&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 18pt;&quot;&gt;&lt;strong&gt;1. &lt;/strong&gt;&lt;/span&gt;First step, you need to do is creating a content area command because each menu item in the context menu is currently matched to a content area command.&lt;/p&gt;
&lt;p&gt;Here is the code example for creating a content area command:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;define(&quot;alloy/contentediting/command/CustomOption&quot;, [
    // General application modules
    &quot;dojo/_base/declare&quot;,
    &quot;dojo/_base/lang&quot;,
    &quot;dojo/when&quot;,

    &quot;epi/dependency&quot;,

    &quot;epi-cms/contentediting/command/_ContentAreaCommand&quot;,
    &quot;epi-cms/contentediting/viewmodel/ContentBlockViewModel&quot;
], function (declare, lang, when, dependency, _ContentAreaCommand, ContentBlockViewModel) {

    return declare([_ContentAreaCommand], {        
        // label: [public] String
        //      The action text of the command to be used in visual elements.
        label: &quot;Custom action&quot;,
        // iconClass: [readonly] String
        //      The icon class of the command to be used in visual elements.
        iconClass: &quot;epi-iconStar&quot;,

        constructor: function () {
        },       
        _execute: function () {            
            //Add your logic here when clicking on this action
        },
        _onModelValueChange: function () {
            // summary:
            //      Updates canExecute after the model value has changed.
            // tags:
            //      protected
            var item = this.model;
           
            this.set(&quot;isAvailable&quot;, true);

            this.set(&quot;canExecute&quot;, false);

            if (item &amp;amp;&amp;amp; item.contentLink) {
                this.set(&quot;canExecute&quot;, true);
                return;
            }           
        }

    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;&lt;strong&gt;2. &lt;/strong&gt;&lt;/span&gt;Next step is creating new dojo component one for ContentAreaCommands. This component is used to declare context menu in on-page editting mode.&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;define(&quot;epi-cms/contentediting/command/ContentAreaCommands&quot;, [
    &quot;dojo/_base/array&quot;,
    &quot;dojo/_base/declare&quot;,
    &quot;dojo/Stateful&quot;,
    &quot;dojo/when&quot;,
    &quot;dijit/Destroyable&quot;,
    &quot;epi-cms/ApplicationSettings&quot;,
    &quot;epi-cms/contentediting/command/BlockRemove&quot;,
    &quot;epi-cms/contentediting/command/BlockEdit&quot;,
    &quot;epi-cms/contentediting/command/ContentAreaItemBlockEdit&quot;,
    &quot;epi-cms/contentediting/command/BlockInlineEdit&quot;,
    &quot;epi-cms/contentediting/command/MoveVisibleToPrevious&quot;,
    &quot;epi-cms/contentediting/command/MoveVisibleToNext&quot;,
    &quot;epi-cms/contentediting/command/Personalize&quot;,
    &quot;epi-cms/contentediting/command/SelectDisplayOption&quot;,
    &quot;epi-cms/contentediting/command/MoveOutsideGroup&quot;,
    &quot;alloy/contentediting/command/CustomOption&quot;
], function (array, declare, Stateful, when, Destroyable, ApplicationSettings, Remove, Edit, ContentAreaItemBlockEdit, InlineEdit, MoveVisibleToPrevious, MoveVisibleToNext, Personalize, SelectDisplayOption, MoveOutsideGroup, CustomOption) {

    return declare([Stateful, Destroyable], {
        // tags:
        //      internal

        commands: null,

        constructor: function () {
            this._commandSpliter = this._commandSpliter || new Stateful({
                category: &quot;menuWithSeparator&quot;
            });
            this.contentAreaItemBlockEdit = new ContentAreaItemBlockEdit({ category: null });
            this.blockInlineEdit = new InlineEdit();
            this.moveVisibleToPrevious = new MoveVisibleToPrevious();
            this.moveVisibleToNext = new MoveVisibleToNext();
            this.customOption = new CustomOption();
            this.commands = [
                new Edit({ category: null }),
                this.contentAreaItemBlockEdit,
                this.blockInlineEdit,
                this.customOption,
                this._commandSpliter,
                new SelectDisplayOption(),
                this.moveVisibleToPrevious,
                this.moveVisibleToNext,
                new Remove()
            ];
            var sectionsVisibility = Object.assign({}, ApplicationSettings.sectionsVisibility);
            // Only add personalize command if the ui is not limited
            if (!ApplicationSettings.limitUI &amp;amp;&amp;amp; (sectionsVisibility.visitorGroups !== false)) {
                this.moveOutsideGroup = new MoveOutsideGroup();
                this.personalize = new Personalize({ category: null });
                this.commands.splice(5, 0, this.personalize, this.moveOutsideGroup);
            }

            this.commands.forEach(function (command) {
                this.own(command);
            }, this);
        },

        handleDoubleClick: function (itemModel) {
            if (itemModel.inlineBlockData) {
                when(this.contentAreaItemBlockEdit.updateModel(itemModel)).then(function () {
                    this.contentAreaItemBlockEdit.execute();
                }.bind(this));
            } else {
                when(this.blockInlineEdit.updateModel(itemModel)).then(function () {
                    this.blockInlineEdit.execute();
                }.bind(this));
            }
        },

        _modelSetter: function (model) {
            this.model = model;

            array.forEach(this.commands, function (command) {
                command.set(&quot;model&quot;, model);
            });
        }
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;&lt;strong&gt;3. &lt;/strong&gt;&lt;/span&gt;Next step is creating new dojo component one for ContentAreaEditor. This component is used to declare the context menu in properties editting mode&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;require({
    cache: {
        &#39;url:epi-cms/contentediting/editors/templates/ContentAreaEditor.html&#39;: &quot;&amp;lt;div class=\&quot;dijitInline\&quot; tabindex=\&quot;-1\&quot; role=\&quot;presentation\&quot;&amp;gt;\r\n    &amp;lt;div class=\&quot;epi-content-area-header-block\&quot;&amp;gt;\r\n        &amp;lt;div data-dojo-type=\&quot;epi-cms/contentediting/AllowedTypesList\&quot;\r\n            data-dojo-props=\&quot;allowedTypes: this.allowedTypes, restrictedTypes: this.restrictedTypes\&quot;\r\n            data-dojo-attach-point=\&quot;allowedTypesHeader\&quot;&amp;gt;&amp;lt;/div&amp;gt;\r\n    &amp;lt;/div&amp;gt;\r\n    &amp;lt;div class=\&quot;epi-content-area-editor--wide epi-content-area-editor\&quot;&amp;gt;\r\n        &amp;lt;div data-dojo-attach-point=\&quot;treeNode\&quot;&amp;gt;&amp;lt;/div&amp;gt;\r\n        &amp;lt;div data-dojo-attach-point=\&quot;actionsContainer\&quot; class=\&quot;epi-content-area-actionscontainer\&quot;&amp;gt;&amp;lt;/div&amp;gt;\r\n    &amp;lt;/div&amp;gt;\r\n&amp;lt;/div&amp;gt;\r\n&quot;
    }
});
define(&quot;epi-cms/contentediting/editors/ContentAreaEditor&quot;, [
    // Dojo
    &quot;dojo/_base/declare&quot;,
    &quot;dojo/aspect&quot;,
    &quot;dojo/dom-class&quot;,
    &quot;dojo/dom-style&quot;,
    &quot;dojo/on&quot;,
    &quot;dojo/topic&quot;,
    &quot;dojo/when&quot;,
    &quot;dojo/Stateful&quot;,

    //Dijit
    &quot;dijit/registry&quot;,
    &quot;dijit/_WidgetBase&quot;,
    &quot;dijit/_TemplatedMixin&quot;,
    &quot;dijit/_CssStateMixin&quot;,
    &quot;dijit/_WidgetsInTemplateMixin&quot;,

    // EPi Framework
    &quot;epi/dependency&quot;,
    &quot;epi/shell/dnd/Target&quot;,
    &quot;epi/shell/command/_CommandProviderMixin&quot;,
    &quot;epi/shell/command/_Command&quot;,
    &quot;epi/shell/applicationSettings&quot;,

    //EPi CMS
    &quot;epi-cms/contentediting/editors/_ContentAreaTree&quot;,
    &quot;epi-cms/contentediting/editors/_ContentAreaTreeModel&quot;,
    &quot;epi-cms/contentediting/viewmodel/PersonalizedGroupViewModel&quot;,
    &quot;epi-cms/_ContentContextMixin&quot;,
    &quot;epi-cms/ApplicationSettings&quot;,
    &quot;epi-cms/contentediting/viewmodel/ContentAreaViewModel&quot;,
    &quot;epi-cms/core/ContentReference&quot;,
    &quot;epi/shell/widget/ContextMenu&quot;,
    &quot;epi/shell/widget/_ValueRequiredMixin&quot;,
    &quot;epi-cms/widget/overlay/Block&quot;,
    &quot;epi-cms/widget/command/CreateContentFromContentArea&quot;,
    &quot;epi-cms/widget/command/CreateContentFromSelector&quot;,

    &quot;epi-cms/widget/_HasChildDialogMixin&quot;,

    &quot;epi-cms/contentediting/command/BlockRemove&quot;,
    &quot;epi-cms/contentediting/command/BlockConvert&quot;,
    &quot;epi-cms/contentediting/command/BlockEdit&quot;,
    &quot;epi-cms/contentediting/command/ContentAreaItemBlockEdit&quot;,
    &quot;epi-cms/contentediting/command/BlockInlineEdit&quot;,
    &quot;epi-cms/contentediting/command/MoveToPrevious&quot;,
    &quot;epi-cms/contentediting/command/MoveToNext&quot;,
    &quot;epi-cms/contentediting/command/MoveOutsideGroup&quot;,
    &quot;epi-cms/contentediting/command/Personalize&quot;,
    &quot;epi-cms/contentediting/command/SelectDisplayOption&quot;,
    &quot;alloy/contentediting/command/CustomOption&quot;,
    &quot;epi-cms/contentediting/AllowedTypesList&quot;,
    &quot;epi-cms/contentediting/editors/_TextWithActionsMixin&quot;,

    // Resources
    &quot;dojo/text!epi-cms/contentediting/editors/templates/ContentAreaEditor.html&quot;,
    &quot;epi/i18n!epi/cms/nls/episerver.cms.contentediting.editors.contentarea&quot;,
    &quot;epi/i18n!epi/cms/nls/episerver.cms.widget.overlay.blockarea&quot;
], function (

    // Dojo
    declare,
    aspect,
    domClass,
    domStyle,
    on,
    topic,
    when,
    Stateful,

    // Dijit
    registry,
    _WidgetBase,
    _TemplatedMixin,
    _CssStateMixin,
    _WidgetsInTemplateMixin,

    // EPi Framework
    dependency,
    Target,
    _CommandProviderMixin,
    _Command,
    shellApplicationSettings,

    // CMS
    _ContentAreaTree,
    _ContentAreaTreeModel,
    PersonalizedGroupViewModel,

    _ContentContextMixin,
    ApplicationSettings,
    ContentAreaViewModel,

    ContentReference,
    ContextMenu,
    _ValueRequiredMixin,
    BlockOverlay,
    CreateContentFromContentArea,
    CreateContentFromSelector,

    _HasChildDialogMixin,

    RemoveCommand,
    BlockConvertCommand,
    EditCommand,
    ContentAreaItemBlockEdit,
    BlockInlineEdit,
    MoveToPrevious,
    MoveToNext,
    MoveOutsideGroup,
    Personalize,
    SelectDisplayOption,
    CustomOption,
    AllowedTypesList, // used in template
    _TextWithActionsMixin,

    // Resources
    template,
    resources,
    blockAreaRes
) {

    return declare([
        _WidgetBase,
        _TemplatedMixin,
        _WidgetsInTemplateMixin,
        _CssStateMixin,
        _ValueRequiredMixin,
        _ContentContextMixin,
        _CommandProviderMixin,
        _HasChildDialogMixin,
        _TextWithActionsMixin
    ], {
        // summary:
        //      Editor for ContentArea to be able to edit my content area property in forms mode
        //      This should be a simple, non WYSIWYG listing of the inner content blocks with possibilities to add, remove and rearrange the content.
        //
        // tags:
        //      internal

        // baseClass: [public] String
        //    The widget&#39;s base CSS class.
        baseClass: &quot;epi-content-area-wrapper&quot;,

        emptyClass: &quot;epi-content-area-wrapper--empty&quot;,

        // res: Json object
        //      Language resource
        res: resources,

        // templateString: String
        //      UI template for content area editor
        templateString: template,

        // value: String
        //      Value of the content area
        value: null,

        // multiple: Boolean
        //  Value must be true, otherwise dijit/Form will trea the value as an object instead of an array
        multiple: true,

        // parent: Object
        //      Editor wrapper object containe the editor
        parent: null,

        // overlayItem: Object
        //      Source overlay of the content area in on page edit mode
        overlayItem: null,

        // model: Object
        //      Content area editor view model
        model: null,

        // intermediateChanges: Boolean
        //      Inherited from editor interface
        intermediateChanges: true,

        // editMode: String
        //      Flags to detect page edit mode (On page edit or Form edit mode or Create content mode)
        editMode: &quot;onpageedit&quot;,

        // _preventOnBlur: [private] Boolean
        //      When set, the onBlur event is prevented.
        _preventOnBlur: false,

        _dndTarget: null,

        // allowedTypes: [public] Array
        //      The types which are allowed. i.e used for filtering based on AllowedTypesAttribute
        allowedTypes: null,

        // restrictedTypes: [public] Array
        //      The types which are restricted.
        restrictedTypes: null,

        // actionsResource: [Object]
        //      The resource of actions link
        actionsResource: blockAreaRes,

        // actionsResource: [Object]
        //      Name of constructor function of ContentAreaTree class
        treeClass: _ContentAreaTree,

        // allowMultipleItems: [Boolean]
        //      Allow dnd multiple items at once
        allowMultipleItems: true,

        constructor: function () {
            this.allowedDndTypes = [];
        },

        onChange: function (value) {
            // summary:
            //    Called when the value in the widget changes.
            // tags:
            //    public callback
        },

        onForceChange: function (value) {
            this.onChange(value);
        },

        _handleModelChange: function (value) {
            // summary:
            //    Called when the value in the model changes.
            // tags:
            //    public callback

            this.validate();
            this.onForceChange(value);

            this._toggleEmptyClass();
            if (this.model.selectedItem) {
                this.updateCommandModel(this.model.selectedItem);
            }

            this._toggleActionsContainer();
        },

        _toggleClass: function (node, className, condition) {
            (node || this.domNode).classList[condition ? &quot;add&quot; : &quot;remove&quot;](className);
        },

        _toggleEmptyClass: function () {
            this._toggleClass(this.domNode, this.emptyClass, !this.value || this.value.length &amp;lt;= 0);
        },

        _onBlur: function () {
            // summary:
            //      Override base to prevent the onBlur from being called when the _preventOnBlur flag is set.
            // tags:
            //      protected override

            if (this._preventOnBlur) {
                return;
            }
            this.inherited(arguments);
        },

        focus: function () {
            // summary:
            //    Focus the tree if there is a value, else focus the create block text.
            // tags:
            //    public
            if (this.tree &amp;amp;&amp;amp; this.model.get(&quot;value&quot;).length &amp;gt; 0) {
                this._focusManager.focus(this.tree.domNode);
            } else {
                if (this.textWithLinks) {
                    this.textWithLinks.focus();
                }
            }
        },

        postMixInProperties: function () {

            this.inherited(arguments);

            this._commandSpliter = this._commandSpliter || new Stateful({
                category: &quot;menuWithSeparator&quot;
            });

            // NOTE: Check for this._commands to allow for mocking the commands without breaking _CommandProviderMixin.
            this.contentAreaItemBlockEdit = new ContentAreaItemBlockEdit({ category: null, isContentAreaReadonly: this.get(&quot;readOnly&quot;) });
            this.blockInlineEdit = new BlockInlineEdit();
            this.movePrevious = new MoveToPrevious();
            this.moveNext = new MoveToNext();
            this.customOption = new CustomOption();

            this.commands = this._commands || [
                new EditCommand({ category: null }),
                this.contentAreaItemBlockEdit,
                this.blockInlineEdit,
                this.customOption,
                this._commandSpliter,
                new SelectDisplayOption(),
                this.movePrevious,
                this.moveNext,
                new RemoveCommand(),
                new BlockConvertCommand()
            ];

            this.own(on(this.contentAreaItemBlockEdit, &quot;save&quot;, function (inlineBlockData, name) {
                this._preventOnBlur = false;

                var value = [];
                (this.model.getChildren() || []).forEach(function (child) {
                    if (child instanceof PersonalizedGroupViewModel) {
                        (child.getChildren() || []).forEach(function (child) {
                            if (child.id === this.model.selectedItem.id) {
                                child.inlineBlockData = inlineBlockData;
                                child.name = name;
                            }
                            value.push(child);
                        }.bind(this));
                    } else {
                        if (child.id === this.model.selectedItem.id) {
                            child.inlineBlockData = inlineBlockData;
                            child.name = name;
                        }
                        value.push(child);
                    }
                }.bind(this));
                value = value.map(function (v) {
                    return v.serialize();
                });

                this.set(&quot;value&quot;, value);

                // In order to be able to add a block when creating it from a floating editor
                // we need to set the editing parameter on the editors parent wrapper to true
                // since it has been set to false while being suspended when switching to
                // the secondaryView.
                this.parent = this.parent || this.getParent();
                this.parent.set(&quot;editing&quot;, true);
                this.onForceChange(value);

                // Now call onBlur since it&#39;s been prevented using the _preventOnBlur flag.
                this.onBlur();
            }.bind(this)));

            // Only add personalize command if the ui is not limited
            if (this._isPersonalizationEnabled()) {
                this.commands.splice(5, 0,
                    new Personalize({ category: null }),
                    new MoveOutsideGroup()
                );
            }
            this.commands.forEach(function (command) {
                this.own(command);
            }, this);

            this.own(
                //Create the view model
                this.model = this.model || new ContentAreaViewModel({
                    maxLength: this.maxLength,
                    minLength: this.minLength
                }),
                this.treeModel = this.treeModel || new _ContentAreaTreeModel({ model: this.model }),
                this.model.watch(&quot;selectedItem&quot;, function (name, oldValue, newValue) {
                    //Update the commands with the selected block
                    this.updateCommandModel(newValue);
                }.bind(this)),
                on(this.model, &quot;changed&quot;, function () {
                    if (!this._started || this._supressValueChanged) {
                        return;
                    }

                    this._set(&quot;value&quot;, this.model.get(&quot;value&quot;));

                    //Call to the handle model change with the new value
                    this._handleModelChange(this.value);
                }.bind(this))
            );
            // personalizationarea isn&#39;t an actual type so it needs to be hardcoded like in _ContentAreaTree
            this.allowedDndTypes.push(&quot;personalizationarea&quot;);
            this.allowedDndTypes.push(this._getInlineBlockDndKey());

            if (!this.contentDataStore) {
                var registry = dependency.resolve(&quot;epi.storeregistry&quot;);
                this.contentDataStore = registry.get(&quot;epi.cms.contentdata&quot;);
            }
        },

        buildRendering: function () {
            this.inherited(arguments);

            this.contextMenu = new ContextMenu();
            this.contextMenu.addProvider(this);

            this.own(this.contextMenu);

            this._setupActions(this.actionsContainer);

            this.own(this._dndTarget = new Target(this.actionsContainer, {
                accept: this.allowedDndTypes,
                reject: this.restrictedDndTypes,
                isSource: false,
                alwaysCopy: false,
                allowMultipleItems: this.allowMultipleItems,
                insertNodes: function () { }
            }));

            this.own(aspect.after(this._dndTarget, &quot;onDropData&quot;, function (dndData, source, nodes, copy) {
                dndData.forEach(function (dndData) {
                    this.model.modify(function () {
                        this.model.addChild(dndData.data);
                    }.bind(this));
                }, this);

                if (!this.tree) {
                    this._createTree();
                }

            }.bind(this), true));

            // Handle focus after dropping on the tree or the drop area. We set focus to ourselves so that
            // it is not left where the drag originated.
            this.own(aspect.after(this._dndTarget, &quot;onDrop&quot;, this.focus.bind(this)));
        },

        postCreate: function () {
            this.inherited(arguments);

            this.set(&quot;emptyMessage&quot;, resources.emptymessage);
            this._toggleEmptyClass();

            if (this.parent &amp;amp;&amp;amp; this.overlayItem) {
                this.own(aspect.after(this.parent, &quot;onStartEdit&quot;, function () {
                    this._selectFromOverlay(this.overlayItem.model);
                }.bind(this)));
            }

            this.own(topic.subscribe(&quot;/dnd/start&quot;, this._startDrag.bind(this)));
        },

        startup: function () {

            if (this._started) {
                return;
            }

            this.inherited(arguments);

            this.tree &amp;amp;&amp;amp; this.tree.startup();
            this.contextMenu.startup();
            this._updateStyle();

            this.own(
                this.allowedTypesHeader.watch(&quot;hasRestriction&quot;, this._updateStyle.bind(this))
            );
        },

        destroy: function () {
            this.tree &amp;amp;&amp;amp; this.tree.destroyRecursive();

            this.inherited(arguments);
        },

        isCreateLinkVisible: function () {
            // summary:
            //      Overridden mixin class, depend on currentMode will show/not create link
            // tags:
            //      protected

            return this.model.canCreateBlock(this.allowedTypes, this.restrictedTypes);
        },

        onDialogExecute: function (selectedContent) {
            if (selectedContent) {
                this._saveValueAndFireOnChange({
                    contentLink: new ContentReference(selectedContent.contentLink).createVersionUnspecificReference().toString(),
                    name: selectedContent.name,
                    typeIdentifier: selectedContent.typeIdentifier
                });
            }
        },

        _saveValueAndFireOnChange: function (block) {
            this._preventOnBlur = false;
            var value = Object.assign([], this.model.get(&quot;value&quot;), true);
            value.push(block);
            this.set(&quot;value&quot;, value);

            // In order to be able to add a block when creating it from a floating editor
            // we need to set the editing parameter on the editors parent wrapper to true
            // since it has been set to false while being suspended when switching to
            // the secondaryView.
            this.parent = this.parent || this.getParent();
            this.parent.set(&quot;editing&quot;, true);
            this.validate();
            this.onForceChange(value);

            // Now call onBlur since it&#39;s been prevented using the _preventOnBlur flag.
            this.onBlur();
        },

        executeAction: function (actionName) {
            // summary:
            //      Overridden mixin class executing click actions from textWithLinks widget
            // actionName: [String]
            //      Action name of link on content area
            // tags:
            //      public

            if (actionName === &quot;createnewblock&quot;) {
                // HACK: Preventing the onBlur from being executed so the editor wrapper keeps this editor in editing state
                this._preventOnBlur = true;

                // since we&#39;re going to create a block, we need to hide all validation tooltips because onBlur is prevented here
                this.validate(false);

                var command = shellApplicationSettings.inlineBlocksInContentAreaEnabled ? new CreateContentFromContentArea({
                    allowedTypes: this.allowedTypes,
                    restrictedTypes: this.restrictedTypes
                }) : new CreateContentFromSelector({
                    creatingTypeIdentifier: &quot;episerver.core.blockdata&quot;,
                    createAsLocalAsset: true,
                    isInQuickEditMode: this.isInQuickEditMode,
                    quickEditBlockId: this.quickEditBlockId,
                    autoPublish: true,
                    allowedTypes: this.allowedTypes,
                    restrictedTypes: this.restrictedTypes
                });

                command.set(&quot;model&quot;, {
                    save: this._saveValueAndFireOnChange.bind(this),
                    cancel: function () {
                        this._preventOnBlur = false;
                        this.onBlur();
                    }.bind(this)
                });
                command.execute();
            }
        },

        isValid: function (isFocused) {
            // summary:
            //    Check if widget&#39;s value is valid.
            // isFocused:
            //    Indicate that the widget is being focused.
            // tags:
            //    protected

            // When create block screen is visible, we need to hide all validation messages since onBlur is prevented.
            return (this._preventOnBlur || !this.required || this.model.get(&quot;value&quot;).length &amp;gt; 0);
        },

        _setReadOnlyAttr: function (readOnly) {
            this._set(&quot;readOnly&quot;, readOnly);
            this._toggleActionsContainer();

            if (this._source) {
                this._source.isSource = !this.readOnly;
            }

            if (this.model) {
                this.model.set(&quot;readOnly&quot;, readOnly);
            }

            this.tree &amp;amp;&amp;amp; this.tree.set(&quot;readOnly&quot;, readOnly);
        },

        _toggleActionsContainer: function () {
            // summary:
            //    Hide actions when readonly or editor has reached the items limit
            // tags:
            //    private

            var visible = !this.model.hasReachedItemsLimit() &amp;amp;&amp;amp; !this.get(&quot;readOnly&quot;);
            domStyle.set(this.actionsContainer, &quot;display&quot;, visible ? &quot;&quot; : &quot;none&quot;);
        },

        _checkAcceptance: function (source, nodes) {
            // summary:
            //      Customize checkAcceptance func
            // source: Object
            //      The source which provides items
            // nodes: Array
            //      The list of transferred items

            return this.readOnly ? false : this._source.defaultCheckAcceptance(source, nodes) &amp;amp;&amp;amp; !this.model.hasReachedItemsLimit();
        },

        _createTree: function () {
            // summary:
            //    Creates the tree widget
            // tags:
            //    private

            //Create the tree
            this.tree = new this.treeClass({
                accept: this.allowedDndTypes,
                reject: this.restrictedDndTypes,
                contextMenu: this.contextMenu,
                model: this.treeModel,
                readOnly: this.readOnly,
                inlineBlockDndKey: this._getInlineBlockDndKey(),
                inlineBlockNameProperties: this.inlineBlockNameProperties
            }).placeAt(this.treeNode);

            this.tree.own(on(this.tree, &quot;dblclick&quot;, function (itemModel) {
                if (itemModel.inlineBlockData) {
                    when(this.contentAreaItemBlockEdit.updateModel(itemModel)).then(function () {
                        this.contentAreaItemBlockEdit.execute();
                    }.bind(this));
                } else {
                    when(this.blockInlineEdit.updateModel(itemModel)).then(function () {
                        this.blockInlineEdit.execute();
                    }.bind(this));
                }
            }.bind(this)));

            this.tree.own(
                aspect.after(this.tree.dndController, &quot;onDndEnd&quot;, this.focus.bind(this))
            );
        },

        _selectFromOverlay: function (overlayModel) {
            var child = overlayModel &amp;amp;&amp;amp; overlayModel.selectedItem &amp;amp;&amp;amp; overlayModel.selectedItem.serialize(),
                model = this.model,
                path = [&quot;root&quot;];

            // exit if there is no overlay model to select item
            if (!child) {
                return;
            }

            if (child.contentGroup) {
                model = model.getChild({ name: child.contentGroup });
                path.push(model.id);
            }
            model = model.getChild(child);
            if (!model) {
                return;
            }

            path.push(model.id);

            model.set(&quot;selected&quot;, true);
            model.set(&quot;ensurePersonalization&quot;, overlayModel.selectedItem.ensurePersonalization);

            // TODO: move this selection into tree instead
            this.tree &amp;amp;&amp;amp; this.tree.set(&quot;path&quot;, path);

        },

        _startDrag: function (source, nodes, copy) {
            var accepted = this._dndTarget.accept &amp;amp;&amp;amp; this._dndTarget.checkAcceptance(source, nodes);
            domClass.toggle(this.domNode, &quot;dojoDndTargetDisabled&quot;, !accepted);

            // close the editor when user start draging Block from BlockArea
            //TODO: This widget should not call a method on the parent
            var widget = registry.getEnclosingWidget(nodes[0]);
            if (widget &amp;amp;&amp;amp; widget.isInstanceOf(BlockOverlay) &amp;amp;&amp;amp; this.parent &amp;amp;&amp;amp; this.parent.cancel) {
                // We set isModified to false (default value) because always synchronize value
                // between OPE and Editor
                this.parent.set(&quot;isModified&quot;, false);
                this.parent.cancel();
            }
        },

        _ensureNonBrokenContentAreaItems: function (value) {
            var itemsList = value || [];
            itemsList.forEach(function (item) {
                if (!item || (!item.contentLink &amp;amp;&amp;amp; !item.inlineBlockData)) {
                    item.isBrokenLink = true;
                    item.name = resources.brokenlink;
                }
            });
            return itemsList;
        },

        _setValueAttr: function (value) {
            value = this._ensureNonBrokenContentAreaItems(value);
            // Destroy the tree since that is the fastest way to remove all items
            this.tree &amp;amp;&amp;amp; this.tree.destroyRecursive();

            this._set(&quot;value&quot;, value);

            this._supressValueChanged = true;
            this.model.set(&quot;value&quot;, value);
            this._supressValueChanged = false;

            // Create the tree again after the value has been set so
            // all tree nodes are created in one go
            this._createTree();

            this._toggleEmptyClass();
            this._toggleActionsContainer();
        },

        _updateStyle: function () {
            // summary:
            //      Handle the widget style depending on the allowedTypesList visibility

            if (!this.domNode) {
                return;
            }

            if (this.allowedTypesHeader.get(&quot;hasRestriction&quot;)) {
                domClass.remove(this.domNode, &quot;allowed-types-list-hidden&quot;);
            } else {
                domClass.add(this.domNode, &quot;allowed-types-list-hidden&quot;);
            }
        },

        _isPersonalizationEnabled: function () {
            var sectionsVisibility = Object.assign({}, ApplicationSettings.sectionsVisibility);
            if (sectionsVisibility.visitorGroups === false) {
                return false;
            }
            return !ApplicationSettings.limitUI;
        },

        _getInlineBlockDndKey: function () {
            return this.name + &quot;_contentarea-inline-block&quot;;
        }
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;&lt;strong&gt;4. &lt;/strong&gt;&lt;/span&gt;Last one, add overrided dojo components into module config as epi base resource to load customized resources once loading epi base resource&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;module loadFromBin=&quot;false&quot; clientResourceRelativePath=&quot;&quot; viewEngine=&quot;Razor&quot;  moduleJsonSerializerType=&quot;None&quot; preferredUiJsonSerializerType=&quot;Net&quot;&amp;gt;	
	&amp;lt;dojo&amp;gt;
		&amp;lt;paths&amp;gt;
			&amp;lt;add name=&quot;alloy&quot; path=&quot;ClientResources/Scripts&quot; /&amp;gt;
		&amp;lt;/paths&amp;gt;
	&amp;lt;/dojo&amp;gt;
	&amp;lt;clientResources&amp;gt;		
		&amp;lt;add name=&quot;epi-cms.widgets.base&quot; path=&quot;ClientResources/Scripts/contentediting/command/ContentAreaCommands.js&quot; resourceType=&quot;Script&quot; /&amp;gt;
		&amp;lt;add name=&quot;epi-cms.widgets.base&quot; path=&quot;ClientResources/Scripts/editors/ContentAreaEditor.js&quot; resourceType=&quot;Script&quot; /&amp;gt;
	&amp;lt;/clientResources&amp;gt;
&amp;lt;/module&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I hope this article will help some of you somehow. Enjoy reading!&lt;/p&gt;</id><updated>2024-02-25T09:37:28.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>How to apply output cache to Optimizely CMS 12</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2023/3/how-to-apply-output-cache-to-optimizely-cms-12/" /><id>&lt;p&gt;&lt;strong&gt;Introduce&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Optimizely CMS 12 is a platform for content management systems. It is written by .NET 6.0. Actually, the output caching concept has not been yet made it in .NET 6.0 but there is a response caching concept in .NET6.0.&lt;/p&gt;
&lt;p&gt;You can use response cache to cache output for MVC controllers, MVC action methods, RAZOR pages. Response caching reduces the amount of work the web server performs to generate a response by returning result immediately from cache if it exists instead of running methods again and again. By this way, the performance is improved and server resources are optimized.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step to apply response cache in Optimizely CMS 12&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Step 1: Add [ResponseCache] attribute to the controller/action/razor page that you want:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class StartPageController : PageControllerBase&amp;lt;StartPage&amp;gt;
    {
        [ResponseCache(Duration =30, Location = ResponseCacheLocation.Any)]
        public IActionResult Index(StartPage currentPage)
        {
            var model = PageViewModel.Create(currentPage);

            // Check if it is the StartPage or just a page of the StartPage type.
            if (SiteDefinition.Current.StartPage.CompareToIgnoreWorkID(currentPage.ContentLink))
            {
                // Connect the view models logotype property to the start page&#39;s to make it editable
                var editHints = ViewData.GetEditHints&amp;lt;PageViewModel&amp;lt;StartPage&amp;gt;, StartPage&amp;gt;();
               editHints.AddConnection(m =&amp;gt; m.Layout.Logotype, p =&amp;gt; p.SiteLogotype);
               editHints.AddConnection(m =&amp;gt; m.Layout.ProductPages, p =&amp;gt; p.ProductPageLinks);
               editHints.AddConnection(m =&amp;gt; m.Layout.CompanyInformationPages, p =&amp;gt; p.CompanyInformationPageLinks);
               editHints.AddConnection(m =&amp;gt; m.Layout.NewsPages, p =&amp;gt; p.NewsPageLinks);
               editHints.AddConnection(m =&amp;gt; m.Layout.CustomerZonePages, p =&amp;gt; p.CustomerZonePageLinks);
           }

           return View(model);
       }
   }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Duration=30&lt;/strong&gt; will cache the page for 30 seconds&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Location=ResponseCacheLocation.Any&lt;/strong&gt; will cache the page in both proxies and client.&lt;/p&gt;
&lt;p&gt;If you do not apply response cache for certain situation then you can use &lt;strong&gt;Location= ResponseCacheLocation.None&lt;/strong&gt; and &lt;strong&gt;NoStore=true&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Step 2: Add Response Cache Middleware services to service collection with AddResponseCaching extension method:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public void ConfigureServices(IServiceCollection services)
    {
        if (_webHostingEnvironment.IsDevelopment())
        {
            AppDomain.CurrentDomain.SetData(&quot;DataDirectory&quot;, Path.Combine(_webHostingEnvironment.ContentRootPath, &quot;App_Data&quot;));

            services.Configure&amp;lt;SchedulerOptions&amp;gt;(options =&amp;gt; options.Enabled = false);
        }

        services
            .AddCmsAspNetIdentity&amp;lt;ApplicationUser&amp;gt;()
            .AddCms()
            .AddCmsTagHelpers()
            .AddAlloy()
            .AddAdminUserRegistration()
            .AddEmbeddedLocalization&amp;lt;Startup&amp;gt;();

        // Required by Wangkanai.Detection
        services.AddDetection();

        services.AddSession(options =&amp;gt;
        {
            options.IdleTimeout = TimeSpan.FromSeconds(10);
            options.Cookie.HttpOnly = true;
            options.Cookie.IsEssential = true;
        });
        services.AddControllers();
        services.AddResponseCaching();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Step 3: Configure the app to use the middleware with the&amp;nbsp;UseResponseCaching&amp;nbsp;extension method:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        // Required by Wangkanai.Detection
        app.UseDetection();
        app.UseSession();

        app.UseResponseCaching();

        app.UseStaticFiles();
        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =&amp;gt;
        {
            endpoints.MapContent();
            endpoints.MapControllers();
        });
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Please note that if you load a page by pressing F5 key in the browser then the cache for this page is refreshed by adding Cache-Control header to request is &amp;ldquo;max-age=0&amp;rdquo;. In order to prevent that, you can add the following middleware before response cache middleware:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   app.Use(async (context, next) =&amp;gt;
   {
        const string cc = &quot;Cache-Control&quot;;

        if (context.Request.Headers.ContainsKey(cc) &amp;amp;&amp;amp; string.Equals(context.Request.Headers[cc], &quot;max-age=0&quot;, StringComparison.InvariantCultureIgnoreCase))
        {
              context.Request.Headers.Remove(cc);
        }
        await next();
   });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;How to invalidate cache when the content is changed&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Actually, the response cache middleware uses MemoryResponseCache by default and this implementation does not support clearing cache. So you can do quickly and dirty to get a new cache by using the content cache version as a query key to vary cache. The content cache version is increased once any content is changed.&lt;/p&gt;
&lt;p&gt;Here are the steps that you can take to do that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add ContentCacheVersion to vary by query keys attribute&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   [ResponseCache(Duration =30, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { &quot;ContentCacheVersion&quot;})]
   public IActionResult Index(StartPage currentPage)
   {
       var model = PageViewModel.Create(currentPage);
       // Check if it is the StartPage or just a page of the StartPage type.
       if (SiteDefinition.Current.StartPage.CompareToIgnoreWorkID(currentPage.ContentLink))
       {&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Add a middleware before Response Caching Middleware to add content cache version to the query string and add a middleware after Response Caching Middleware to remove this from the query string.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   app.Use(async (context, next) =&amp;gt;
        {
            var contentCacheVersion = ServiceLocator.Current.GetInstance&amp;lt;IContentCacheVersion&amp;gt;();

            context.Request.QueryString = context.Request.QueryString.Add(&quot;ContentCacheVersion&quot;, contentCacheVersion.Version.ToString());
           
            await next();
        });

        app.UseResponseCaching();

        app.Use(async (context, next) =&amp;gt;
        {
            var nameValueCollection = System.Web.HttpUtility.ParseQueryString(context.Request.QueryString.ToString());
            nameValueCollection.Remove(&quot;ContentCacheVersion&quot;);
            context.Request.QueryString = new QueryString($&quot;?{nameValueCollection}&quot;);
            
            await next();
        });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;How to vary response cache by visitor group&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It is the same as in the case of changed content, you can do quickly and dirty to add visitor group as a vary by query key like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add VisitorGroup to vary by query keys attribute&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ResponseCache(Duration =30, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { &quot;VisitorGroup&quot;})]
    public IActionResult Index(StartPage currentPage)
    {
        var model = PageViewModel.Create(currentPage);

        // Check if it is the StartPage or just a page of the StartPage type.
        if (SiteDefinition.Current.StartPage.CompareToIgnoreWorkID(currentPage.ContentLink))
        {&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Add a middleware before Response Caching Middleware to add the current visitor group to the query string and add a middleware after Response Caching Middleware to remove it from the query string.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   app.Use(async (context, next) =&amp;gt;
        {
            context.Request.QueryString = context.Request.QueryString.Add(&quot;VisitorGroup&quot;, GetCurrentVisitorGroups(context));
           
            await next();
        });
        app.UseResponseCaching();
        app.Use(async (context, next) =&amp;gt;
        {
            var nameValueCollection = System.Web.HttpUtility.ParseQueryString(context.Request.QueryString.ToString());
            nameValueCollection.Remove(&quot;VisitorGroup&quot;);
            context.Request.QueryString = new QueryString($&quot;?{nameValueCollection}&quot;);
            await next();
        });&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Add GetCurrentVisitorGroups method to get visitor groups based on current context&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    private string GetCurrentVisitorGroups(HttpContext context)
    {
        var  principalAccessor = ServiceLocator.Current.GetInstance&amp;lt;IPrincipalAccessor&amp;gt;();
        var  visitorGroupRepository = ServiceLocator.Current.GetInstance&amp;lt;IVisitorGroupRepository&amp;gt;();
        var  visitorGroupRoleRepository = ServiceLocator.Current.GetInstance&amp;lt;IVisitorGroupRoleRepository&amp;gt;();

        var roleNames = visitorGroupRepository.List().Select(x =&amp;gt; x.Name);

        var currentGroups = new List&amp;lt;string&amp;gt;();
        foreach (var roleName in roleNames)
        {
            if (visitorGroupRoleRepository.TryGetRole(roleName, out var role))
            {
                if (role.IsMatch(principalAccessor.Principal, context))
                {
                    currentGroups.Add(roleName);
                }
            }
        }
        return string.Join(&quot;|&quot;, currentGroups);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is it. Using this approach, you can apply built-in response cache quickly into Optimizely CMS 12 without customizing too much or using third-party packages. You can see another topic about using third-party output caching package here &lt;a href=&quot;https://www.gulla.net/en/blog/quick-and-dirty-output-cache-in-optimizely-cms12/&quot;&gt;https://www.gulla.net/en/blog/quick-and-dirty-output-cache-in-optimizely-cms12/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;div class=&quot;ddict_btn&quot;&gt;&lt;img src=&quot;chrome-extension://bpggmmljdiliancllaapiggllnkbjocb/logo/48.png&quot; /&gt;&lt;/div&gt;
&lt;div class=&quot;ddict_btn&quot;&gt;&lt;img src=&quot;chrome-extension://bpggmmljdiliancllaapiggllnkbjocb/logo/48.png&quot; /&gt;&lt;/div&gt;
&lt;div class=&quot;ddict_btn&quot;&gt;&lt;img src=&quot;chrome-extension://bpggmmljdiliancllaapiggllnkbjocb/logo/48.png&quot; /&gt;&lt;/div&gt;</id><updated>2023-03-17T04:57:59.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>How to route a simple url address within action name</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2020/8/how-to-route--a-content-with-simple-url-and-action/" /><id>&lt;p&gt;&lt;strong&gt;What is the simple address in the EpiServer?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The simple address is a single segment uniquely identifying&amp;nbsp;content and its language.&amp;nbsp;&lt;span&gt;The simple address can be used as a direct address without parent nodes included in URL.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/fc5212912a6a475498cd1a4e00f76984.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The situation here&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You are using simple url address for your CMS pages and there are some pages you want to proceed data that sent from the client.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;p&gt;You are in the contact page &quot;{base_url}/contact-page-seo-url&quot;, you have a submit form to allow user input data and send back to our system by POST endpoint&amp;nbsp;&quot;{base_url}/contact-page-seo-url/subcription&quot;.&lt;/p&gt;
&lt;p&gt;Actually, you cannot use the endpoint like this, you will get 404 message immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I realized that the default simple address route of Episerver always considers that all path after {base_url} is SIMPLE ADDRESS PATH includes ACTION name.&lt;/p&gt;
&lt;p&gt;So the system cannot route any content that matchs to the simple address &quot;contact-page-seo-url/subcription&quot;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How can we solve this problem?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here is it. You need to add more a route to allow that. And we do not need to create any custom route, just do like that&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
    public partial class SiteInitializationModule : IConfigurableModule
    {

        public void Initialize(InitializationEngine context)
        {
            RegisterCustomSimpleAddressRoute(context, RouteTable.Routes);
        }

        public void ConfigureContainer(ServiceConfigurationContext context)
        {
        }

        public void Uninitialize(InitializationEngine context)
        {
        }

        private static void RegisterCustomSimpleAddressRoute(InitializationEngine context, RouteCollection routes)
        {
            var rewriteExtension = Settings.Instance.UrlRewriteExtension;

            var urlResolver = context.Locate.Advanced.GetInstance&amp;lt;UrlResolver&amp;gt;();
            var contentLoader = context.Locate.Advanced.GetInstance&amp;lt;IContentLoader&amp;gt;();

            var contentLanguageSettingsHandler = context.Locate.Advanced.GetInstance&amp;lt;IContentLanguageSettingsHandler&amp;gt;();

            var basePathResolver = context.Locate.Advanced.GetInstance&amp;lt;IBasePathResolver&amp;gt;();

            var addressSegmentRouter = new SimpleAddressSegmentRouter(sd =&amp;gt; sd.StartPage);

            var parameters = new MapContentRouteParameters()
            {
                UrlSegmentRouter = addressSegmentRouter,
                BasePathResolver = basePathResolver.Resolve,
                Direction = SupportedDirection.Incoming,
                SegmentMappings = new Dictionary&amp;lt;string, ISegment&amp;gt;()
                {
                    {
                        RoutingConstants.SimpleAddressKey,
                        new SimpleAddressSegment(RoutingConstants.SimpleAddressKey, rewriteExtension, addressSegmentRouter, contentLoader, urlResolver, contentLanguageSettingsHandler)
                        {
                            MatchOneSegment = true
                        }
                    }
                }
            };


            routes.MapContentRoute(&quot;CustomSimpleAddress&quot;, &quot;{simpleaddresslanguage}/{simpleaddress}/{partial}/{action}&quot;, (object)new
            {
                action = &quot;index&quot;
            }, parameters);

        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tada, it is not much complicated. You just need to register more another simple address route with parameter &quot;MatchOneSegment = true&quot;.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;This parameter indicates that only one segment is matching to a certain simple address instead of all segments as default.&lt;/p&gt;
&lt;p&gt;Hope this help some of you.&lt;/p&gt;</id><updated>2020-10-15T02:16:09.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Lock and Unlock account using AspNet Identity</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2020/4/lock-and-unlock-account-using-aspnet-identity/" /><id>&lt;p&gt;You are using AspNet Identity for authentication and want to configure to block user if he/she inputs wrong password over a certainly allowed login attempts. I have had an experience to implement this function in EpiServer version 11 and&amp;nbsp;Microsoft.AspNet.Identity 2.2&lt;/p&gt;
&lt;p&gt;Here are steps:&lt;/p&gt;
&lt;p&gt;1. Configure user lockout in your&amp;nbsp;ApplicationUserManager as mentioned in&amp;nbsp;&lt;a href=&quot;/link/5da1a6cc4d7f4f95a9daf3b5bb726748.aspx&quot;&gt;https://world.episerver.com/documentation/developer-guides/CMS/security/episerver-aspnetidentity/&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;// Configure user lockout defaults
manager.UserLockoutEnabledByDefault = true; //This flag is true it means will enable lockout when users are created. Noticed that a user is locked if LockEnable flag is true and LockoutEndDateUtc is set and greater than now
manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(60); //User will be locked in 60 minutes
manager.MaxFailedAccessAttemptsBeforeLockout = 5; //User will be locked after 5 continuesly failed attempts&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2. Pass shouldLockout is true when you call to validate user for login&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;  var signInStatus = await _signInManager.PasswordSignInAsync(username, password, isPersistent, shouldLockout:true);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3. If there are a lot of existed users that created before turning on user lockout functionality then you should migrate all existed user to enable lockout for them if you want to apply user lockout for all existed users too. You can create an Episerver migration step to do that like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ServiceConfiguration(typeof(IMigrationStep))]
    public class EnableUserLockOutMigrationStep : IMigrationStep
    {
        private readonly IConnectionStringHandler _connectionHandler;

        public EnableUserLockOutMigrationStep(IConnectionStringHandler connectionHandler)
        {
            this._connectionHandler = connectionHandler;
        }

        public bool Execute(IProgressMessenger progressMessenger)
        {
            progressMessenger.AddProgressMessageText(&quot;Enabling user lockout...&quot;, false, 0);
            try
            {
              
                using (SqlConnection connection = new SqlConnection(this._connectionHandler.Commerce.ConnectionString))
                {
                    connection.Open();
                    using (SqlTransaction transaction = connection.BeginTransaction())
                    {
                        try
                        {
                            this.CreateCommand(transaction, @&quot;UPDATE [dbo].[AspNetUsers] SET [LockoutEnabled] = 1&quot;, 300).ExecuteNonQuery();
                            transaction.Commit();
                        }
                        catch (Exception ex)
                        {
                            transaction.Rollback();
                            connection.Close();

                            throw new Exception((string)null, ex);
                        }
                    }
                    connection.Close();
                }
                return true;
            }
            catch (Exception ex)
            {
                progressMessenger.AddProgressMessageText(string.Format((IFormatProvider)CultureInfo.InvariantCulture, &quot;Enable user lockout has failed with exception &#39;{0}&#39;.&quot;, (object)ex), true, 0);
            }
            return false;
        }

        public int Order =&amp;gt; 1000;
        public string Name =&amp;gt; &quot;Enable User Lockout&quot;;
        public string Description =&amp;gt; &quot;This is used to turn on Enable User Lockout for existed users&quot;;

        private SqlCommand CreateCommand(
            SqlTransaction transaction,
            string query,
            int timeout = 30)
        {
            return new SqlCommand
            {
                Connection = transaction.Connection,
                Transaction = transaction,
                CommandType = CommandType.Text,
                CommandText = query,
                CommandTimeout = timeout
            };
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tada, it is not too complicated to enable lockout account, right? So what about if you want to unblock account somewhere? I see that we can do that in editing user view in admin mode like that:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/a7efb247c6df4eaa896112d856e63ec9.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;But it seems this function works well if we use Membership Provider for authentication. It does not works if I use Aspnet Identity.&lt;/p&gt;
&lt;p&gt;I found that the episerver is using IsLockedOut to check lockout status and unblock user by changing IsLockedOut to false. But currently Aspnet Identity uses the LockEnable flag and&amp;nbsp;LockoutEndDateUtc to check lockout status. So the solution that I use to unblock user in Aspnet Identity is creating a custom user that inherited from Application and over IsLockedOut property like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;        public override bool IsLockedOut
        {
            get =&amp;gt; LockoutEnabled &amp;amp;&amp;amp; LockoutEndDateUtc != null &amp;amp;&amp;amp; LockoutEndDateUtc &amp;gt;= DateTime.UtcNow;
            set
            {
                if (!LockoutEnabled || value) return;

                if (LockoutEndDateUtc != null &amp;amp;&amp;amp; LockoutEndDateUtc &amp;gt; DateTime.UtcNow)
                {
                    LastLockoutDate = LockoutEndDateUtc = DateTime.UtcNow;
                }
                AccessFailedCount = 0;
            }
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is all. Now you can unblock account in Episerver admin mode as usual.&lt;/p&gt;</id><updated>2020-04-24T02:41:33.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Workaround for supporting multilingual promotion</title><link href="https://world.optimizely.com/blogs/binh-nguyen/dates/2019/4/workaround-for-supporting-multilingual-discount/" /><id>&lt;p&gt;Did you use to be bored with old marketing system? The performance when running the old promotion engine or customizing a promotion used to be a big problem. But since the new marketing system was launched, the hard time has been ended.&lt;/p&gt;
&lt;p&gt;But one good day, you have a customer request that we need to show our promotion name/description in the front view by multiple different languages. For example: there are 2 available languages in our site are English and Sweden.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How to do that?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We are aware that we could not do that with OOTB&#39;s Episerver until now. Although discount data has already been based on IContent but it has some limitations compared to other Episerver content like pages, blocks or catalog contents.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;So is there any way to workaround with this requirement?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The answer here is YES but it is not simple way. Here is my way:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Install package EPiServer.VisitorGroupsCriteriaPack. We will use SelectedLanguage criterion for filtering promotions based on the current language&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Setup 2 visitor groups for English and Sweden&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/670809ee95dd4ce9869b00a1840846a1.aspx&quot; width=&quot;1311&quot; height=&quot;584&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/d74ea300f07f4e928cd8411f84766ce0.aspx&quot; width=&quot;1296&quot; height=&quot;151&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Create 2 different campaigns for these two languages and appropriated promotions in these campaigns&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/172e9b3655d84d31b74d3693bf3f4e7e.aspx&quot; width=&quot;1313&quot; height=&quot;527&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/7be7aa1592d24793ab5132c42b784a19.aspx&quot; width=&quot;1323&quot; height=&quot;541&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Modiy&amp;nbsp;MarketContentLoader class in QuickSilver sample code a bit to see proper promotions based on the current language in the home page: &lt;/strong&gt;Using&amp;nbsp;CampaignVisitorGroupFilter to filter campaign based on visitor group&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; private readonly CampaignVisitorGroupFilter _campaignVisitorGroupFilter;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public virtual IList&amp;lt;PromotionData&amp;gt; GetEvaluablePromotionsInPriorityOrderForMarket(IMarket market)
{
   var result = _campaignVisitorGroupFilter.Filter(
                new PromotionFilterContext(GetPromotions().Where(x =&amp;gt; IsValid(x, market)),
                    RequestFulfillmentStatus.All));
   return result.IncludedPromotions.OrderBy(x =&amp;gt; x.Priority).ToList();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;5. Finally, let&#39;s see the result in the front view&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In English&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/04073edc2e6c47ebaf737a7ddde05837.aspx&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Sweden&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/37e6bcda0e5e4461b97a1a89ac6c68d3.aspx&quot; width=&quot;826&quot; height=&quot;830&quot; /&gt;&lt;/p&gt;</id><updated>2019-04-04T03:51:56.0000000Z</updated><summary type="html">Blog post</summary></entry></feed>