Showing posts with label .Net/c#. Show all posts
Showing posts with label .Net/c#. Show all posts

Wednesday, December 01, 2010

Deploy SharePoint Designer 2010 Reusable Workflow As *.WSP File

SharePoint Designer 2010 makes workflow development really fast and simple. Much simpler than using Visual Studio. But unlike Visual Studio this tool has some limitations from developer's point of view.

This is due to SPD was created as a tool for end (SharePoint) users, but not for solution developers. As a result we have one serious limitation that prevents developers to use this tool: it doesn't allow deploy created workflows to another SharePoint servers. Which means you cannot develop and test workflows on a development SharePoint server and then move it to production. You forced to develop in production, which is not right.

In particular what I said is true for reusable workflows that work with custom list instances. The problem is once you reference some list in workflow, SPD will link workflow template (*.xoml) to this list using its ListId attribute which is unique identifier that is valid for that particular site. This ListId is a random value that SharePoint generates when the list deployed. Note that you deploy list instances not only when staging ready solution from development to production, but also repeatedly during development cycle.

There are two ways you can notice your workflow corrupted. The first is in SPD you will see GUIDs instead of list names, and if you click these GUIDs you'll see unbinded dialogs:




And if you deploy such workflow to another SharePoint site (with prior export to *.WSP) you will get the error like this (in SharePoint logs) when try to run it:

SOAP exception: System.Runtime.InteropServices.COMException (0x82000006): List does not exist. The page you selected contains a list that does not exist. It may have been deleted by another user.
at Microsoft.SharePoint.SoapServer.SPBaseImpl.GetSPListByTitle(SPWeb spWeb, String strListName)
at Microsoft.SharePoint.SoapServer.SPBaseImpl.GetSPList(SPWeb spWeb, String strListName, Boolean bGetMetaData, Boolean bGetSecurityData)
at Microsoft.SharePoint.SoapServer.SPBaseImpl.GetSPList(String strListName, Boolean bGetMetaData, Boolean bGetSecurityData)
at Microsoft.SharePoint.SoapServer.ListSchemaImpl.GetList(String strListName)
at Microsoft.SharePoint.SoapServer.ListSchemaValidatorImpl.GetList(String strListName)
at Microsoft.SharePoint.SoapServer.Lists.GetList(String listName)


The most common approach you may find on the Internet is to export SPD reusable workflow to *.WSP solution package, import that *.WSP to Visual Studio and continue development there. I don't like this approach for two reasons: first is once you do this you can't open that workflow in SharePoint designer again to made any changes (and you don't want to change it in Visual Studio because, like every auto-generated code, SPD generated *.xoml is not very human-friendly):


And the second (which I'm not sure, though)---you can't deploy such workflow as a sandboxed solution (correct me if I wrong).

Fortunately, there is nothing that prevents us from deploying SPD reusable workflows except ListIds. All we need to do is replace broken Ids with the new ones. Here's how you may do this:

  1. Export reusable workflows to *.WSP files using SPD 2010
  2. To fix ListIds change contents of the process*.xoml file contained in *.WSP file (which is *.CAB file that contain (inter alia) Feature.xml)
    To extract and package contents of *.WSP I recommend to use PowerShell + built-in expand command and WSPBuilder's CabLib.dll accordingly
  3. *.xoml is an regular text/xml file so we can simply find and replace GUID strings
  4. We know what GUIDs to be replaced by examining contents of the *.xoml file.
    Look for entries like this:
    <ns1:LookupActivity ListId="{}{909E9DFD-A30B-4E28-BF2E-5BA47095967D}"  
    x:Name="ID10" FieldName="ID" LookupFunction="LookupInt"
    __Context="{ActivityBind ROOT,Path=__context}"
    ListItem="{ActivityBind ID11,Path=ReturnValue}" />

  5. We know the replacement for old GUIDs by using PowerShell automation: get SPWeb object of site where we want to deploy the workflow, get list in that web by list title, get ID of that list and convert that ID to string. Note that GUID string should be in upper case, otherwize you won't be able to edit workflow in SPD, though it will run okay on site (thats probably SPD bug)
  6. After replacing GUIDs we create new *.WSP with relevant ListIds which may be deployed to SharePoint


Below is sample PowerShell script that you can use as a reference to implement steps described above. To run it save contents to file, say Deploy-Workflows.ps1, change values of $siteUrl, $wspDir and $listIds to match your environment. You will also have to place CabLib.dll and AnjLab-SharePoint.ps1 files to the same folder as Deploy-Workflows.ps1. After that open SharePoint 2010 Management Shell, CD to directory with Deploy-Workflows.ps1 and run the script with command .\Deploy-Workflows.ps1.

# Allow running *.ps1 scripts from network shares 
# Set-ExecutionPolicy Unrestricted

# Copy CabLib.dll to user's temp (to prevent security exceptions if your project files are on network share)
# Note: $cablibFullName is a global variable used in AnjLab-SharePoint.ps1
$cablibFullName = "$env:TEMP\CabLib.dll"
if ((test-path $cablibFullName) -eq $false)
{
cp "CabLib.dll" $env:TEMP
}

# Import AnjLab-SharePoint functions
. .\AnjLab-SharePoint.ps1

# Replace with yours

$siteUrl = "http://dev-en/gls/" # SharePoint site to deploy *.WSP workflows to
$wspDir = "bin\Debug\Workflows" # Directory with *.WSP files
# (all files from this folder will be updated and deployed)
$wspTempDir = "$wspDir\temp" # Temp directory
$wspFinalDir = "$wspDir\final" # Directory where final *.WSP files will be placed

###################################################################################################
# Define mapping for GUID replacement in the hashtable below.
# All GUIDs that match keys from this hashtable will be replaced with corresponding GUIDs of lists
# taken from $siteUrl by specified list titles.
###################################################################################################

$listIds = @{ # ListId in workflow's *.WSP List Title on SharePoint Site
# ------------------------------------- -----------------------------
"909E9DFD-A30B-4E28-BF2E-5BA47095967D" = "Consumers";
"2F311150-7360-45DA-A4B1-C64339F3B931" = "Warehouses";
"435E8D1B-FC3F-42A9-B761-1958A31D9BDE" = "Leads";
}

# Replace List Ids

$wspFiles = (Get-ChildItem "$wspDir\*.wsp")
Update-WspListIds $siteUrl $wspFiles $wspTempDir $wspFinalDir $listIds

# Deploy Packages

$wspFiles = (Get-ChildItem "$wspFinalDir\*.wsp")
Deploy-Wsp $siteUrl $wspFiles $wspTempDir

Write-Host "Done"

Tuesday, September 14, 2010

Add Interactivity to ASP.NET ReportViewer

In one of our projects I had a task to add interactivity to charts located on (MS Report Server) reports that were viewed in ASP.NET web application using ReportViewer control.

Charts I worked with contained date series and user should had have the ability to add notes to data in series by clicking on that data on chart.

In the final solution I used jQuery to find images (charts) in rendered report HTML that had special value encoded in image's alt tag. Client javascript (which was on the same page as ReportViewer control) made AJAX requests to server to calibrate chart axes. Then calibration result was used on client side to build HTML MAP with AREAs to which jQuery onclick handlers were attached.

Here's how working example looked in browser:



Client side implementation was written in pure jQuery with help of jQuery qTip plugin and takes around 450 lines of code. Nothing interesting.

The most interesting part in this approach was getting chart image from generated report on server side.

To get image on server side I used the same mechanism that ReportViewer web control uses. I must to say that I couldn't do this without Red Gate's .Net Reflector (I used free version, it was enough here). This is really helpful tool that makes (nearly all) .Net libraries open sourced. I highly recommend it.

Browser obtains chart image by URL, so I had to pass that URL to server side to grab that image from there.

            function calibrate($image) {
$.ajax({
type: "POST",
contentType: "application/json; charset=utf-8",
url: options.calibrateAxesUrl,
data: "{imageUrl: '" + $image.attr("src") + "'}",
dataType: "json",
success: function(msg) {
calibrationResult = msg.d;

// process calibration result...
}
});
}


As you see I used image's src tag to get image URL and invoked web method CalibrateAxes (which is a public static method with [WebMethod] annotation) of my ASP.NET page (the full URL looks like '<%= ResolveClientUrl("~/Reporting/ReportViewer.aspx") + "/CalibrateAxes" %>').

CalibrateAxes method gets chart image as bitmap and does some bitmap analysis to form the result:


        [WebMethod]
public static CalibrationResult CalibrateAxes(string imageUrl)
{
byte[] image = RenderImage(imageUrl);

using (var stream = new MemoryStream(image))
using (var bmp = new Bitmap(stream))
{
var result = new CalibrationResult();

// analyze bitmap...

return result;
}
}

private static byte[] RenderImage(string imageUrl)
{
var imageUri = new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority) + imageUrl);

var parameters = HttpUtility.ParseQueryString(imageUri.Query);

var reportSession = parameters["ReportSession"];
var controlID = parameters["ControlID"];
var culture = parameters["Culture"];
var uiCulture = parameters["UICulture"];
var reportStack = parameters["ReportStack"];
var streamID = parameters["StreamID"];

var rs = new ReportExecutionService
{
Url = ConfigurationManager.AppSettings["ReportExecutionServiceUrl"],
Credentials = new NetworkCredential(
ConfigurationManager.AppSettings["ReportServerUserName"],
ConfigurationManager.AppSettings["ReportServerUserPassword"],
ConfigurationManager.AppSettings["ReportServerUserDomain"]),
ExecutionHeaderValue = new ExecutionHeader {ExecutionID = reportSession}
};

string deviceInfo = GetDeviceInfo(reportSession, controlID, culture, uiCulture, reportStack);

string encoding;
string mimetype;

return rs.RenderStream("HTML4.0", streamID, deviceInfo, out encoding, out mimetype);
}

private static string GetDeviceInfo(string reportSession, string controlID, string culture, string uiCulture, string reportStack)
{
var writer = new StringWriter();
var xmlWriter = new XmlTextWriter(writer);
xmlWriter.WriteStartElement("DeviceInfo");
var url = CreateUrl(reportSession, controlID, culture, uiCulture, reportStack);
xmlWriter.WriteElementString("StreamRoot", url);
xmlWriter.WriteEndElement();
return writer.ToString();
}

private static string CreateUrl(string reportSession,
string controlID,
string culture,
string uiCulture,
string reportStack)
{
var uriBuilder = new UriBuilder(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority));
var applicationPath = HttpContext.Current.Request.ApplicationPath;
if (!applicationPath.EndsWith("/", true, CultureInfo.InvariantCulture))
{
applicationPath = applicationPath + "/";
}
applicationPath = applicationPath + "Reserved.ReportViewerWebControl.axd";
applicationPath = HttpContext.Current.Response.ApplyAppPathModifier(applicationPath);
uriBuilder.Path = applicationPath;

var builder = new StringBuilder();
builder.AppendFormat("{0}={1}", "ReportSession", reportSession);
builder.AppendFormat("&{0}={1}", "ControlID", HttpUtility.UrlEncode(controlID));
builder.AppendFormat("&{0}={1}", "Culture", culture);
builder.AppendFormat("&{0}={1}", "UICulture", uiCulture);
builder.AppendFormat("&{0}={1}", "ReportStack", reportStack);
builder.Append("&OpType=ReportImage&StreamID=");
uriBuilder.Query = builder.ToString();

return uriBuilder.Uri.PathAndQuery;
}


This code of getting rendered image from report turned to be very generic, so you may also use it in your web applications.

Wednesday, April 28, 2010

AnjLab.FX Scheduler for ASP.NET

If you need simple yet easy configurable scheduler in your ASP.NET application, AnjLab.FX Scheduler might be your choise.

To use AnjLab.FX Scheduler you need to do 3 simple steps:


  1. Get the latest version of AnjLab.FX from github, make a build and add AnjLab.FX.dll as a reference to your project;

  2. Implement AnjLab.FX.Sys.ICommand interface on your task workers, like this:

    public class HelloWorldTask : ICommand
    {
    private static readonly log4net.ILog Log = log4net.LogManager.GetLogger(typeof(HelloWorldTask));

    public void Execute()
    {
    Log.Info("Hello World!");
    }
    }

  3. Configure tasks schedule in web.config. To do this you need to:

    1. add the following line to web.config/configuration/configSections:

      <?xml version="1.0" encoding="UTF-8"?>
      <configuration>
      <configSections>
      ...
      <section name="triggers" type="AnjLab.FX.Tasks.Scheduling.SchedulerConfigSection, AnjLab.FX"/>
      ...

    2. define triggers section:

      <configuration>
      ...
      <triggers>
      <!--
      <daily tag='restoreDB' timeOfDay='23:00'/>
      <weekly tag='backupDB' timeOfDay='01:30' weekDays='monday,friday'/>
      <hourly tag='delTempFiles' minutes='30'/>
      <interval tag='dumpLog' interval='00:05:00'/>
      <once tag='upgradeDB' dateTime='01/15/2007 23:00'/>
      <monthly tag='archiveDB' monthDay='29' timeOfDay='23:00'/>
      -->
      <interval tag='helloworld-task' interval='00:00:10'/>
      </triggers>
      ...
      </configuration>

      Here we defined named trigger "helloworld-task" to be triggered every 10 seconds.


  4. Map trigger names to your task workers and start up the scheduler.

    To map your task workers you create instance of KeyedFactory and register your tasks. We propose you do this in Global.asax Application_Start method:


    protected void Application_Start(object sender, EventArgs e)
    {
    // Map trigger names to task workers

    var factory = new KeyedFactory<string, ICommand>();
    factory.RegisterType<HelloWorldTask>("helloworld-task");

    // Start up scheduler

    var scheduler = new Scheduler<ICommand>(factory);

    var triggers = (List<ITrigger>)ConfigurationManager.GetSection("triggers");

    scheduler.RegisterTriggers(triggers.ToArray());

    scheduler.Start();
    }


Thats it!

Resources:


P.S.
By the way, you can also use this API to schedule your tasks in Windows.Forms applications as well.

P.P.S.
AnjLab.FX is a framework we built during development of our projects. Its continue evolving and you can use it in your applications without any restrictions.

Saturday, November 28, 2009

Best Practice: Implementing Telerik RadGrid with Linq(ToSQL)

Telerik RadGrid is a very powerful component that allows you to view, edit, order and filter data.

I'd like to share my experience using this component with linq to sql.

Using this component is very straightforward unless your entity contains references to other entities (in other words if your table have foreign keys to other tables, see DB diagram below). Very often such relations have a kind of reference data.



In this case you may meet the following difficulties:

  1. ability to display data field of referenced entity (except its ID, of course) in grid column;
  2. ability to sort and filter data set of your entities by columns which are references to other entities.


To understand the cause of these difficulties lets look at simple example.

Generated Linq to SQL model for the database schema above will look like the following:



Now, to display a list of Books in RadGrid you should implement databinding. To do this you usually subscribe to RadGrid's OnNeedDataSource event and invoke RadGrid.DataBind() in PageLoad event handler:


protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
RadGrid1.DataBind();
}
}

protected void RadGrid1_NeedDataSource(object source, GridNeedDataSourceEventArgs e)
{
if (!e.IsFromDetailTable)
{
if (e.RebindReason == GridRebindReason.InitialLoad
|| e.RebindReason == GridRebindReason.ExplicitRebind)
{
RadGrid1.VirtualItemCount = Book.GetBooksCount();
}

int skip = RadGrid1.MasterTableView.CurrentPageIndex * RadGrid1.MasterTableView.PageSize;
int take = RadGrid1.MasterTableView.PageSize;

RadGrid1.DataSource = Book.GetBooks(skip, take);
}
}


Here's our data access methods:


public static int GetBooksCount()
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx).Count();
}
}

public static List<Book> GetBooks(int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{
var query = GetBooksQuery(ctx);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<Book> GetBooksQuery(DataClasses1DataContext ctx)
{
var query = (from book in ctx.Books
select book);

return query;
}


part of *.aspx file that holds RadGrid markup:


<form id="form1" runat="server">
<div>
<telerik:RadScriptManager ID="RadScriptManager1" runat="server"/>
<telerik:RadGrid ID="RadGrid1" runat="server"
OnNeedDataSource="RadGrid1_NeedDataSource"
AllowCustomPaging="True"
AllowPaging="True">
<PagerStyle AlwaysVisible="True" Mode="NextPrevAndNumeric" />
</telerik:RadGrid>
</div>
</form>


and the resulting grid:



Notice how we implemented pagind with just few lines of code.

By default RadGrid created columns and bound them to properties of our Book class (AutoGenerateColumns="True"). But we need to display Pubilsher's Name instead of PublisherID.

To do this we need to change AutoGenerateColumns to False and write RadGrid markup by hand.

Here is the markup:

<Columns>
<telerik:GridBoundColumn UniqueName="Title" HeaderText="Title"
DataField="Title" DataType="System.String" />
<telerik:GridBoundColumn UniqueName="Author" HeaderText="Author"
DataField="Author" DataType="System.String" />
<telerik:GridBoundColumn UniqueName="Publisher.Name" HeaderText="Publisher"
DataField="Publisher.Name" DataType="System.String" />
</Columns>

and code behind:

public partial class Book
{

public static int GetBooksCount()
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx).Count();
}
}

public static List<Book> GetBooks(int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{

// Preload Book's Publisher field
var loadOptions = new DataLoadOptions();
loadOptions.LoadWith<Book>(b => b.Publisher);
ctx.LoadOptions = loadOptions;

var query = GetBooksQuery(ctx);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<Book> GetBooksQuery(DataClasses1DataContext ctx)
{
var query = (from book in ctx.Books
select book);

return query;
}
}

Here's what we got at this point:



Now we will add support for filter and sorting capabilities. To do this we should set RadGrid's AllowFilteringByColumn and AllowSorting to True and change our DAL methods to support query filtering and ordering.

RadGrid gives us very good support here, because it can generate linq string that contains part of linq query's where expression. To get this expression we wrote simple RadGridHelper class (see full source code in attachments below).

To use RadGrid expressions we need DynamicQueriable (CSharpSamples.zip\LinqSamples\DynamicQuery\DynamicQuery\Dynamic.cs) to mix dynamic linq expressions with static queries in DAL.

Finishing stroke is to make filtering case insensitive, in order to do this we should set CaseSensitive="False" in RadGrid's GroupingSettings.

Below are the resulting code linstings:

Resulting RadGrid markup:

<telerik:RadGrid ID="RadGrid1" runat="server"
OnNeedDataSource="RadGrid1_NeedDataSource"
AllowCustomPaging="True"
AllowPaging="True"
AllowFilteringByColumn="True"
AllowSorting="True"
AutoGenerateColumns="False" GridLines="None">
<HeaderContextMenu EnableTheming="True">
<CollapseAnimation Type="OutQuint" Duration="200"></CollapseAnimation>
</HeaderContextMenu>
<PagerStyle AlwaysVisible="True" Mode="NextPrevAndNumeric" />
<GroupingSettings CaseSensitive="False" />
<MasterTableView>
<ExpandCollapseColumn>
<HeaderStyle Width="20px"></HeaderStyle>
</ExpandCollapseColumn>
<Columns>
<telerik:GridBoundColumn UniqueName="Title" HeaderText="Title"
DataField="Title" DataType="System.String" SortExpression="Title" />
<telerik:GridBoundColumn UniqueName="Author" HeaderText="Author"
DataField="Author" DataType="System.String" SortExpression="Author" />
<telerik:GridBoundColumn UniqueName="PublisherName" HeaderText="Publisher"
DataField="Publisher.Name" DataType="System.String" SortExpression="Publisher.Name" />
</Columns>
</MasterTableView>
<FilterMenu EnableTheming="True">
<CollapseAnimation Type="OutQuint" Duration="200"></CollapseAnimation>
</FilterMenu>
</telerik:RadGrid>


Resulting code behind:


protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
RadGrid1.DataBind();
}
}

protected void RadGrid1_NeedDataSource(object source, GridNeedDataSourceEventArgs e)
{
if (!e.IsFromDetailTable)
{
string where = RadGridHelper.GetFilterExpression(RadGrid1.MasterTableView,
null);
string orderBy = RadGridHelper.GetOrderBy(RadGrid1.MasterTableView);

if (e.RebindReason == GridRebindReason.InitialLoad
|| e.RebindReason == GridRebindReason.ExplicitRebind)
{
RadGrid1.VirtualItemCount = Book.GetBooksCount(where);
}

int skip = RadGrid1.MasterTableView.CurrentPageIndex * RadGrid1.MasterTableView.PageSize;
int take = RadGrid1.MasterTableView.PageSize;

RadGrid1.DataSource = Book.GetBooks(where, orderBy, skip, take);
}
}


Final Book class:


public partial class Book
{

public static int GetBooksCount(string where)
{
using (var ctx = new DataClasses1DataContext())
{
return GetBooksQuery(ctx, where, null).Count();
}
}

public static List<Book> GetBooks(string where, string orderBy, int? skip, int? take)
{
using (var ctx = new DataClasses1DataContext())
{
var query = GetBooksQuery(ctx, where, orderBy);

if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}

return query.ToList();
}
}

private static IQueryable<BookWrapper> GetBooksQuery(DataClasses1DataContext ctx, string where, string orderBy)
{
var query = (from book in ctx.Books
select book);

if (!string.IsNullOrEmpty(where))
{
query = query.Where(where);
}
if (!string.IsNullOrEmpty(orderBy))
{
query = query.OrderBy(orderBy);
}

return query;
}
}


Resulting RadGrid in action:



Attachments


radgridsample-src.zip Full project sources + DB (142 KB)

Friday, August 07, 2009

.Net Collections Lookup Performance



using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;

namespace TechNoir.NorCal.BL.Tests
{
[TestFixture]
public class PerformanceTests
{
[Test]
public void CompareLookupSpeed()
{
bool warmUp = true;

for (int j = 1; j <= 10; j++)
{
int n = j * 1000;

Console.WriteLine("\nTest {0}; n = {1}\n", j, n);

List<int> list;
Dictionary<int, object> dictionary;
List<int> sortedList;
IQueryable<int> queryable;

PrepareTestData(n,
out list, out dictionary, out sortedList, out queryable);


DateTime start = DateTime.Now;
foreach (int item in list)
{
Assert.That(dictionary.ContainsKey(item));
}
DateTime end = DateTime.Now;

Console.WriteLine("IDictionary.ContainsKey : "
+ (end - start).TotalMilliseconds);


start = DateTime.Now;
foreach (int item in list)
{
Assert.That(sortedList.BinarySearch(item) != -1);
}
end = DateTime.Now;

Console.WriteLine("List.BinarySearch : "
+ (end - start).TotalMilliseconds);


start = DateTime.Now;
foreach (var item in list)
{
Assert.That(list.Contains(item));
}
end = DateTime.Now;

Console.WriteLine("List.Contains : "
+ (end - start).TotalMilliseconds);


start = DateTime.Now;
if (j <= 3)
{
foreach (int item in list)
{
int localItem = item;
Assert.That(queryable.Any(i => i == localItem));
}
}
end = DateTime.Now;

Console.WriteLine("IQueryable.Any : "
+ (end - start).TotalMilliseconds);


if (warmUp)
{
warmUp = false;
j--;
}

}
}

private static void PrepareTestData(
int n,
out List<int> list,
out Dictionary<int, object> dictionary,
out List<int> sortedList,
out IQueryable<int> queryable)
{
list = new List<int>();

var rand = new Random(42);

for (int i = 0; i < n; i++)
{
int x;
do
{
x = rand.Next(int.MaxValue);
} while (list.Contains(x));

list.Add(x);
}

queryable = list.AsQueryable();

dictionary = new Dictionary<int, object>();

foreach (var item in list)
{
dictionary.Add(item, null);
}


sortedList = new List<int>();
sortedList.AddRange(list);

sortedList.Sort();
}
}
}