Sitefinity offers default widgets for all modules which support setting a specific datasource and configuring standard paging. However, more and more web applications present their data using the so called infinite scroll paging – where we use load on demand when the user scrolls to the near end of the data container, causing it to grow with additional content. In this blog post I will show how we can achieve this in Sitefinity, providing search and sorting functionality, as well. For a base we will be using the RadListView with an extender providing the scrolling event functionality described here, from which we will inherit and implement a custom control for Sitefinity front-end. I will be using dynamic module items for datasource with related data field, since this can easily be modified to work with the built in modules.
Here is the main module we will be using:
Here is the related one:
Widget Initialization
I will start with the server-side logic. We will implement our widget on the base of a SimpleView, or an empty widget created using Sitefinity Thunder. Since, we will be using client-side binding on the client, we will need to implement a service which would be called to retrieve the data. We will implement a method in the widget, which will query the necessary data and register it as a service, so we could request it client-side.
namespace
SitefinityWebApp.InfiniteScrollWidget
{
[ServiceContract]
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Required)]
public
class
InfiniteScrollWidget : SimpleView
{
[OperationContract]
[WebInvoke(Method =
"POST"
, RequestFormat = WebMessageFormat.Json, ResponseFormat = WebMessageFormat.Json, BodyStyle = WebMessageBodyStyle.Wrapped)]
public
DataResult GetData(
int
startIndex,
string
search, SortModel sort)
{
}
}
}
We define the widget class as a Service using the ServiceContract attribute and an operation which is the method called. Defined are several parameters, the http request method, the request response and request formats. We will use POST, since we will send the sort, search and page data, for format – JSON. The implementation of the service ends up with registering it in the Global.asax file of our web application:
static
void
BootstrapperInitialized(
object
sender, ExecutedEventArgs e)
{
if
(e.CommandName ==
"Bootstrapped"
)
{
SystemManager.RegisterWebService(
typeof
(SitefinityWebApp.InfiniteScrollWidget.InfiniteScrollWidget),
"Public/InfiniteScrollService.svc"
);
}
}
Querying the data
I have built the query in steps depending on the input parameters received. The PageIndex is the page number, the search query is a simple string and the sorting is an object holding the field and the sort order (Ascending, Descending):
namespace
SitefinityWebApp.InfiniteScrollWidget
{
public
class
SortModel
{
public
string
Field {
get
;
set
; }
public
SortOrder SortOrder {
get
;
set
; }
}
}
namespace
SitefinityWebApp.InfiniteScrollWidget
{
public
enum
SortOrder
{
Ascending,
Descending
}
}
The base query is all live items from that dynamic type. Remember to leave all base queries in IQueryable , so the database call will be made at the end when the full query is constructed. When working with dynamic content items collections always use the GetValue<T> method, so the query could be translated by OpenAccess to SQL one. The main idea is to get all items with one call to the database and only these columns (item’s properties) we need.
Part of the method for sample querying:
public
DataResult GetData(
int
startIndex,
string
search, SortModel sort)
{
startIndex = startIndex * PageSize;
var providerName = String.Empty;
DynamicModuleManager dynamicModuleManager = DynamicModuleManager.GetManager(providerName);
Type brandType = TypeResolutionService.ResolveType(
"Telerik.Sitefinity.DynamicTypes.Model.Brands.Brand"
);
var baseQuery = dynamicModuleManager.GetDataItems(brandType)
.Where(d => d.Status == Telerik.Sitefinity.GenericContent.Model.ContentLifecycleStatus.Live);
if
(!
string
.IsNullOrWhiteSpace(search))
{
// Search in title by default
var baseSearchQuery = baseQuery.Where(c => c.GetValue<
string
>(
"Title"
).StartsWith(search));
// If Search, return the searched items count
var count = baseSearchQuery.Count();
if
(sort !=
null
&& !
string
.IsNullOrWhiteSpace(sort.Field))
{
if
(sort.SortOrder == SortOrder.Ascending)
{
if
(sort.Field ==
"Established"
)
{
return
DateTimeSortAscending(startIndex, sort, baseSearchQuery, count);
}
else
{
return
StringSortAscending(startIndex, sort, baseSearchQuery, count);
}
}
}
}
Using the base query, we filter the collection further and complete the request with a select:
private
static
DataResult DateTimeSortAscending(
int
startIndex, SortModel sort, IQueryable<Telerik.Sitefinity.DynamicModules.Model.DynamicContent> baseSearchQuery,
int
count = 0)
{
var data = baseSearchQuery.OrderBy(c => c.GetValue<DateTime?>(sort.Field))
.Skip(startIndex).Take(PageSize)
.Select(BrandsItemModel.Convert);
return
new
DataResult() { Data = data, Count = count };
}
private
static
DataResult StringSortAscending(
int
startIndex, SortModel sort, IQueryable<Telerik.Sitefinity.DynamicModules.Model.DynamicContent> baseSearchQuery,
int
count = 0)
{
var data = baseSearchQuery.OrderBy(c => c.GetValue<
string
>(sort.Field))
.Skip(startIndex).Take(PageSize)
.Select(BrandsItemModel.Convert);
return
new
DataResult() { Data = data, Count = count };
}
I have made a model of my dynamic item and implemented a method for transferring DynamicContent to the model class, which is used in the above logic for querying the items from the database:
namespace
SitefinityWebApp.InfiniteScrollWidget
{
[DataContract]
public
class
BrandsItemModel
{
[DataMember]
public
DateTime Established {
get
;
set
; }
[DataMember]
public
string
Title {
get
;
set
; }
[DataMember]
public
string
OriginCountry {
get
;
set
; }
public
DynamicContent OriginCountryItem {
get
;
set
; }
[DataMember]
public
Guid Id {
get
;
set
; }
public
static
Func<DynamicContent, BrandsItemModel> Convert
{
get
{
Func<DynamicContent, BrandsItemModel> convertMethod = ConvertToModel;
return
convertMethod;
}
}
private
static
BrandsItemModel ConvertToModel(DynamicContent n)
{
var model =
new
BrandsItemModel()
{
Id = n.Id,
Title = n.GetValue<Lstring>(
"Title"
),
Established = n.GetValue<DateTime>(
"Established"
),
OriginCountryItem = n.GetValue<DynamicContent>(
"CountryOrigin"
)
};
if
(model.OriginCountryItem !=
null
)
{
model.OriginCountry = model.OriginCountryItem.GetValue<Lstring>(
"Title"
);
}
return
model;
}
}
}
The Database query made, with one selection we get page size number of items:
The Infinite Scroll ListView
As I said in the beginning, we will derive from the RadListViewExtender, which implements the logic for handling the scrolling and binding the data after that. However, I have extent it to provide support for searching and sorting. Implementing the widget, we will also have the opportunity to add more controls to the descriptor and manipulate them client-side as well.
The infinite scroll extender class, do not forget to include all scripts and resources from the base, as well as all controls necessary for the client widget:
namespace
SitefinityWebApp.InfiniteScrollWidget
{
[TargetControlType(
typeof
(RadListView))]
public
class
CustomExtender : RadListViewClientBindingExtender
{
// add controls properties
protected
override
IEnumerable<ScriptDescriptor> GetScriptDescriptors(Control targetControl)
{
ScriptBehaviorDescriptor descriptor =
new
ScriptBehaviorDescriptor(
"SitefinityWebApp.InfiniteScrollWidget.CustomExtender"
, targetControl.ClientID);
descriptor.AddProperty(
"_clientItemTemplate"
, HtmlEncode(ClientItemTemplate.Trim()));
DataBindingSettings.DescribeClientProperties(descriptor);
// Add necessary controls, from the custom ascx of the widget
return
new
ScriptDescriptor[] { descriptor };
}
protected
override
IEnumerable<System.Web.UI.ScriptReference> GetScriptReferences()
{
ScriptReference reference =
new
ScriptReference();
reference.Path =
"CustomExtender.js"
;
ScriptReference reference1 =
new
ScriptReference();
reference1.Path =
"RadListViewClientBindingExtender.js"
;
return
new
ScriptReference[] { reference1, reference };
}
}
}
Here is the widget full template with the controls for reference, we define our extender as the RadListView extender. For the client binding is used jQuery template:
<
telerik:RadListView
runat
=
"server"
ID
=
"RadListView1"
AllowCustomPaging
=
"true"
>
<
LayoutTemplate
>
<
div
class
=
"RadListView RadListViewFloated RadListView_Windows7"
>
<
div
class
=
"rlvFloated"
id
=
"MyContainer1"
>
<
div
id
=
"itemPlaceHolder"
runat
=
"server"
>
</
div
>
</
div
>
</
div
>
</
LayoutTemplate
>
<
ItemTemplate
>
<
div
class
=
"rlvI"
>
<
ul
>
<
li
><
i
>Title</
i
>: <
b
>
<%#DataBinder.Eval(Container.DataItem, "Title")%></
b
> </
li
>
<
li
><
i
>Established</
i
>: <
b
>
<%#DataBinder.Eval(Container.DataItem, "Established")%></
b
> </
li
>
<
li
><
i
>Origin Country</
i
>: <
b
>
<%#DataBinder.Eval(Container.DataItem, "OriginCountry")%></
b
> </
li
>
</
ul
>
</
div
>
</
ItemTemplate
>
</
telerik:RadListView
>
<
telerik:CustomExtender
runat
=
"server"
ID
=
"RadListViewClientBindingExtender1"
TargetControlID
=
"RadListView1"
>
<
ClientItemTemplate
>
<
div
class
=
"rlvI"
>
<
ul
>
<
li
><
i
>Title</
i
>: <
b
>
{%=Title%}</
b
> </
li
>
<
li
><
i
>Established</
i
>: <
b
>
{%=Established%}</
b
> </
li
>
<
li
><
i
>Origin Country</
i
>: <
b
>
{%=OriginCountry%}</
b
> </
li
>
</
ul
>
</
div
>
</
ClientItemTemplate
>
<
DataBindingSettings
ClientItemContainerID
=
"MyContainer1"
LocationUrl
=
"/Public/InfiniteScrollService.svc"
MethodName
=
"GetData"
/>
</
telerik:CustomExtender
>
The important part is the client-side of the widget, which do most of the work. Remember to declare the right namespace and all descriptor controls’ getters and setters.
Handling the scroll event and decide whether or not to load more data:
_handleScroll:
function
() {
// if scroller is dragged to the bottom we request for more data
var
container =
this
.get_itemContainer()[0];
var
visibleHeight = (container.scrollHeight - container.offsetHeight);
if
(
this
.get_itemContainer().scrollTop() >= (container.scrollHeight - container.offsetHeight)) {
this
._loadData();
}
},
The sort and search handlers just set the entered input in the widget properties and call the loadData function, as well.
get_searchValue:
function
() {
if
(
this
._searchTextBox)
return
this
._searchTextBox.value;
var
textbox =
this
.get_searchTextBox();
this
._searchTextBox = textbox;
return
this
._searchTextBox.value;
},
_handleSearch:
function
() {
this
._search =
this
.get_searchValue();
// when searching restart the page counters and noMoreData flag
this
._currentPageIndex = -1;
this
._noMoreData =
false
;
// set search message
this
._isSearch =
true
;
this
.get_searchMessage().innerHTML =
"Searching for: "
+
this
._search;
try
{
var
container =
this
.get_itemContainer();
container.html(
""
);
this
._loadData();
}
catch
(e) {
alert(e);
}
},
_handleSort:
function
() {
var
checked =
this
.get_sortOrderCheckBox().checked;
var
sortField =
this
.get_sortTextBox().value;
// Enumeration Descending value is 1
if
(checked) {
this
._sort = {
"Field"
: sortField,
"SortOrder"
: 1 };
}
else
{
this
._sort = {
"Field"
: sortField,
"SortOrder"
: 0 };
}
this
._currentPageIndex = -1;
this
._noMoreData =
false
;
try
{
var
container =
this
.get_itemContainer();
container.html(
""
);
this
._loadData();
}
catch
(e) {
alert(e);
}
},
The loadData function reads all properties and construct the http request to the server, remember that we are using JSON as request format:
_loadData:
function
() {
if
(
this
._isNullOrEmpty(
this
._locationUrl) ||
this
._isNullOrEmpty(
this
._methodName) ||
this
._noMoreData)
return
;
var
url = String.format(
"{0}/{1}"
,
this
._locationUrl,
this
._methodName);
var
data = {
"startIndex"
: ++
this
._currentPageIndex,
"search"
:
this
._search,
"sort"
:
this
._sort };
// serialize Data to JSON
var
dataToPost = Sys.Serialization.JavaScriptSerializer.serialize(data);
// execute the request
try
{
$telerik.$.ajax({
type:
"POST"
,
url: url,
contentType:
"application/json"
,
dataType:
"json"
,
data: dataToPost,
success: Function.createDelegate(
this
,
this
._bindContainer),
error:
function
(error) { alert(error); }
});
}
catch
(e) {
throw
new
Error(e);
}
},
If the request is successful, the result is processed and the listview bound with the additional data. Keep in mind the result JSON will be wrapped in an object named after the result method name plus Result and will contain the JSON result:
{
"GetDataResult"
:{
"Count"
:8,
"Data"
:[]}}
_bindContainer:
function
(result) {
if
(result) {
var
resultObject = result.GetDataResult;
if
(resultObject) {
var
dataObject = resultObject.Data;
var
count = resultObject.Count;
if
(dataObject) {
var
data = dataObject;
if
(data && data.length > 0) {
var
container =
this
.get_itemContainer();
for
(
var
i = 0, len = data.length; i < len; i++) {
var
dataItem = data[i];
var
itemHtml =
this
._constructTemplate(dataItem);
jQuery(container).append(
"clientItemTemplate"
, dataItem);
}
}
else
{
this
._noMoreData =
true
;
var
container =
this
.get_itemContainer();
var
noDataElement = $(
"<div>No More Data</div>"
);
noDataElement.css(
'width'
,
'100%'
);
noDataElement.css(
'clear'
,
'both'
);
jQuery(container).append(noDataElement);
}
}
if
(count &&
this
._isSearch) {
this
._isSearch =
false
;
this
.get_searchMessage().innerHTML +=
" Count: "
+ count;
}
}
}
else
{
var
container =
this
.get_itemContainer();
jQuery(container).append(
"<div style='width:100%; clear:both;'>No Data</div>"
);
}
},
Here is a video demonstration of the sample:
The full source code could be downloaded InfiniteScrollWidget.
Nikola Zagorchev
Nikola Zagorchev is a Tech Support Engineer at Telerik. He joined the Sitefinity Support team in March 2014.