Creating one to many relationships in dynamic modules

February 27, 2012 Digital Experience

(With Sitefinity version 5.1 and above there is a simple way to relate dynamic modules and here is a blog post that summarizes the process)

With the Sitefinity 5.0 release we are introducing two new field types for the Module Builder - Guid and Array of Guids. This blog post is going to focus on the latter and sample the process of creating a field control which allows you to associate one item from a dynamic module with other items (from the same module or other dynamic  modules). This field control relies on KnockoutJS and the KnockoutJS mapping plugin to bind and manage the related items in the write mode of our field control.

Creating the field control

Instead of creating a field control from scratch we are going to use the sample field control outlined here: Extend the Image Selector for Content Items with Filtering by Album, and remove the unnecessary logic for our case. We need to implement the field control class, template, its definition and configuration elements and script component.

Field control template

The field control template is going to include content selector which will pop up in a window to select related items and a unordered list which will display selected items and let us manipulate the collection. KnockoutJS is going to automatically handle binding of the list and removing items:

...       
<sf:ConditionalTemplate Left="DisplayMode" Operator="Equal" Right="Write" runat="server">
            <sf:SitefinityLabel ID="titleLabel_write" runat="server" CssClass="sfTxtLbl" />
            <asp:LinkButton ID="expandButton_write" runat="server" OnClientClick="return false;" CssClass="sfOptionalExpander" />
            <asp:Panel ID="expandableTarget_write" runat="server" CssClass="sfFieldWrp">
                <div div id="selectorTag" style="display: none;" class="sfDesignerSelector sfFlatDialogSelector">
                   <designers:ContentSelector
                       ID="selector"
                       runat="server"
                       TitleText="Choose items"
                       BindOnLoad="false"
                       WorkMode="List"
                       SearchBoxInnerText=""
                       SearchBoxTitleText="<%$Resources:Labels, NarrowByTypingTitleOrAuthorOrDate %>"
                       ListModeClientTemplate="<strong class='sfItemTitle'>{{Title}}</strong><span class='sfDate'>{{PublicationDate ? PublicationDate.sitefinityLocaleFormat('dd MMM yyyy') : ""}} by {{Author}}</span>">
                   </designers:ContentSelector>
                </div>
 
                <ul id="selectedItemsList" data-bind="foreach: items">
                    <li>
                        <span data-bind="text: Title"> </span>
                        <a href="#" class="remove">Remove</a>
                    </li>
                </ul>
 
                <asp:TextBox ID="textBox_write" runat="server" Style="display:none" CssClass="sfTxt" />
                <asp:HyperLink ID="selectLink" runat="server" NavigateUrl="javascript:void(0);" CssClass="sfLinkBtn sfChange">
                    <strong class="sfLinkBtnIn">Select...</strong>
                </asp:HyperLink>
                <sf:SitefinityLabel id="descriptionLabel_write" runat="server" WrapperTagName="div" HideIfNoText="true" CssClass="sfDescription" />
                <sf:SitefinityLabel id="exampleLabel_write" runat="server" WrapperTagName="div" HideIfNoText="true" CssClass="sfExample" />
            </asp:Panel>
        </sf:ConditionalTemplate>
...

Field control class

The field control class needs to include a reference to the script component and the KnockoutJS libraries. It will also pass as a script script descriptor the items selector, the button which opens it and the path to the dynamic content data service. Excerpt from the class bellow:

#region Overridden Methods
 
protected override void InitializeControls(GenericContainer container)
{
    this.ConstructControl();
    this.ItemsSelector.ServiceUrl = string.Concat("~/Sitefinity/Services/DynamicModules/Data.svc/");
    //set item type for the items we want to select from
    this.ItemsSelector.ItemType = "Telerik.Sitefinity.DynamicTypes.Model.Releasenotes.ReleaseNote";
    this.ItemsSelector.AllowMultipleSelection = true;
}
 
#endregion
 
#region IScriptControl Members
 
/// <summary>
/// Gets the script references.
/// </summary>
/// <returns></returns>
public override IEnumerable<ScriptReference> GetScriptReferences()
{
    var baseReferences = new List<ScriptReference>(base.GetScriptReferences());
    var componentRef = new ScriptReference(relatedItemsFieldScript, this.GetType().Assembly.FullName);
    //add references to knockoutJS and knockoutJS mapping plugin
    var knockOutRef = new ScriptReference(knockOutScript, this.GetType().Assembly.FullName);
    var knockOutMappingRef = new ScriptReference(knockOutMappingScript, this.GetType().Assembly.FullName);
    var jqueryUIScript = new ScriptReference("Telerik.Sitefinity.Resources.Scripts.jquery-ui-1.8.8.custom.min.js", "Telerik.Sitefinity.Resources");
    baseReferences.Add(componentRef);
    baseReferences.Add(knockOutRef);
    baseReferences.Add(knockOutMappingRef);
    baseReferences.Add(jqueryUIScript);
    return baseReferences;
}
 
/// <summary>
/// Gets the script descriptors.
/// </summary>
/// <returns></returns>
public override IEnumerable<ScriptDescriptor> GetScriptDescriptors()
{
    var lastDescriptor = (ScriptControlDescriptor)base.GetScriptDescriptors().Last();
 
    if (this.DisplayMode == FieldDisplayMode.Write)
    {
        lastDescriptor.AddElementProperty("selectLink", this.SelectLink.ClientID);
        lastDescriptor.AddComponentProperty("itemsSelector", this.ItemsSelector.ClientID);
        lastDescriptor.AddProperty("dynamicModulesDataServicePath", RouteHelper.ResolveUrl(dynamicModulesDataServicePath, UrlResolveOptions.Rooted));
    }
    if (this.DisplayMode == FieldDisplayMode.Read)
    {
        lastDescriptor.AddElementProperty("imageControl", this.ImageControl.ClientID);
    }
 
    yield return lastDescriptor;
}
 
#endregion

Field control client component

This is where all the actual logic is placed. First we need to implement the get_value and set_value methods required for all field controls. Those methods are called by Sitefinity to populate the field control and get the values for persisting data set in the field control. 

// Gets the value of the field control.
 
get_value: function () {
    //on publish if we have items in the knockout data context
    //we get their ids in a aray of Guids so that they can be persisted
    var selectedKeysArray = new Array();
    var data = ko.mapping.toJS(this._selectedItems);
    for (var i = 0; i < data.length; i++) {
        selectedKeysArray.push(data[i].Id);
    }
    if (selectedKeysArray.length > 0)
        return selectedKeysArray;
    else
        return null;
},
 
// Sets the value of the text field control depending on DisplayMode.
set_value: function (value) {
    if (this._hideIfValue != null && this._hideIfValue == value) {
    }
    else {
        //if there are related items get them through the dynamic modules' data service
        if (value != null && value != "") {
            var filterExpression = "";
            for (var i = 0; i < value.length; i++) {
                if (i == 0)
                    filterExpression = filterExpression + 'Id == ' + value[i].toString();
                else
                    filterExpression = filterExpression + ' OR Id == ' + value[i].toString();
            }
            var data = {
                "itemType": "Telerik.Sitefinity.DynamicTypes.Model.Releasenotes.ReleaseNote",
                "filter": filterExpression
            };
            $.ajax({
                url: this.get_dynamicModulesDataServicePath(),
                type: "GET",
                dataType: "json",
                data: data,
                contentType: "application/json; charset=utf-8",
                //on success add them to the knockout data context
                success: this._getSelectedItemsSuccesssDelegate
            });
        }
    }
    this.raisePropertyChanged("value");
    this._valueChangedHandler();
},
 
_getSelectedItemsSuccesss: function (result) {
    //push existing related items in the knockout data context
    ko.mapping.fromJS(result.Items, this._selectedItems);
},

We also need one event handler that will be called when the dialog for selecting items closes to push them in the Knockout data context and one event handler which is raised when the remove link for a respective item is clicked.

_doneLinkClicked: function (keys) {
    if (keys != null) {
        //push newly selected items in the list of selected items
        var data = ko.mapping.toJS(this._selectedItems);
        var selectedItems = this.get_itemsSelector().getSelectedItems();
        for (var i = 0; i < selectedItems.length; i++) {
                data.push(selectedItems[i]);
            }
        //reapply the data context
        ko.mapping.fromJS(data, this._selectedItems);
    }
    this._selectDialog.dialog("close");
},
_removeItem: function (value) {
    var context = ko.contextFor(value.currentTarget);
    this._selectedItems.remove(context.$data);
},

Installation Instructions

  1. Download the source code and open it.
  2. Extract the Telerik.Sitefinity.Samples folder from the archive to a location of your choice. 
  3. Open your web project in Visual Studio
  4. Add an existing project to the solution, and locate the Telerik.Sitefinity.Samples from the extracted folder.
  5. Resolve the broken assembly references by pointing them to the bin folder of your web project.
  6. Include a project reference to the Telerik.Sitefinity.Samples project in your web project.
  7. Run your project and follow set up instructions from the bellow video.

Note: The field control is using the Virtual Path provider to resolve its template. You need to register a VP as bellow:

<virtualPaths>
    ...
    <add resourceLocation="Telerik.Sitefinity.Samples" resolverName="EmbeddedResourceResolver" virtualPath="~/SfSamples/*" />
</virtualPaths>

The Progress Team