In my current project, I needed to find a way to give content authors the ability to add a custom attribute — let’s call this custom attribute Tag for simplicity– to the “Insert Link” and “Insert External Link” dialogs of the General Link field (NOTE: the following solution does not use the “out of the box” SPEAK dialogs that ship with Sitecore 7.2 and up. This solution uses the older Sheer UI dialogs. Perhaps I will share a solution in the future on how to do the following using the newer SPEAK dialogs).
You might be asking why? Well, let’s imagine that there is some magical JavaScript code that puts a click event on links, and grabs the value of the tag attribute for reporting purposes — perhaps the JavaScript calls a service that captures this information.
In this post, I am going to share how I went about doing this minus the code I needed to add to get this to work in the Glass.Mapper ORM (I’m going to show you this code in my next blog post).
I first built the following custom LinkField class (this class is not used in this solution but will be used in my next blog post where I should how to integrate the functionality below in Glass.Mapper. I’m just setting the stage 😉 ):
using Sitecore.Data.Fields; namespace Sitecore.Sandbox.Data.Fields { public class TagLinkField : LinkField { public TagLinkField(Field innerField) : base(innerField) { } public TagLinkField(Field innerField, string runtimeValue) : base(innerField, runtimeValue) { } public string Tag { get { return GetAttribute("tag"); } set { this.SetAttribute("tag", value); } } } }
The class above subclasses Sitecore.Data.Fields.Link (this lives in Sitecore.Kernel.dll) — this class represents a link in Sitecore — and added a new Tag property (this class will magically parse or save this value into the field’s underlying XML).
Next, I created the following Sheer UI form for a custom “Insert Link” dialog:
using System; using System.Xml; using System.Collections.Specialized; using Sitecore; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Shell.Applications.Dialogs; using Sitecore.Shell.Applications.Dialogs.InternalLink; using Sitecore.Web.UI.HtmlControls; using Sitecore.Web.UI.Sheer; using Sitecore.Xml; namespace Sitecore.Sandbox.Shell.Applications.Dialogs.InternalLink { public class TagInternalLinkForm : InternalLinkForm { private const string TagAttributeName = "tag"; protected Edit Tag; private NameValueCollection customLinkAttributes; protected NameValueCollection CustomLinkAttributes { get { if(customLinkAttributes == null) { customLinkAttributes = new NameValueCollection(); ParseLinkAttributes(GetLink()); } return customLinkAttributes; } } protected override void OnLoad(EventArgs e) { Assert.ArgumentNotNull(e, "e"); base.OnLoad(e); if (Context.ClientPage.IsEvent) { return; } LoadControls(); } protected override void ParseLink(string link) { base.ParseLink(link); ParseLinkAttributes(link); } protected virtual void ParseLinkAttributes(string link) { Assert.ArgumentNotNull(link, "link"); XmlDocument xmlDocument = XmlUtil.LoadXml(link); if (xmlDocument == null) { return; } XmlNode node = xmlDocument.SelectSingleNode("/link"); if (node == null) { return; } CustomLinkAttributes[TagAttributeName] = XmlUtil.GetAttribute(TagAttributeName, node); } protected virtual void LoadControls() { string tagValue = CustomLinkAttributes[TagAttributeName]; if (!string.IsNullOrWhiteSpace(tagValue)) { Tag.Value = tagValue; } } protected override void OnOK(object sender, EventArgs args) { Assert.ArgumentNotNull(sender, "sender"); Assert.ArgumentNotNull(args, "args"); Item selectionItem = Treeview.GetSelectionItem(); if (selectionItem == null) { Context.ClientPage.ClientResponse.Alert("Select an item."); } else { string attributeFromValue = LinkForm.GetLinkTargetAttributeFromValue(this.Target.Value, this.CustomTarget.Value); string queryString = this.Querystring.Value; if (queryString.StartsWith("?", StringComparison.InvariantCulture)) queryString = queryString.Substring(1); Packet packet = new Packet("link", new string[0]); LinkForm.SetAttribute(packet, "text", (Control)Text); LinkForm.SetAttribute(packet, "linktype", "internal"); LinkForm.SetAttribute(packet, "anchor", (Control)Anchor); LinkForm.SetAttribute(packet, "title", (Control)Title); LinkForm.SetAttribute(packet, "class", (Control)Class); LinkForm.SetAttribute(packet, "querystring", queryString); LinkForm.SetAttribute(packet, "target", attributeFromValue); LinkForm.SetAttribute(packet, "id", selectionItem.ID.ToString()); TrimEditControl(Tag); LinkForm.SetAttribute(packet, TagAttributeName, (Control)Tag); Assert.IsTrue(!string.IsNullOrEmpty(selectionItem.ID.ToString()) && ID.IsID(selectionItem.ID.ToString()), "ID doesn't exist."); SheerResponse.SetDialogValue(packet.OuterXml); SheerResponse.CloseWindow(); } } protected virtual void TrimEditControl(Edit control) { if(control == null || string.IsNullOrEmpty(control.Value)) { return; } control.Value = control.Value.Trim(); } } }
The OnLoad method invokes its base class’ OnLoad method — the base class’ OnLoad method loads values from the field’s XML into the Edit controls on the form — and also parses the value from the tag XML attribute and places it into the Tag Edit control.
The ParseLink method above is where values from the field’s XML are extracted — these are extracted from the XML attributes of the field. The ParseLink method delegates to the ParseLinkAttributes method which extracts the value from the tag attribute.
The OnOK method is where values from the Edit controls are extract and passed to a class instance that generates XML for the field. I could not call the base class’ OnOK method since it would prevent me from saving the custom tag attribute and value, so I “borrowed/stole” code from it, and then added my modifications.
I then added new Tag Literal and Edit controls to the “Internal Link” dialog, and also updated the CodeBeside xml element to point to my new class (I copy and pasted this from /sitecore/shell/Applications/Dialogs/InsertLink.InsertLink.xml and put my new file into /sitecore/shell/Override/InternalLink/InsertLink.xml in my website root — always put custom Sheer UI dialogs XML files in /sitecore/shell/Override/ so that you don’t run into issues when upgrading Sitecore):
<?xml version="1.0" encoding="utf-8" ?> <control xmlns:def="Definition" xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense"> <InternalLink> <FormDialog Icon="Network/32x32/link.png" Header="Internal Link" Text="Select the item that you want to create a link to and specify the appropriate properties." OKButton="OK"> <Stylesheet Key="Style"> .ff input { width: 160px; } </Stylesheet> <CodeBeside Type="Sitecore.Sandbox.Shell.Applications.Dialogs.InternalLink.TagInternalLinkForm, Sitecore.Sandbox" /> <DataContext ID="InternalLinkDataContext"/> <GridPanel Columns="2" Width="100%" Height="100%" CellPadding="4" Style="table-layout:fixed"> <Scrollbox Width="100%" Height="100%" Class="scScrollbox scFixSize" Background="window" Padding="0" Border="1px solid #CFCFCF" GridPanel.VAlign="top" GridPanel.Width="100%" GridPanel.Height="100%"> <TreeviewEx ID="Treeview" DataContext="InternalLinkDataContext" MultiSelect="False" Width="100%"/> </Scrollbox> <Scrollbox Width="256" Height="100%" Background="transparent" Border="none" GridPanel.VAlign="top" GridPanel.Width="256"> <GridPanel CellPadding="2" Columns="2"> <Literal Text="Link Description:" GridPanel.NoWrap="true"/> <Edit ID="Text"/> <Literal Text="Anchor:" GridPanel.NoWrap="true"/> <Edit ID="Anchor"/> <Label for="Target" GridPanel.NoWrap="true"><Literal Text="Target Window:"/></Label> <Combobox ID="Target" Width="100%" Change="OnListboxChanged"> <ListItem Value="Self" Header="Active browser"/> <ListItem Value="New" Header="New browser"/> <ListItem Value="Custom" Header="Custom"/> </Combobox> <Panel ID="CustomLabel" Background="transparent" Border="none" GridPanel.NoWrap="true" GridPanel.Align="right"><Label For="CustomTarget"><Literal Text="Custom:" /></Label></Panel> <Edit ID="CustomTarget" /> <Literal Text="Style Class:" GridPanel.NoWrap="true"/> <Edit ID="Class"/> <Literal Text="Alternate Text:" GridPanel.NoWrap="true"/> <Edit ID="Title"/> <Literal Text="Query String:" GridPanel.NoWrap="true"/> <Edit ID="Querystring"/> <Literal Text="Tag:" GridPanel.NoWrap="true"/> <Edit ID="Tag"/> </GridPanel> </Scrollbox> </GridPanel> </FormDialog> </InternalLink> </control>
Likewise, I repeated the steps for the “External Link” dialog’s code-beside class (I’m not going to go into details here since they are the same as the “Insert Link” dialog class above):
using System; using Sitecore; using Sitecore.Diagnostics; using Sitecore.Shell.Applications.Dialogs; using Sitecore.Shell.Applications.Dialogs.ExternalLink; using Sitecore.Web.UI.HtmlControls; using Sitecore.Web.UI.Sheer; using Sitecore.Xml; using System.Collections.Specialized; using System.Xml; namespace Sitecore.Sandbox.Shell.Applications.Dialogs.ExternalLink { public class TagExternalLinkForm : ExternalLinkForm { private const string TagAttributeName = "tag"; protected Edit Tag; private NameValueCollection customLinkAttributes; protected NameValueCollection CustomLinkAttributes { get { if (customLinkAttributes == null) { customLinkAttributes = new NameValueCollection(); ParseLinkAttributes(GetLink()); } return customLinkAttributes; } } protected override void ParseLink(string link) { base.ParseLink(link); ParseLinkAttributes(link); } protected virtual void ParseLinkAttributes(string link) { Assert.ArgumentNotNull(link, "link"); XmlDocument xmlDocument = XmlUtil.LoadXml(link); if (xmlDocument == null) { return; } XmlNode node = xmlDocument.SelectSingleNode("/link"); if (node == null) { return; } CustomLinkAttributes[TagAttributeName] = XmlUtil.GetAttribute(TagAttributeName, node); } protected override void OnLoad(EventArgs e) { Assert.ArgumentNotNull(e, "e"); base.OnLoad(e); if (Context.ClientPage.IsEvent) { return; } LoadControls(); } protected virtual void LoadControls() { string tagValue = CustomLinkAttributes[TagAttributeName]; if (!string.IsNullOrWhiteSpace(tagValue)) { Tag.Value = tagValue; } } protected override void OnOK(object sender, EventArgs args) { Assert.ArgumentNotNull(sender, "sender"); Assert.ArgumentNotNull(args, "args"); string path = GetPath(); string attributeFromValue = LinkForm.GetLinkTargetAttributeFromValue(Target.Value, CustomTarget.Value); Packet packet = new Packet("link", new string[0]); LinkForm.SetAttribute(packet, "text", (Control)Text); LinkForm.SetAttribute(packet, "linktype", "external"); LinkForm.SetAttribute(packet, "url", path); LinkForm.SetAttribute(packet, "anchor", string.Empty); LinkForm.SetAttribute(packet, "title", (Control)Title); LinkForm.SetAttribute(packet, "class", (Control)Class); LinkForm.SetAttribute(packet, "target", attributeFromValue); TrimEditControl(Tag); LinkForm.SetAttribute(packet, TagAttributeName, (Control)Tag); SheerResponse.SetDialogValue(packet.OuterXml); SheerResponse.CloseWindow(); } private string GetPath() { string url = this.Url.Value; if (url.Length > 0 && url.IndexOf("://", StringComparison.InvariantCulture) < 0 && !url.StartsWith("/", StringComparison.InvariantCulture)) { url = string.Concat("http://", url); } return url; } protected virtual void TrimEditControl(Edit control) { if (control == null || string.IsNullOrEmpty(control.Value)) { return; } control.Value = control.Value.Trim(); } } }
I also added a Label and Edit control for the Tag as I did for the “Insert Link” dialog above (the “out of the box” External Link dialog xml file lives in /sitecore/shell/Applications/Dialogs/ExternalLink/ExternalLink.xml of the Sitecore website root. When creating custom one be sure to put it in /sitecore/shell/override of your website root):
<?xml version="1.0" encoding="utf-8" ?> <control xmlns:def="Definition" xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense"> <ExternalLink> <FormDialog Header="Insert External Link" Text="Enter the URL for the external website that you want to insert a link to and specify any additional properties for the link." OKButton="Insert"> <CodeBeside Type="Sitecore.Sandbox.Shell.Applications.Dialogs.ExternalLink.TagExternalLinkForm, Sitecore.Sandbox"/> <GridPanel Class="scFormTable" CellPadding="2" Columns="2" Width="100%"> <Label For="Text" GridPanel.NoWrap="true"> <Literal Text="Link description:"/> </Label> <Edit ID="Text" Width="100%" GridPanel.Width="100%"/> <Label For="Url" GridPanel.NoWrap="true"> <Literal Text="URL:"/> </Label> <Border> <GridPanel Columns="2" Width="100%"> <Edit ID="Url" Width="100%" GridPanel.Width="100%" /> <Button id="Test" Header="Test" Style="margin-left: 10px;" Click="OnTest"/> </GridPanel> </Border> <Label for="Target" GridPanel.NoWrap="true"> <Literal Text="Target window:"/> </Label> <Combobox ID="Target" GridPanel.Width="100%" Width="100%" Change="OnListboxChanged"> <ListItem Value="Self" Header="Active browser"/> <ListItem Value="New" Header="New browser"/> <ListItem Value="Custom" Header="Custom"/> </Combobox> <Panel ID="CustomLabel" Disabled="true" Background="transparent" Border="none" GridPanel.NoWrap="true"> <Label For="CustomTarget"> <Literal Text="Custom:" /> </Label> </Panel> <Edit ID="CustomTarget" Width="100%" Disabled="true"/> <Label For="Class" GridPanel.NoWrap="true"> <Literal Text="Style class:" /> </Label> <Edit ID="Class" Width="100%" /> <Label for="Title" GridPanel.NoWrap="true"> <Literal Text="Alternate text:"/> </Label> <Edit ID="Title" Width="100%" /> <Label for="Tag" GridPanel.NoWrap="true"> <Literal Text="Tag:"/> </Label> <Edit ID="Tag" Width="100%" /> </GridPanel> </FormDialog> </ExternalLink> </control>
Since the “out of the box” “External Link” dialog isn’t tall enough for the new Tag Label and Edit controls — I had no quick way of changing this since the height of the dialog is hard-coded in Sitecore.Shell.Applications.ContentEditor.Link in Sitecore.Client — I decided to create a new Content Editor field for the General Link field — this is further down in this post — which grabs the Url of the dialog and dimensions from a custom pipeline I built (the dimensions live in the patch configuration file that is found later on in this post). This custom pipeline uses the following PipelineArgs class:
using Sitecore.Collections; using Sitecore.Pipelines; namespace Sitecore.Sandbox.Pipelines.DialogInfo { public class DialogInfoArgs : PipelineArgs { public string Message { get; set; } public string Url { get; set; } public SafeDictionary<string, string> Parameters { get; set; } public DialogInfoArgs() { Parameters = new SafeDictionary<string, string>(); } public bool HasInformation() { return !string.IsNullOrWhiteSpace(Url); } } }
Each dialog defined in a pipeline processor of this custom pipeline will specify the dialog’s Url; it’s message — this is how the code ascertains which dialog to load; and any properties of the dialog (e.g. height).
I then built the following class that serves as a processor for this custom pipeline:
using System; using System.Xml; using Sitecore.Collections; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Pipelines.DialogInfo { public class SetDialogInfo { protected virtual string ParameterNameAttributeName { get; private set; } protected virtual string ParameterValueAttributeName { get; private set; } protected virtual string Message { get; private set; } protected virtual string Url { get; private set; } protected virtual SafeDictionary<string, string> Parameters { get; private set; } public SetDialogInfo() { Parameters = new SafeDictionary<string, string>(); } public void Process(DialogInfoArgs args) { Assert.ArgumentNotNull(args, "args"); if(!CanProcess(args)) { return; } SetDialogInformation(args); } protected virtual bool CanProcess(DialogInfoArgs args) { return !string.IsNullOrWhiteSpace(Message) && !string.IsNullOrWhiteSpace(Url) && args != null && !string.IsNullOrWhiteSpace(args.Message) && string.Equals(args.Message, Message, StringComparison.CurrentCultureIgnoreCase); } protected virtual void SetDialogInformation(DialogInfoArgs args) { args.Url = Url; args.Parameters = Parameters; } protected virtual void AddParameter(XmlNode node) { Assert.ArgumentNotNullOrEmpty(ParameterNameAttributeName, "ParameterNameAttributeName"); Assert.ArgumentNotNullOrEmpty(ParameterValueAttributeName, "ParameterValueAttributeName"); if (node == null || !IsAttributeSet(node.Attributes[ParameterNameAttributeName]) || !IsAttributeSet(node.Attributes[ParameterValueAttributeName])) { return; } Parameters[node.Attributes[ParameterNameAttributeName].Value] = node.Attributes[ParameterValueAttributeName].Value; } protected bool IsAttributeSet(XmlAttribute attribute) { return attribute != null && !string.IsNullOrEmpty(attribute.Value); } } }
The Sitecore Configuration Factory injects the dialog’s url, message and parameters into the class instance.
The CanProcess method determines if there is match with the message that is sent via the DialogInfoArgs instance passed to the processor’s Process method. If there is a match, the Url and dialog parameters are set on the DialogInfoArgs instance.
If there isn’t a match, the processor just exits and does nothing.
I then built the following class to serve as a custom Sitecore.Shell.Applications.ContentEditor.Link:
using System; using System.Collections.Specialized; using Sitecore.Collections; using Sitecore.Diagnostics; using Sitecore.Pipelines; using Sitecore.Shell.Applications.ContentEditor; using Sitecore.Web.UI.Sheer; using Sitecore.Sandbox.Pipelines.DialogInfo; namespace Sitecore.Sandbox.Shell.Applications.ContentEditor { public class TagLink : Link { public override void HandleMessage(Message message) { Assert.ArgumentNotNull(message, "message"); if (message["id"] != ID) { return; } DialogInfoArgs info = GetDialogInformation(message.Name); if (info.HasInformation()) { Insert(info.Url, ToNameValueCollection(info.Parameters)); return; } base.HandleMessage(message); } protected virtual DialogInfoArgs GetDialogInformation(string message) { Assert.ArgumentNotNullOrEmpty(message, "message"); DialogInfoArgs args = new DialogInfoArgs { Message = message }; CorePipeline.Run("dialogInfo", args); return args; } protected virtual NameValueCollection ToNameValueCollection(SafeDictionary<string, string> dictionary) { if(dictionary == null) { return new NameValueCollection(); } NameValueCollection collection = new NameValueCollection(); foreach(string key in dictionary.Keys) { collection.Add(key, dictionary[key]); } return collection; } } }
The HandleMessage method above passes the message name to the custom <dialogInfo> pipeline and gets back a DialogInfoArgs instance with the dialog’s Url and parameters if there is a match. If there is no match, then the HandleMessage method delegates to its base class’ HandleMessage method (there are dialog Urls and Parameters baked in it).
Now we need to let Sitecore know about the above Content Editor class. We do so like this:
Now that the Content Editor bits are in place, we need some code to render the tag on the front-end of the website. I do this in the following class which serves as a custom <renderField> pipeline processor:
using System.Xml; using Sitecore.Pipelines.RenderField; using Sitecore.Xml; namespace Sitecore.Sandbox.Pipelines.RenderField { public class SetTagAttributeOnLink { private string TagXmlAttributeName { get; set; } private string TagAttributeName { get; set; } private string BeginningHtml { get; set; } public void Process(RenderFieldArgs args) { if (!CanProcess(args)) { return; } args.Result.FirstPart = AddTagAttributeValue(args.Result.FirstPart, TagAttributeName, GetXmlAttributeValue(args.FieldValue, TagXmlAttributeName)); } protected virtual bool CanProcess(RenderFieldArgs args) { return !string.IsNullOrWhiteSpace(TagAttributeName) && !string.IsNullOrWhiteSpace(BeginningHtml) && !string.IsNullOrWhiteSpace(TagXmlAttributeName) && args != null && args.Result != null && HasXmlAttributeValue(args.FieldValue, TagAttributeName) && !string.IsNullOrWhiteSpace(args.Result.FirstPart) && args.Result.FirstPart.ToLower().StartsWith(BeginningHtml.ToLower()); } protected virtual bool HasXmlAttributeValue(string linkXml, string attributeName) { return !string.IsNullOrWhiteSpace(GetXmlAttributeValue(linkXml, attributeName)); } protected virtual string GetXmlAttributeValue(string linkXml, string attributeName) { XmlDocument xmlDocument = XmlUtil.LoadXml(linkXml); if(xmlDocument == null) { return string.Empty; } XmlNode node = xmlDocument.SelectSingleNode("/link"); if (node == null) { return string.Empty; } return XmlUtil.GetAttribute(TagAttributeName, node); } protected virtual string AddTagAttributeValue(string html, string attributeName, string attributeValue) { if(string.IsNullOrWhiteSpace(html) || string.IsNullOrWhiteSpace(attributeName) || string.IsNullOrWhiteSpace(attributeValue)) { return html; } int index = html.LastIndexOf(">"); if (index < 0) { return html; } string firstPart = html.Substring(0, index); string attribute = string.Format(" {0}=\"{1}\"", attributeName, attributeValue); string lastPart = html.Substring(index); return string.Concat(firstPart, attribute, lastPart); } } }
The Process method above delegates to the CanProcess method which determines if the generated HTML by the previous <renderField> pipeline processors should be manipulated — the code should only run it the generated HTML is a link and only when there is a tag attribute set on the field.
If the HTML should be manipulated, we basically add the tag attribute with its value it to the generated link HTML — this is done in the AddTagAttributeValue method.
I then wired everything together via the following patch configuration file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <controlSources> <source mode="on" namespace="Sitecore.Sandbox.Shell.Applications.ContentEditor" assembly="Sitecore.Sandbox" prefix="sandbox-content"/> </controlSources> <fieldTypes> <fieldType name="General Link"> <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute> </fieldType> <fieldType name="General Link with Search"> <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute> </fieldType> <fieldType name="link"> <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute> </fieldType> </fieldTypes> <pipelines> <dialogInfo> <processor type="Sitecore.Sandbox.Pipelines.DialogInfo.SetDialogInfo, Sitecore.Sandbox"> <ParameterNameAttributeName>name</ParameterNameAttributeName> <ParameterValueAttributeName>value</ParameterValueAttributeName> <Message>contentlink:externallink</Message> <Url>/sitecore/shell/Applications/Dialogs/External link.aspx</Url> <parameters hint="raw:AddParameter"> <parameter name="height" value="300" /> </parameters> </processor> </dialogInfo> <renderField> <processor patch:after="processor[@type='Sitecore.Pipelines.RenderField.GetInternalLinkFieldValue, Sitecore.Kernel']" type="Sitecore.Sandbox.Pipelines.RenderField.SetTagAttributeOnLink, Sitecore.Sandbox"> <TagXmlAttributeName>tag</TagXmlAttributeName> <TagAttributeName>tag</TagAttributeName> <BeginningHtml><a </BeginningHtml> </processor> </renderField> </pipelines> </sitecore> </configuration>
Let’s try this out!
For testing I added two General Link fields to the Sample Item template (/sitecore/templates/Sample/Sample Item in the master database):
I also had to add two Link field controls to the sample rendering.xslt that ships with Sitecore:
Let’s test the “Insert Link” dialog:
After clicking the “OK” button and saving the Item, I looked at the “Raw values” on the field and saw that the tag was added to the field’s xml:
Let’s see if this works on the “Insert External Link” dialog:
After clicking the “OK” button and saving the Item, I looked at the “Raw values” on the field and saw that the tag was added to the field’s xml:
After publishing everything, I navigated to my home page and looked at its rendered HTML. As you can see, the tag attributes were added to the links:
If you have any comments or thoughts on this, please drop a comment.
Until next time, keep on Sitecoring!
Great idea. Wish this was built into the product though!
[…] Add a Custom Attribute to the General Link Field in Sitecore […]
[…] Add a Custom Attribute to the General Link Field in Sitecore […]
[…] Add a Custom Attribute to the General Link Field in Sitecore […]
Any hint around how to achieve it in sitecore 8.0 with SPEAK UI