Custom Dialogs im Rich Text Editor

Sitecore bietet Dank des RadEditors von Telerik standardmässig einen mächtigen Rich Text Editor. Durch die starke Vernetzung mit Sitecore kann dieser sehr einfach erweitert werden. So können beispielsweise in der Core-Datenbank neue Rich Text Profile erstellt werden, welche die Möglichkeit bieten, dem Autor im Editor komplett unterschiedliche Buttons zur Verfügung zu stellen. Ebenfalls können auf einfache Weise Snippets erstellt werden, die das Einfügen von statischem HTML-Code in den Editor per Klick ermöglichen. Dies funktioniert bei wenig komplexen Strukturen, wie Tabellen und Listen, ziemlich gut. Bei komplexeren Strukturen, wie beispielsweise einem Youtube-Iframe, kommt diese Technik jedoch an ihre Grenzen, da der Benutzer hier gezwungen ist, die Youtube-ID manuell in den Code einzufügen. Für Autoren ohne HTML-Kenntnisse ist dies oftmals mit Problemen verbunden. Es wäre daher sehr hilfreich, wenn der Autor die Möglichkeit hat, konfigurierbare Werte zunächst in ein Formular einzutragen und der richtige HTML-Code dann per Knopfdruck erzeugt wird. Für die Lösung dieses Problems bietet Sitecore ebenfalls eine Möglichkeit, die jedoch etwas Programmieraufwand erfordert. Der im Folgenden beschriebene Custom Dialog erzeugt genau den oben angesprochenen Youtube-Iframe.

Schritt 1:

Als erstes muss ein neuer Html Editor Button im gewünschten Rich Text Editor Profil erzeugt werden. Dies kann in der Core-Datenbank unter dem Pfad \sitecore\system\settings\Html Editor Profiles\My Rich Text Profile\Toolbar X bewerkstelligt werden.

Schritt 2:

Nun muss das User Interface des Dialogs erstellt werden. Dies wird mittels XML realisiert und im Ordner /sitecore/shell/Controls/Rich Text Editor/InsertYoutubeVideo erstellt. Im SDN ist mehr zu diesem Thema beschrieben.

<?xml version="1.0" encoding="utf-8" ?>

<control xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense">
    <RichText.InsertYoutubeVideo>

        <FormDialog Icon="Multimedia/32x32/film_clip.png" Header="Insert a youtube video"
     Text="You can insert a youtube video as an iframe." OKButton="Insert Video">
           
            <script Type="text/javascript" Language="javascript" Src="Controls/Rich Text Editor/InsertYoutubeVideo/InsertYoutubeVideo.js">.</script>

            <CodeBeside Type="Sitecore.CustomCode.InsertYoutubeVideoDialog,Sitecore.CustomCode"/>

            <GridPanel Width="100%" Columns="2">
                <Border Width="100%" GridPanel.Width="20%" Padding="0px 4px 0px 0px">
                    <Literal Text="YouTube ID:"/>
                </Border>
                <Edit ID="YoutubeId" Width="95%"/>

                <Border Width="100%" GridPanel.Width="20%" Padding="0px 4px 0px 0px">
                    <Literal Text="Video Width:"/>
                </Border>
                <Edit ID="VideoWidth" Width="95%"/>

                <Border Width="100%" GridPanel.Width="20%" Padding="0px 4px 0px 0px">
                    <Literal Text="Video Height:"/>
                </Border>
                <Edit ID="VideoHeight" Width="95%"/>
            </GridPanel>

        </FormDialog>

    </RichText.InsertYoutubeVideo>
</control>

Schritt 3:

Der nächste Schritt ist die Erstellung des Code-Behind für das Dialog Control.

// --------------------------------------------------------------------------------------------------------------------
// <copyright file="InsertYoutubeVideoDialog.cs" company="Namics AG">
//   (c) Namics AG
// </copyright>
// <summary>
//   Dialog form to insert a youtube video.
// </summary>
// --------------------------------------------------------------------------------------------------------------------

namespace Sitecore.CustomCode
{
    using System;

    using Sitecore;
    using Sitecore.Web;
    using Sitecore.Web.UI.Pages;
    using Sitecore.Web.UI.Sheer;

    /// <summary>
    /// Dialog form to insert a youtube video.
    /// </summary>
    public class InsertYoutubeVideoDialog : DialogForm
    {
        /// <summary>
        /// The youtube id.
        /// </summary>
        protected Sitecore.Web.UI.HtmlControls.Edit YoutubeId;

        /// <summary>
        /// The video width.
        /// </summary>
        protected Sitecore.Web.UI.HtmlControls.Edit VideoWidth;

        /// <summary>
        /// The video height.
        /// </summary>
        protected Sitecore.Web.UI.HtmlControls.Edit VideoHeight;

        /// <summary>
        /// Gets or sets the mode.
        /// </summary>
        protected string Mode
        {
            get
            {
                string modeString = StringUtil.GetString(ServerProperties["Mode"]);
                if (!string.IsNullOrEmpty(modeString))
                {
                    return modeString;
                }

                return "shell";
            }

            set
            {
                ServerProperties["Mode"] = value;
            }
        }

        /// <summary>
        /// The on load.
        /// </summary>
        /// <param name="pE">
        /// The e.
        /// </param>
        protected override void OnLoad(EventArgs pE)
        {
            base.OnLoad(pE);
            if (Context.ClientPage.IsEvent)
            {
                return;
            }

            Mode = WebUtil.GetQueryString("mo");
            YoutubeId.Value = WebUtil.GetQueryString("id");
            VideoWidth.Value = WebUtil.GetQueryString("width");
            VideoHeight.Value = WebUtil.GetQueryString("height");
        }

        /// <summary>
        /// The ok event.
        /// </summary>
        /// <param name="pSender">
        /// The sender.
        /// </param>
        /// <param name="pArgs">
        /// The args.
        /// </param>
        protected override void OnOK(object pSender, EventArgs pArgs)
        {
            if (string.IsNullOrEmpty(YoutubeId.Value) || string.IsNullOrEmpty(VideoWidth.Value) || string.IsNullOrEmpty(VideoHeight.Value))
            {
                SheerResponse.Alert("Please enter values for the fields YouTube ID, Video Width and Video Height.");
                return;
            }
           
            string outputYoutubeId = StringUtil.EscapeJavascriptString(YoutubeId.Value, true);
            string outputVideoWidth = StringUtil.EscapeJavascriptString(VideoWidth.Value, true);
            string outputVideoHeight = StringUtil.EscapeJavascriptString(VideoHeight.Value, true);
            if (Mode == "webedit")
            {
                SheerResponse.SetDialogValue(outputYoutubeId);
                base.OnOK(pSender, pArgs);
                return;
            }

            SheerResponse.Eval("scClose(" + outputYoutubeId + ", " + outputVideoWidth + ", " + outputVideoHeight + ")");
        }

        /// <summary>
        /// The cancel event.
        /// </summary>
        /// <param name="pSender">
        /// The sender.
        /// </param>
        /// <param name="pArgs">
        /// The args.
        /// </param>
        protected override void OnCancel(object pSender, EventArgs pArgs)
        {
            if (Mode == "webedit")
            {
                base.OnCancel(pSender, pArgs);
                return;
            }

            SheerResponse.Eval("scCancel()");
        }
    }
}

Schritt 4:

Nun wird der Javascript-Controller erstellt, der clientseitig für die Abarbeitung der Click-Events zuständig ist. Dieser muss im bereits erstellten XML-Control referenziert werden. Diese Javascript-Datei wird im selben Ordner angelegt wie die XML-Datei.

function GetDialogArguments() {
    return getRadWindow().ClientParameters;
}

function getRadWindow() {
  if (window.radWindow) {
        return window.radWindow;
  }
   
    if (window.frameElement && window.frameElement.radWindow) {
        return window.frameElement.radWindow;
    }
   
    return null;
}

var isRadWindow = true;

var radWindow = getRadWindow();

if (radWindow) {
  if (window.dialogArguments) {
    radWindow.Window = window;
  }
}

function scClose(youtubeId, videoWidth, videoHeight) {
    var returnValue = {
        youtubeId: youtubeId,
        videoWidth: videoWidth,
        videoHeight: videoHeight
    };

    getRadWindow().close(returnValue);
}

function scCancel() {
  getRadWindow().close();
}

function scCloseWebEdit(url) {
  window.returnValue = url;
  window.close();
}

if (window.focus && Prototype.Browser.Gecko) {
  window.focus();
}

Schritt 5:

Zu guter Letzt wird der erstellte Button in die Command-Liste des RadEditors eingetragen. Diese befindet sich in der Datei /sitecore/shell/Controls/Rich Text Editor/RichText Commands.js

RadEditorCommandList["InsertYoutubeVideo"] = function (commandName, editor, tool) {
    var html = editor.getSelectionHtml();
   
    var attributes = GetYoutubeVideoAttributes(html);

    scEditor = editor;

    editor.showExternalDialog(
            "/sitecore/shell/default.aspx?xmlcontrol=RichText.InsertYoutubeVideo&id=" + attributes.youtubeId + "&width=" + attributes.videoWidth + "&height=" + attributes.videoHeight,
            null, //argument
            500,
            400,
            scInsertYoutubeVideoDialog,
            null,
            "Insert youtube video");
};


// CallBack function for InsertYoutubeVideo window
function scInsertYoutubeVideoDialog(sender, returnValue) {
    if (!returnValue) {
        return;
    }
   
    var htmlCode = "<iframe width="" + returnValue.videoWidth + "" height="" + returnValue.videoHeight + "" src="http://www.youtube.com/embed/" + returnValue.youtubeId + "" frameborder="0" allowfullscreen="true"></iframe>";

    scEditor.pasteHtml(htmlCode, "DocumentManager");
}

GetYoutubeVideoAttributes = function (html) {
    var returnValue = {
        youtubeId: "",
        videoWidth: "",
        videoHeight: ""
    };
   
    var pattern = "<iframe width="(.*)" height="(.*)" src="http://www.youtube.com/embed/(.*)" frameborder="0" allowfullscreen="true"></iframe>";
    var regex = new RegExp(pattern, 'm');
    var match = regex.exec(html);
    if (match && match.length >= 1) {
        if (match[1]) {
            returnValue.videoWidth = match[1];
        }
        if (match[2]) {
            returnValue.videoHeight = match[2];
        }
        if (match[3]) {
            returnValue.youtubeId = match[3];
        }
    }

    return returnValue;
}

Resultat:

Der erstellte Dialog bietet den Autoren eine komfortable Möglichkeit, Youtube-Videos als Iframe in einen Text einzufügen. Wird ein eingefügtes Video selektiert und erneut der Button betätigt, so sind die zuvor eingetragenen Werte im Dialog bereits automatisch eingetragen.

Type Safe Programming mit Sitecore

Das Ablegen der strukturierten Inhalte in Sitecore erfolgt in vordefinierten Templates. Die Items speichern die nach dem zugeordneten Template strukturierten Informationen ab, welche anschliessend für das Rendern der Webseite verwendet werden können.

Problematik

Im Sitecore Backend wird diese Struktur/Templates für das Editieren der Informationen herangezogen. Dem Programmierer bleibt die Struktur der Templates während der Design Time (Entwicklung) immer verwehrt. Erst zur Runtime sind diese Informationen verfügbar. Dies führt dazu, dass beim kompilieren keine Validierung des Zugriffes auf die Daten erfolgen kann, was z.B. beim Umbenennen oder Typenändern eines Feldes nicht sofort zu einer Exception führt. Weiter muss der Entwickler immer den jeweiligen Feldnamen und Typen kennen/nachschlagen.

Lösung

Extrahieren der gesamten Sitecore Template Struktur zur Entwicklungszeit ins Visual Studio. Dabei gibt es unterschiedliche Varianten wie dies erfolgen kann:

Methode Beschreibung Vorteile Nachteile
dynamic Mit .Net 3.0 wurden dynamische Objekte eingeführt die erst zur Runtime bestimmt werden.
  • sehr einfache Implementation
  • Kein Intellisense zur Design Time
  • Keine Validierung zum Kompilierzeitpunkt
F# Typeprovider F# bietet die Möglichkeit beim Kompilieren dynamisch statische Typen aus externen definitionen zu erstellen
  • F# Klassen lassen sich nahtlos in C# verwenden
  • Voller Funktionsumfang
  • Automatisches Update bei Änderungen
  • Intellisense nur in F# vorhanden
T4 Templates Mittels T4 Templates werden zur Design Time statische Klassen generiert, welche den Zugriff auf die Sitecore Items stark typisiert wrappen
  • Intellisense
  • Validierung zum Kompilierzeitpunkt
  • Manuelles Anstossen des Generiervorganges bei Änderungen (könnte durch PreBuild Action erfolgen)
Project Roslyn Spracherweiterung für C# welche zur Design und Runtime den Zugriff auf die Item Definition in Sitecore sicherstellt
  • Optimale Integration
  • Änderungen im Sitecore sind sofort erkennbar
  • LINQ Funktionalität
  • Sehr komplex
Wir haben uns für die T4 Template Lösung entschieden, da alle aktuellen Codegerneratoren im Visualstudio darauf basieren und es sich um eine bewährte Technologie handelt (Microsoft Entity Framework, SOAP, usw). Um den Zugriff auf Sitecore stark zu kapseln wurden für alle Standardfeldtypen aus Sitecore Helperklassen erstellt die z.B. den Zugriff auf die Url direkt über ein Property möglich machen.

Sitecore Template

Generiertes Interface

Datenzugriff (Razor)

Durch die weitere Integration von Razor als Render Engine kann nun stark typisiert sauberer Render-Code erstellt werden und gänzlich auf den Stringbuilder und dergleichen verzichtet werden.

Durch den Aufbau dieser beiden Verbesserungen konnte das Entwickeln/Debuggen mit Sitecore massiv verbessert und beschleunigt werden.

Dynamische Sitemaps

Eine XML-Sitemap ist eine klassische Datei, welche nahezu jede Seite besitzen soll. Da wir unter der Haube ein modernes CMS haben, wollen wir hierbei natürlich auch eine automatisch generierte Sitemap-XML haben. Unsere Sitemap soll jedoch nicht blind, alle Content-Items in ihren Index packen, sondern auch je nach Fall entscheiden können, diese draussen zu lassen.

Damit dies in Sitecore möglich ist, erstellen wir hierfür einen gewöhnlichen ASPX-Handler, welcher sich gegen aussen als www.mywebsite.ch/sitemap.xml outet.

  1. Wir erzeugen also in unserer Solution einen Handler (Add new item…) und geben ihm den Namen sitemapxml_handler.ashx
  2. Wir erstellen eine für uns optimale XML-Sitemap. Hier ein mögliches Beispiel:
    // --------------------------------------------------------------------------------------------------------------------
    // <copyright file="SitemapXmlHandler.cs" company="Namics AG">
    //   (c) Namics AG
    // </copyright>
    // <summary>
    //   The sitemap xml handler.
    // </summary>
    // --------------------------------------------------------------------------------------------------------------------

    namespace Namics.Samples.WebApp.Services
    {
        using System;
        using System.Web;
        using System.Xml;
        using Namics.Framework.Sitecore.Core.Utils;

        using Sitecore.Configuration;
        using Sitecore.Data;
        using Sitecore.Data.Items;
        using Sitecore.Diagnostics;
        using Sitecore.Links;

        /// <summary>
        /// The sitemap xml handler.
        /// </summary>
        public class SitemapXmlHandler : IHttpHandler
        {
            #region Public Properties

            /// <summary>
            /// Gets a value indicating whether IsReusable.
            /// </summary>
            public bool IsReusable
            {
                get
                {
                    return true;
                }
            }

            #endregion

            #region Public Methods and Operators

            /// <summary>
            /// The process request.
            /// </summary>
            /// <param name="pContext">
            /// The context.
            /// </param>
            public void ProcessRequest(HttpContext pContext)
            {
                try
                {
                    pContext.Response.ContentType = "text/xml";
                    pContext.Response.StatusCode = 200;
                   
                    XmlDocument xmlDoc = new XmlDocument();
                    XmlDeclaration xmlDeclaration = xmlDoc.CreateXmlDeclaration("1.0", "utf-8", null);

                    XmlElement rootNode = xmlDoc.CreateElement("urlset");
                    rootNode.SetAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
                    xmlDoc.InsertBefore(xmlDeclaration, xmlDoc.DocumentElement);
                    xmlDoc.AppendChild(rootNode);

                    string databaseName = Settings.GetSetting("Sitemap.Database");
                    string startItemPath = Settings.GetSetting("Sitemap.StartItemPath");
                    Database database = Factory.GetDatabase(databaseName);
                    UrlOptions urlOptions = UrlOptions.DefaultOptions;
                    urlOptions.AlwaysIncludeServerUrl = true;

                    Item item = database.GetItem(startItemPath);
                    AddUrlEntry(item, xmlDoc, rootNode, urlOptions);
                   
                    pContext.Response.Write(xmlDoc.OuterXml);
                }
                catch (Exception ex)
                {
                    Log.Error("Error at sitemap xml handler.", ex, this);
                    pContext.Response.StatusCode = 500;
                }
            }

            #endregion

            #region Methods

            /// <summary>
            /// The add url entry.
            /// </summary>
            /// <param name="pItem">
            /// The item.
            /// </param>
            /// <param name="pXmlDoc">
            /// The xml doc.
            /// </param>
            /// <param name="pRootNode">
            /// The root node.
            /// </param>
            /// <param name="pUrlOptions">
            /// The url options.
            /// </param>
            private void AddUrlEntry(
                Item pItem, XmlDocument pXmlDoc, XmlElement pRootNode, UrlOptions pUrlOptions)
            {
                if (pItem != null && pItem.Visualization != null && pItem.Visualization.Layout != null && pItem.Name != "*")
                {
                    if (!pItem.Fields.Contains(FieldNames.GetFieldId(FieldNames.EXCLUDEFROMSITEMAP))
                        || pItem.Fields[FieldNames.EXCLUDEFROMSITEMAP].Value != "1")
                    {
                        string url = LinkManager.GetItemUrl(pItem, pUrlOptions);
                        GenerateUrlEntry(pXmlDoc, pRootNode, url);
                    }
                }

                if (pItem != null)
                {
                    foreach (Item childItem in pItem.Children)
                    {
                        AddUrlEntry(childItem, pXmlDoc, pRootNode, pUrlOptions);
                    }
                }
            }

            /// <summary>
            /// The generate url entry.
            /// </summary>
            /// <param name="pXmlDoc">
            /// The xml doc.
            /// </param>
            /// <param name="pRootNode">
            /// The root node.
            /// </param>
            /// <param name="pUrl">
            /// The url.
            /// </param>
            private void GenerateUrlEntry(XmlDocument pXmlDoc, XmlElement pRootNode, string pUrl)
            {
                XmlElement sitemapNode = pXmlDoc.CreateElement("url");
                pRootNode.AppendChild(sitemapNode);

                XmlElement locationNode = pXmlDoc.CreateElement("loc");
                locationNode.AppendChild(pXmlDoc.CreateTextNode(pUrl));
                sitemapNode.AppendChild(locationNode);

                XmlElement changeFrequencyNode = pXmlDoc.CreateElement("changefreq");
                changeFrequencyNode.AppendChild(pXmlDoc.CreateTextNode("weekly"));
                sitemapNode.AppendChild(changeFrequencyNode);
            }

            #endregion
        }
    }
  3. Wir registrieren unseren Handler in der Web.Config unter der Rubrik Handlers:
    <add verb="*" path="sitemapxml_handler.ashx" type="Namics.Samples.WebApp.Services.SitemapXmlHandler, Namics.Samples.WebApp" name="Namics.SitemapXmlHandler" />
  4. Wir registrieren unseren Handler in Sitecore (ebenfalls Web.Config) unter der Rubrik „CustomHandlers“ und geben ihm unseren gewünschten Namen:
    <handler trigger="/sitemap.xml" handler="sitemapxml_handler.ashx" />
  5. Wie im C-Sharp Code ersichtlich, prüfen wir noch, ob ein Item aus der Sitemap excluded werden soll. Hierfür erstellen wir auf dem entsprechenden Page-Template ein Checkbox-Field, welches „Excluded from Sitemap“ lautet und demzufolge für die Steuerung zuständig ist.

Als Resultat haben wir eine vollfunktionsfähige sitemap.xml auf unserer WebPage.

Urwald der Konfigurationen

Wie bereits in unserem Blog-Eintrag Sitecore Web.Config aufteilen beschrieben, ist es möglich die Konfiguration der Sitecore Solution in logisch-trennbare Dateien zu splitten. Dies verbessert zum einen den Überblick massiv und vereinfacht das Austauschen von einzelnen Konfigurations-Bereiche.

Neben der eigentlichen Web.Config und deren Bestandteile, besitzt Sitecore jedoch eine grosse Menge an weiteren Konfigurationsdateien. Im App_Config Verzeichnis verstecken sich nur schon in einer leeren Sitecore-Solution viele weitere Konfigurationsdateien. Zu all dem kann man als Entwickler relativ einfach und schnell, weitere Config’s erzeugen, welche es später dem Administrator der Webseite erlaubt – im vordefinierten Rahmen – die Lösung an die gewünschten Bedürfnisse anzupassen.

Nun ist es jedoch so, dass wir selten direkt auf der schlussendlichen Zielplattform entwickeln und bei jedem Speichern/Kompilieren dies direkt live haben wollen. So entstehen pro Plattform verschiedene Konfigurationen. Als Entwickler möchte ich beispielsweise alle Debug-Informationen im Log, ein visuelles Tracing auf der Dev-Webseite oder eine andere Ansicht für gewisse Informationen haben, welche spätestens in der Live-Webseite nicht mehr erscheinen sollten. Je nach Kritikalität des Projektes gibt es da noch Testing-, Quality-, Content-Plattformen dazwischen. All diese Umgebungen benötigen unter umständen verschiedene Einstellungen.

Damit dies nicht zu einem unübersichtlichem Urwald führt und bei jedem Deployment darauf geachtet werden muss, welche Configs überschrieben, angepasst oder gelöscht werden müssen, haben wir einen kleinen, simplen Skript dazu geschrieben:

 

@ECHO off

REM Title:          Set Environment Configuration

REM Description:    Recursively replaces all master files with the specific environment
REM                 files for a given suffix, starting at the defined path or the default
REM                 path if none given. The format for each environment file has to be as
REM                 follows: myfilename.config_SUFFIX, ex. sitecore.config_Q
REM Parameters:     %1 Environment Suffix, ex. Q (required)
REM                 %2 Start Path, ex. "C:\My Directory" (optional)
REM Example:        set_env_config.bat Q "C:\My Directory"

SETLOCAL ENABLEDELAYEDEXPANSION

ECHO.

REM Error handling - check parameters

IF (%1)==() (
    ECHO Parameter environment suffix missing, ex. Q
    GOTO END
)

IF NOT (%2)==() (
    IF NOT EXIST "%~2" (
        ECHO Specified path not found, check if the directory exists and make sure to use quotes, ex. "C:\My Directory"
        GOTO END
    )
)

REM Looks recursively for files following the pattern *.config_SUFFIX, starting at the

REM specified path or the default path if none given
FOR /r %2 %%f IN (*.config_*%1*) DO (
    REM environment path (with filename) and file
    SET env_path=%%f
    SET env_file=%%~nxf
    REM replace _SUFFIX in specific environment file to get the original filename
    SET orig_path=!env_path:_%1=%!
    REM copy and override the env file with the original one without confirmation
    xcopy "!env_path!" "!orig_path!" /Y
    REM check for errors after copy
    IF errorlevel 0 (
        ECHO -- Successfully copied file !env_file! to !orig_path!
    ) ELSE (
        ECHO -- Error while copying file !env_file! to !orig_path!
    )
    ECHO.
)

:END
REM PAUSE
REM EXIT

 

Dieses Script ersetzt also alle Config-Dateien mit einem bestimmten Dateinamen-Suffix.

So kann ich beispielsweise für die ConnectionsStrings, pro Umgebung eine Konfiguration vorbereiten:

  • ConnectionString.config_DEV
  • ConnectionString.config_TEST
  • ConnectionString.config_QUALITY
  • ConnectionString.config_LIVE

Im Deployment-Prozess kann ich dann lediglich das Batchfile ausführen & meine Umgebung ist bereits richtig konfiguriert:

D:\inetpup\myWebsite\SetEnvironmentConfig.bat QUALITY

 

Dies durchsucht nun alle Config-Dateien (ab Batch-Standort & Subfolder) und ersetzt alle bestehenden Konfigurationen mit den Quality-Einstellungen.