مطالب
ارتقاء به ASP.NET Core 1.0 - قسمت 18 - کار با ASP.NET Web API
در ASP.NET Core، برخلاف نگارش‌های قبلی ASP.NET که ASP.NET Web API مجزای از ASP.NET MVC و همچنین وب فرم‌ها ارائه شده بود، اکنون جزئی از ASP.NET MVC است و با آن یکپارچه می‌باشد. بنابراین پیشنیازهای راه اندازی Web API با ASP.NET Core شامل سه مورد ذیل هستند که پیشتر آن‌ها را بررسی کردیم:
الف) فعال سازی ارائه‌ی فایل‌های استاتیک
ب) فعال سازی ASP.NET MVC
ج) آشنایی با تغییرات مسیریابی

و مابقی آن صرفا یک سری نکات تکمیلی هستند که در ادامه آن‌ها را بررسی خواهیم کرد.


تعریف مسیریابی کلی کنترلر

در اینجا همانند مطلب «ارتقاء به ASP.NET Core 1.0 - قسمت 9 - بررسی تغییرات مسیریابی»، می‌توان در صورت نیاز، مسیریابی کلی کنترلر را توسط ویژگی Route بازنویسی کرد و برای مثال درخواست‌های آن‌را محدود به درخواست‌هایی کرد که با api/ شروع شوند:
[Route("api/[controller]")] // http://localhost:7742/api/test
public class TestController : Controller
{
    private readonly ILogger<TestController> _logger;
 
    public TestController(ILogger<TestController> logger)
    {
        _logger = logger;
    }
[controller] هم در اینجا یک توکن پیش فرض است که با نام کنترلر جاری یا همان Test، به صورت خودکار جایگزین می‌شود.
در مورد سرویس ثبت وقایع نیز در مطلب «ارتقاء به ASP.NET Core 1.0 - قسمت 17 - بررسی فریم ورک Logging» بحث کردیم و از آن می‌توان برای ثبت استثناءهای رخ داده استفاده کرد.


یک کنترلر ، اما با قابلیت‌های متعدد

همانطور که ملاحظه می‌کنید، اینبار کلاس پایه‌ی این کنترلر Test، همان Controller متداول ASP.NET MVC ذکر شده‌است و نه Api Controller سابق. تمام قابلیت‌های موجود در این‌دو توسط همان Controller ارائه می‌شوند.


هنوز پیش فرض‌های سابق Web API برقرار هستند

در مثال ذیل که به نظر یک کنترلر ASP.NET MVC است،
- هنوز متد Get مربوط به Web API که به صورت پیش فرض به درخواست‌های Get ختم شده‌ی به نام کنترلر پاسخ می‌دهد، برقرار است (متد IEnumerable<string> Get). برای مثال اگر شخصی در مرورگر، آدرس http://localhost:7742/api/test را درخواست دهد، متد Get اجرا می‌شود.
- در اینجا می‌توان نوع خروجی متد را دقیقا از همان نوع اشیاء مدنظر، تعیین کرد؛ برای نمونه تعریف  <IEnumerable<string در مثال زیر.
- مهم نیست که از return Json استفاده کنید و یا خروجی را مستقیما با فرمت <IEnumerable<string ارائه دهید.
- اگر نیاز به کنترل بیشتری بر روی HTTP Response Status بازگشتی داشتید، می‌توانید از متدهایی مانند return Ok و یا return BadRequest در صورت بروز مشکلی استفاده نمائید. برای مثال در متد IActionResult GetEpisodes2، استثنای فرضی حاصل، ابتدا توسط سرویس ثبت وقایع ذخیره شده و در آخر یک BadRequest بازگشت داده می‌شود.
- تمام مسیریابی‌ها را توسط ویژگی Route و یا نوع‌های درخواستی مانند HttpGet، می‌توان بازنویسی کرد؛ مانند مسیر /api/path1
- امکان محدود ساختن نوع پارامترهای دریافتی همانند متد Get(int page) ذیل، توسط ویژگی‌های مسیریابی وجود دارد.
[Route("api/[controller]")] // http://localhost:7742/api/test
public class TestController : Controller
{
    private readonly ILogger<TestController> _logger;
 
    public TestController(ILogger<TestController> logger)
    {
        _logger = logger;
    }
 
    [HttpGet]
    public IEnumerable<string> Get() // http://localhost:7742/api/test
    {
        return new [] { "value1", "value2" };
    }
 
    [HttpGet("{page:int}")]
    public IActionResult Get(int page) // http://localhost:7742/api/test/1
    {
        return Json(new[] { "value3", "value4" });
    }
 
    [HttpGet("/api/path1")]
    public IActionResult GetEpisodes1() // http://localhost:7742/api/path1
    {
        return Json(new[] { "value5", "value6" });
    }
 
    [HttpGet("/api/path2")]
    public IActionResult GetEpisodes2() // http://localhost:7742/api/path2
    {
        try
        {
            // get data from the DB ...
            return Ok(new[] { "value7", "value8" });
        }
        catch (Exception ex)
        {
            _logger.LogError("Failed to get data from the API", ex);
            return BadRequest();
        }
    } 
}
بنابراین در اینجا اگر می‌خواهید یک کنترلر ASP.NET Web API 2.x را به ASP.NET Core 1.0 ارتقاء دهید، تمام متدهای Get و Put و امثال آن هنوز معتبر هستند و مانند سابق عمل می‌کنند:
    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        // GET: api/values
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }
// GET api/values/5
        [HttpGet("{id}")]
        public string Get(int id)
        {
            return "value";
        }
// POST api/values
        [HttpPost]
        public void Post([FromBody]string value)
        {
        }
// PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody]string value)
        {
        }
// DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }
}
در مورد ویژگی FromBody در ادامه بیشتر بحث خواهد شد.

یک نکته: اگر می‌خواهید خروجی Web API شما همواره JSON باشد، می‌توانید ویژگی جدید Produces را به شکل ذیل به کلاس کنترلر اعمال کنید:
 [Produces("application/json")]
[Route("api/[controller]")] // http://localhost:7742/api/test
public class TestController : Controller


تغییرات Model binding پیش فرض، برای پشتیبانی از ASP.NET MVC و ASP.NET Web API

فرض کنید مدل زیر را به برنامه اضافه کرده‌اید:
namespace Core1RtmEmptyTest.Models
{
    public class Person
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public int Age { get; set; }
    }
}
و همچنین قصد دارید اطلاعات آن‌را از کاربر توسط یک عملیات POST دریافت کرده و به شکل JSON نمایش دهید:
using Core1RtmEmptyTest.Models;
using Microsoft.AspNetCore.Mvc;
 
namespace Core1RtmEmptyTest.Controllers
{
    public class PersonController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
 
        [HttpPost]
        public IActionResult Index(Person person)
        {
            return Json(person);
        }
    }
}
برای اینکار، از jQuery به نحو ذیل استفاده می‌کنیم (از این جهت که بیشتر ارسال‌های به سرور جهت کار با Web API نیز Ajax ایی هستند):
@section scripts
{
    <script type="text/javascript">
        $(function () {
            $.ajax({
                type: 'POST',
                url: '/Person/Index',
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                data: JSON.stringify({
                    FirstName: 'F1',
                    LastName: 'L1',
                    Age: 23
                }),
                success: function (result) {
                    console.log('Data received: ');
                    console.log(result);
                }
            });
        });
    </script>
}


همانطور که مشاهده می‌کنید، اگر در ابتدای این متد یک break-point قرار دهیم، اطلاعاتی را از سمت کاربر دریافت نکرده‌است و مقادیر دریافتی نال هستند.
این مورد یکی از مهم‌ترین تغییرات Model binding این نگارش از ASP.NET MVC با نگارش‌های قبلی آن است. در اینجا اشیاء پیچیده از request body دریافت و bind نمی‌شوند و باید به نحو ذیل، محل دریافت و تفسیر آن‌ها را دقیقا مشخص کرد:
 public IActionResult Index([FromBody]Person person)
زمانیکه ویژگی FromBody را مشخص می‌کنیم، آنگاه اطلاعات دریافتی از request body دریافتی، به شیء Person نگاشت خواهند شد.


نکته‌ی مهم: حتی اگر FromBody را ذکر کنید ولی از JSON.stringify در سمت کاربر استفاده نکنید، باز هم نال دریافت خواهید کرد. بنابراین در این نگارش ذکر JSON.stringify نیز الزامی است.


حالت‌های دیگر تغییرات Model Binding در ASP.NET Core

تا اینجا مشخص شد که اگر یک درخواست Ajax ایی را به سمت سرور یک برنامه‌ی ASP.NET Core ارسال کنیم، به صورت پیش فرض به اشیاء پیچیده‌ی سمت سرور bind نمی‌شود و باید حتما ویژگی FromBody را نیز مشخص کرد تا اطلاعات را از request body واکشی کند (محل دریافت اطلاعات پیش فرض آن نامشخص است).
یک سؤال: اگر به سمت یک چنین اکشن متدی، اطلاعات فرمی را به حالت معمول ارسال کنیم، چه اتفاقی رخ خواهد داد؟
ارسال اطلاعات فرم‌ها به سرور، همواره شامل دو تغییر ذیل است:
  var dataType = 'application/x-www-form-urlencoded; charset=utf-8';
 var data = $('form').serialize();
اطلاعات فرم سریالایز می‌شوند و data type مخصوصی هم برای آن‌ها تنظیم خواهد شد. در این حالت، ارسال یک چنین اطلاعاتی به سمت اکشن متد فوق، با خطای 415 unsupported media type متوقف می‌شود. برای رفع این مشکل باید از ویژگی دیگری به نام FromForm استفاده کرد:
 [HttpPost]
public IActionResult Index([FromForm]Person person)
حالت‌های دیگر ممکن را در تصویر ذیل ملاحظه می‌کنید:


علت این مساله نیز بالا رفتن میزان امنیت سیستم است. در نگارش‌های قبلی، تمام مکان‌ها و حالت‌های میسر جستجو می‌شوند و اگر یکی از آن‌ها قابلیت تطابق با خواص شیء مدنظر را داشته باشد، کار binding به پایان می‌رسد. اما در اینجا با مشخص شدن محل دقیق منبع اطلاعات، دیگر سایر حالات جستجو نشده و سطح حمله کاهش پیدا می‌کند.
در اینجا باید مشخص کرد که دقیقا اطلاعاتی که قرار است به یک شیء پیچیده Bind شوند، آیا از یک Form تامین می‌شوند، یا از Body و یا از هدر، کوئری استرینگ، مسیریابی و یا حتی از یک سرویس.
تمام این حالت‌ها مشخص هستند (برای مثال دریافت اطلاعات از هدر درخواست HTTP و انتساب آن‌ها به خواص متناظری در شیء مشخص شده)، منهای FromService آن که به نحو ذیل عمل می‌کند:
در این حالت می‌توان در سازنده‌ی کلاس مدل خود، سرویسی را تزریق کرد و توسط آن خاصیتی را مقدار دهی نمود:
public class ProductModel
{
    public ProductModel(IProductService prodService)
    {
        Value = prodService.Get(productId);
    }
    public IProduct Value { get; private set; }
}
این تزریق وابستگی‌ها برای اینکه تکمیل شود، نیاز به ویژگی FromServices خواهد داشت:
 public async Task<IActionResult> GetProduct([FromServices]ProductModel product)
{
}
وجود ویژگی FromServices به این معنا است که سرویس‌های مدل یاد شده را از تنظیمات ابتدایی IoC Container خود خوانده و سپس در اختیار مدل جاری قرار بده. به این ترتیب حتی تزریق وابستگی‌ها در مدل‌های برنامه هم میسر می‌شود.


تغییر تنظیمات اولیه‌ی خروجی‌های ASP.NET Web API

در اینجا حالت ارائه‌ی خروجی XML به صورت پیش فرض فعال نیست. اگر علاقمند به افزودن آن نیز باشید، نحوه‌ی کار را در متد ConfigureServices کلاس آغازین برنامه در کدهای ذیل مشاهده می‌کنید:
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
    {
        options.FormatterMappings.SetMediaTypeMappingForFormat("xml", new MediaTypeHeaderValue("application/xml")); 
 
    }).AddJsonOptions(options =>
    {
        options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        options.SerializerSettings.DefaultValueHandling = DefaultValueHandling.Include;
        options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
    });
همچنین اگر خواستید تنظیمات ابتدایی JSON.NET را تغییر داده و برای مثال خروجی JSON تولیدی را camel case کنید، این‌کار را توسط متد AddJsonOptions به نحو فوق می‌توان انجام داد.
بازخوردهای دوره
بوت استرپ (نگارش 3) چیست؟
سلام
میخواستم بدونم چرا به جای خواندن جی کوئری از سایت آن
 < script src = "http://code.jquery.com/jquery-latest.min.js" ></ script >
اون رو هم مثل بقیه فایل‌های جاوا اسکریپت دانلود نمیکنیم تا به صورت local استفاده کنیم؟
 < script src = "Scripts/jquery-latest.min.js  " ></ script >
مطالب
قابل ویرایش کننده‌ی فوق العاده x-editable ؛ قسمت اول
من در یکی از پروژه‌ها از Kendo UI Treeview استفاده کردم و قصد داشتم قابلیت تغییر نام را به گره‌ها بدهم. به همین جهت پس از جستجو به x-editable برخوردم. این کتابخانه‌ی جاواسکریپتی در ابتدا برای قالب‌های بوت استراپ طراحی شده بود که در حال حاضر اینگونه نیست و به راحتی در هر پروژه‌ای که فقط جی کوئری صدا زده شده باشد، قابل اجرا است و نسخه‌ی مخصوص Angular آن هم در این آدرس قرار دارد. همچنین این قابلیت اختیاری و پیش فرض را دارد که به طور خودکار اطلاعات تغییر یافته را به سمت url ایی که شما تعیین می‌کنید، ارسال کند. برای همین نیازی به استفاده جداگانه از jQuery Ajax برای ارسال اطلاعات نیست و البته در انتهای مقاله هم هنگام استفاده از درخت کندو به مشکلی برخوردم که آن را هم بررسی می‌کنیم.

وارد کردن کتابخانه ها
این کتابخانه شامل دو فایل css و JS می‌باشد که بسته به محیطی که در آن کار می‌کنید متفاوت هستند. در این صفحه شما می‌توانید برای 4 محیط Jquery ,JqueryUI , Bootstrap2 و Bootsrap3 بسته‌ی مخصوصش را یا به صورت دانلود فایل‌ها یا از طریق CDN دریافت نمایید. در اینجا هم یک دمو از قابلیت‌های آن قابل مشاهده است.

برای شروع، کتابخانه‌ی مورد نظر خود را دریافت و آن‌ها را به صفحه‌ی خود اضافه نمایید. در صورتیکه از Bootstrap استفاده می‌کنید، ابتدا فایل‌های زیر را اضافه کنید:
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" rel="stylesheet">
    <script src="http://code.jquery.com/jquery-2.0.3.min.js"></script> 
    <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
سپس فایل‌های x-editable را صدا بزنید.

اولین حالتیکه میتوانید با این کتابخانه کار کنید، استفاده از خاصیت *-data است. نمونه زیر را در نظر بگیرید:
  <a href="#" id="favsite" data-type="text" data-pk="1" data-url="@Url.Action(MVC.Categories.EditCategory())" 
data-title="Enter your favorite site">dotnettips.info</a>
به تعداد هر عنصری که نیاز است، اینکار را انجام دهید و به هر کدام یک id انتساب دهید. بعد از آن کد زیر را در قسمت script بنویسید:
 $(document).ready(function () {
        $('#favsite').editable();
    });
در صورتیکه قصد دارید خصوصیاتی از اشیاء را که برای همه‌ی عنصرهای معرفی شده یکسان است، معرفی کنید می‌توانید از کد زیر بهره ببرید:
 $(document).ready(function () {
        $.fn.editable.defaults.mode = 'inline';
        $('#favsite').editable();
    });
کد بالا حالت ویرایش تمام عناصر معرفی شده را به inline تغییر می‌دهد.

حالت بعدی که می‌توان استفاده کرد به شکل زیر است:
  <a href="#" id="favsite" >dotnettips.info</a>

  $.fn.editable.defaults.mode = 'inline';
    $(document).ready(function () {
        $('#favsite').editable({
            type: 'text',
            pk: 1,
            url: '@Url.Action(MVC.Categories.EditCategory())',
            title: 'Enter your favorite site'
        });
    });

خوبی این روش این است که می‌توان اطلاعات بیشتری چون رویدادها را به آن پاس داد. تا الان با نحوه‌ی انتساب آن به اشیاء آشنا شدیم. اجازه دهید تا با خصوصیات آن آشنا شویم.


 AjaxOptions
 همانطور که متوجه شدید به طور خودکار اطلاعات ویرایش شده، به سمت آدرس داده شده، به شیوه Post ارسال می‌گردند. در صورتیکه قصد دست بردن در نوع درخواست را دارید، می‌توانید از این ویژگی استفاده کنید:
    ajaxOptions: {
        type: 'put',
        dataType: 'json'
    }
 Anim
 این ویژگی که تنها در حالت inline پاسخ می‌دهد، می‌تواند زمان بسته شدن x-editable را تغییر دهد که به طور پیش فرض با false مقداردهی شده است. جهت تغییر زمان بسته شدن، کد زیر را وارد نمایید:
anim:'false'
//or
anim: {
                duration: 2000
            }
 autotext  در انتهای جدول آمده است.
 defaultValue 
 در صورتیکه عنصر مورد نظر محتوایی نداشته باشد و این خصوصیت را مقداردهی کنید، موقع ویرایش، این عبارت تعیین شده نمایش می‌یابد. در مثال بالا باید متن تگ a را حذف کرده تا نتیجه را ببینید: (البته فیلد value نباید مقداری داشته باشد)
defaultValue: 'default val'
//or
defaultValue: undefined
//or
defaultValue: null
در بقیه‌ی حالات، ویرایشگر خالی از متن خواهد بود و مقدار پیش فرض آن نال است.
 disabled  false کردن این ویژگی باعث غیرفعال شدن x-editable بر روی کنترل جاری میگردد.
 display 
خاصیت  display یا مقدار بولین false را دریافت می‌کند، یا نال، یا یک تابع callback را می‌توان به آن پاس داد. این خصوصت زمانی صدا زده می‌شود که اطلاعات به سمت آدرس سرور رفته و با موفقیت بازگشت داده می‌شوند (در صورتی که این ویژگی غیرفعال باشد، بلافاصه بعد از تایید کاربر، از اطلاعات وارد شده صدا زده می‌شود) و سپس متن جدید عنصر تغییر می‌یابد. حال اگر این خاصیت نال که مقدار پیش فرض آن است باشد، متن تغییر می‌یابد. ولی اگر false باشد، متن سابق باقی خواهد ماند و در صورتیکه تابعی به آن پاس داده باشید، طبق تابع شما عمل خواهد کرد.
پارامترهایی که تابع شما می‌تواند داشته باشد به شرح زیر است:
value : مقدار جدید
response : پاسخ سرور ( در صورتی که ارسال از طریق Ajax صورت گرفته باشد)

و در صورتیکه عنصر شما checlklist یا select باشد که حاوی منبعی از مقادیر هست، مقادیرشان در قالب یک آرایه با نام sourceData بازگشت خواهد خورد. برای دسترسی به آیتم‌های انتخابی هم از کد زیر استفاده می‌کنیم:
$.fn.editableutils.itemsByValue(value, sourceData)
 emptyclass  معرفی یک کلاس css برای موقعیکه عنصر خالی است.
 emptytext  در صورتی خالی بودن عنصر، این متن را برای عنصر نمایش بده.
 highlight   بعد از به روز رسانی متن عنصر، آن را با این رنگ highlight خواهد کرد و کد رنگی باید در مبنای هگز باشد. مقدار پیش فرض آن false است.
 mode
 دو حالت نمایشی دارد که پیش فرض آن popup است و با باز کردن یک پنجره، مقدار جدید را دریافت می‌کند. مورد بعدی inline است که به جای باز کردن پنجره، متن عنصر را به حالت ویرایش تغییر میدهد.
 name  نام فیلدی که مقدارش تغییر می‌کند.
 onblur زمانی که کاربر فوکوس را از ویرایشگر  می‌گیرد، ویرایشگر چه پاسخی باید به آن بدهد، باز بماند؟ ignore ، بسته شود؟ cancel و یا مقدار داده شده را تایید کند؟submit
 params
پارامترهای درخواست ایجکسی که کنترل در حالت پیش فرض ارسال می‌کند؛ شامل Pk که آن را با id رکورد پر می‌کنیم. name نام فیلدی که تغییر یافته است و value که مقدار جدید است. در صورتیکه دوست دارید اطلاعات اضافی‌تری نیز ارسال شوند، می‌توانید از این خاصیت استفاده کنید و پارامترها را در قالب Object به آن پاس کنید. ولی اگر بخواهید در کل همه‌ی پارامترها را رونویسی کنید باید یک تابع را به آن پاس کنید:

  params: function(params) {
        //در این حالت پارامترهای پیش فرض ارسال نشده 
و تنها پارامترهای معرفی شده در این تابع ارسال می‌شوند
        params.a = 1;
        return params;
    }

 pk  کلید اصلی رکورد شما در دیتابیس یا هان id است. در صورتی که از کلیدهای ترکیبی استفاده می‌کنید، نگران نباشید فکر آن را هم کرده اند.
//کلید عدد
pk:1,
//کلید رشته ای
pk:'dp123'
//کلید ترکیبی
pk:{id: 1, lang: 'en'}

//معرفی یک تابع به آن و بازگشت 
یکی از مقادیر بالا بعد از محاسبات pk:function() { }
 Placement  این ویژگی فقط به درد حالت Popup می‌خورد که پنجره را کجای عنصر نمایش دهد و شامل چهار مقدار left,right,top,bottom می‌شود.
 saveonchange  زمانی که مقدار جدید، برابر مقدار فعلی باشد و این خاصیت false باشد، هیچ تغییر‌ی رخ نخواهد داد. ولی اگر برابر true باشد ،مقدار جدید اسال و جایگزین مقدار فعلی خواهد شد. مقدار پیش فرض آن false است.
 selector
  با استفاده از این خصوصیت در عنصر انتخابی به دنبال عناصری که در selector تعیین شده می‌گردد و حالت ویرایش را روی آن‌ها فعال می‌کند.
در این حالت استفاده از خصوصیات emptytext و autotext در ابتدای امر ممکن نیست و بعد از اولین کلیک قابل استفاده هستند.
نکته بعدی اینکه شما باید کلاس‌های زیر را دستی اضافه کنید.
کلاس  editable-click برای همه کنترل‌ها وکلاس editable-empty به کنترل‌های بدون مقدار و برای مقداردهی کنترلهای بدون مقدار میتوان از خاصیت ''=data-value استفاده کرد.
    <div id="user">
      <!-- empty -->
      <a href="#" data-name="username" 
data-type="text" data-value="" 
title="Username">Empty</a>
      <!-- non-empty -->
      <a href="#" data-name="group" 
data-type="select" data-source="/groups"
 data-value="1" title="Group">Operator</a>
    </div>     
     
    <script>
    $('#user').editable({
        selector: 'a',
        url: '/post',
        pk: 1
    });
    </script>
 send  سه مقدار auto,always و never را دریافت می‌کند. موقعی که شما آن را روی auto تنظیم کنید؛ در صورتی مقادیر به سمت سرور ارسال می‌شوند که دو خاصیت url و pk تعریف شده باشند. در غیر این صورت ویرایش فقط در حالت محلی و روی سیستم کاربر رخ خواهد داد.
 showbuttons   در صورتیکه با false مقداردهی شود، تایید فرم به طور خودکار انجام می‌گیرد و اگر با یکی از مقادیر left یا Bottom پر شود، دکمه‌ها را در آن قسمت نشان می‌دهد.
 success
 اطلاعات به سمت سرور رفته و با موفقیت با کد 200 بازگشت داده شده‌اند. در مستندات نوشته است، هر کد وضعیتی غیر از 200 بازگشت داده شود، به سمت خاصیت error هدایت می‌شو.د ولی آن طور که من با httpresponsemessage تست کردم، چنین چیزی را مشاهده نکردم و مجددا success صدا زده شد. پس بهتر هست داده‌ای را که به سمت کنترل برگشت می‌دهید، خودتان کنترل کنید. به خصوص اگر انتقال اطلاعات صحیح باشد. ولی اگر در دیتابیس، تغییر با خطا روبرو گردد بهتر است نتیجه‌ی آن ارسال شده و از تغییر مقدار فعلی ممانعت به عمل آورید.
    success: function(response, newValue) {
        if(!response.success) return response.msg;
    }
 toggle  اگر قصد دارید که باز و بسته کردن ویرایشگر را بر عهده‌ی مثلا یک دکمه‌ی روی صفحه بگذارید، این خصوصیت به شما کمک می‌کند:
    $('#edit-button').click(function(e) {
        e.stopPropagation();
        $('#favsite').editable('toggle');
    });
به جای toggle نیز می‌توان از show و hide هم استفاده کرد. وجود عبارت e.stopPropagation جهت باز شدن صحیح ویرایشگر الزامی است.

 type نوع ویرایشی را که قرار است انجام گیرد، مشخص می‌کند. text برای متن، date برای تاریخ، textarea جهت متون طولانی‌تر نسبت به text و بسیاری از موارد دیگر
 unsavedclass  این کلاس موقعی اعمال می‌گردد که اطلاعاتی را ویرایش کرده‌اید، ولی اطلاعاتی به سمت سرور ارسال نشده است. مثلا pk مقداردهی نشده یا send=never قرار داید و یا اینکه به صورت محلی ذخیره می‌کنید و می‌خواهید در آخر همه‌ی اطلاعات را ارسال کنید.
این خاصیت به طور پیش فرض با کلاس editable-unsaved مقداردهی شده که می‌توانید با نال کردن، از شرش خلاص شوید.
 url
این خاصیت با آدرس سمت سرور پر می‌شود. ولی میتوان به آن یک تابع هم پاس کرد که این تابع جایگزین درخواست ایجکسی خودش خواهد شد و برای بازگشت دادن نتیجه‌ی این درخواست به سمت تابع‌های callback خودش می‌توانید یک deferred object را برگشت دهید:

 url: function(params) {
        var d = new $.Deferred;
        if(params.value === 'abc') {
//returning error via deferred object 

 return d.reject('error message'); 
        } else {
            //async saving data in js model
            someModel.asyncSaveMethod({
               ..., 
               success: function(){
                  d.resolve();
               }
            }); 
            return d.promise();
        }
    }

 validate
 مقدار پیش فرض آن نال است و می‌توان به آن یک تابع را جهت اعتبارسنجی سمت کلاینت پاس داد. به عنوان آرگومان، مقدار جدیدی را ارسال کرده و در آن به اعتبارسنجی می‌پردازید. در صورتی که مقدار، نامعتبر باشد، می‌توانید یک پیام خطا از نوع رشته‌ای را برگردانید.

در صورتی که از نسخه‌ی 1.5.1 به بعد استفاده می‌کنید، دریافت یک object با مقادیر زیر نیز ممکن شده است:
newValue: مقدار جدید و جایگزین مقدار غیر معتبر.
msg : پیام خطا.
به کدهای زیر در سه حالت نگاه کنید:
validate: function (value) {
                if ($.trim(value) == '') {
//در تمامی نسخه‌های یک پیام متنی باز میگردد
                    return 'This field is required';
//1.5.1
//یک مقدار جدید برگشت میدهد که بلافاصله آن را
// تایید میکند و متن عنصر به روز می‌شود
                    return { newValue: 'validated' };
//متن جدید ار ارسال کرده و پیام هشدار را نشان میدهد 
//ولی تایید نمی‌کند و منتظر تایید کاربر است
                    return { newValue: 'validated',
 msg: 'This field is required' }; } }

 value مقدار پیش فرضی که در ویرایشگر نشان می‌دهد و اگر مقداردهی نشود، از متن عنصر استفاده می‌کند.
 autotext  سه مقدار دارد auto (پیش فرض)،always و never.
موقعیکه عنصر شما متنی نداشته باشد و روی auto تنظیم شده باشد، مقدار value را به عنوان متن عنصر نمایش می‌دهد.
always کاری ندارد که عنصر شما متن دارد یا خالی است؛ مقدار value به آن انتساب داده خواهد شد.
never هیچگاه.

  در قسمت بعدی که قسمت پایانی است مطالب را ادامه می‌دهیم.
مطالب
ASP.NET MVC #20

تهیه گزارشات تحت وب به کمک WebGrid

WebGrid از ASP.NET MVC 3.0 به صورت توکار به شکل یک Html Helper در دسترس می‌باشد و هدف از آن ساده‌تر سازی تهیه گزارشات تحت وب است. البته این گرید، تنها گرید مهیای مخصوص ASP.NET MVC نیست و پروژه MVC Contrib یا شرکت Telerik نیز نمونه‌های دیگری را ارائه داده‌اند؛ اما از این جهت که این Html Helper، بدون نیاز به کتابخانه‌های جانبی در دسترس است، بررسی آن ضروری می‌باشد.


صورت مساله

لیستی از کارمندان به همراه حقوق ماهیانه آن‌ها در دست است. اکنون نیاز به گزارشی تحت وب، با مشخصات زیر می‌باشد:
1- گزارش باید دارای صفحه بندی بوده و هر صفحه تنها 10 ردیف را نمایش دهد.
2- سطرها باید یک در میان دارای رنگی متفاوت باشند.
3- ستون حقوق کارمندان در پایین هر صفحه، باید دارای جمع باشد.
4- بتوان با کلیک بر روی عنوان هر ستون، اطلاعات را بر اساس ستون انتخابی، مرتب ساخت.
5- لینک‌های حذف یا ویرایش یک ردیف نیز در این گزارش مهیا باشد.
6- لیست تهیه شده، دارای ستونی به نام «ردیف» نیست. این ستون را نیز به صورت خودکار اضافه کنید.
7- لیست نهایی اطلاعات، دارای ستونی به نام مالیات نیست. فقط حقوق کارمندان ذکر شده است. ستون محاسبه شده مالیات نیز باید به صورت خودکار در این گزارش نمایش داده شود. این ستون نیز باید دارای جمع پایین هر صفحه باشد.
8- تمام اعداد این گزارش در حین نمایش باید دارای جدا کننده سه رقمی باشند.
9- تاریخ‌های موجود در لیست، میلادی هستند. نیاز است این تاریخ‌ها در حین نمایش شمسی شوند.
10- انتهای هر صفحه گزارش باید بتوان برچسب «صفحه y/n» را مشاهده کرد. n در اینجا منظور تعداد کل صفحات است و y شماره صفحه جاری می‌باشد.
11- انتهای هر صفحه گزارش باید بتوان برچسب «رکوردهای y تا x از n» را مشاهده کرد. n در اینجا منظور تعداد کل رکوردها است.
12- نام کوچک هر کارمند، ضخیم نمایش داده شود.
13- به ازای هر شماره کارمندی، یک تصویر در پوشه images سایت وجود دارد. برای مثال images/id.jpg. ستونی برای نمایش تصویر متناظر با هر کارمند نیز باید اضافه شود.
14- به ازای هر کارمند، تعدادی پروژه هم وجود دارد. پروژه‌های متناظر را توسط یک گرید تو در تو نمایش دهید.


راه حل به کمک استفاده از WebGrid

ابتدا یک پروژه خالی ASP.NET MVC را آغاز کنید. سپس مدل‌های زیر را به آن اضافه نمائید (یک کارمند که می‌تواند تعداد پروژه منتسب داشته باشد):

using System;
using System.Collections.Generic;

namespace MvcApplication17.Models
{
public class Employee
{
public int Id { set; get; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime AddDate { get; set; }
public double Salary { get; set; }
public IList<Project> Projects { get; set; }
}
}

namespace MvcApplication17.Models
{
public class Project
{
public int Id { set; get; }
public string Name { set; get; }
}
}

سپس منبع داده نمونه زیر را به پروژه اضافه کنید. به عمد از ORM‌ خاصی استفاده نشده تا بتوانید پروژه جاری را به سادگی در یک پروژه آزمایشی جدید،‌ تکرار کنید.
using System;
using System.Collections.Generic;

namespace MvcApplication17.Models
{
public static class EmployeeDataSource
{
public static IList<Employee> CreateEmployees()
{
var list = new List<Employee>();
var rnd = new Random();
for (int i = 1; i <= 1000; i++)
{
list.Add(new Employee
{
Id = i + 1000,
FirstName = "fName " + i,
LastName = "lName " + i,
AddDate = DateTime.Now.AddYears(-rnd.Next(1, 10)),
Salary = rnd.Next(400, 3000),
Projects = CreateRandomProjects()
});
}
return list;
}

private static IList<Project> CreateRandomProjects()
{
var list = new List<Project>();
var rnd = new Random();
for (int i = 0; i < rnd.Next(1, 7); i++)
{
list.Add(new Project
{
Id = i,
Name = "Project " + i
});
}
return list;
}
}
}


در ادامه یک کنترلر جدید را با محتوای زیر اضافه نمائید:
using System.Web.Mvc;
using MvcApplication17.Models;

namespace MvcApplication17.Controllers
{
public class HomeController : Controller
{
[HttpPost]
public ActionResult Delete(int? id)
{
return RedirectToAction("Index");
}

[HttpGet]
public ActionResult Edit(int? id)
{
return View();
}

[HttpGet]
public ActionResult Index(string sort, string sortdir, int? page = 1)
{
var list = EmployeeDataSource.CreateEmployees();
return View(list);
}
}
}

علت تعریف متد index با پارامترهای sort و غیره به URLهای خودکاری از نوع زیر بر می‌گردد:

http://localhost:3034/?sort=LastName&sortdir=ASC&page=3

همانطور که ملاحظه می‌کنید، گرید رندر شده، از یک سری کوئری استرینگ برای مشخص سازی صفحه جاری، یا جهت مرتب سازی (صعودی و نزولی بودن آن) یا فیلد پیش فرض مرتب سازی، کمک می‌گیرد.

سپس یک View خالی را نیز برای متد Index ایجاد کنید. تا اینجا تنظیمات اولیه پروژه انجام شد.
کدهای کامل View را در ادامه ملاحظه می‌کنید:

@using System.Globalization
@model IList<MvcApplication17.Models.Employee>

@{
ViewBag.Title = "Index";
}

@helper WebGridPageFirstItem(WebGrid grid)
{
@(((grid.PageIndex + 1) * grid.RowsPerPage) - (grid.RowsPerPage - 1));
}

@helper WebGridPageLastItem(WebGrid grid)
{
if (grid.TotalRowCount < (grid.PageIndex + 1 * grid.RowsPerPage))
{
@grid.TotalRowCount;
}
else
{
@((grid.PageIndex + 1) * grid.RowsPerPage);
}
}

<h2>Employees List</h2>

@{
var grid = new WebGrid(
source: Model,
canPage: true,
rowsPerPage: 10,
canSort: true,
defaultSort: "FirstName"
);
var salaryPageSum = 0;
var taxPageSum = 0;
var rowIndex = ((grid.PageIndex + 1) * grid.RowsPerPage) - (grid.RowsPerPage - 1);
}

<div id="container">
@grid.GetHtml(
tableStyle: "webgrid",
headerStyle: "webgrid-header",
footerStyle: "webgrid-footer",
alternatingRowStyle: "webgrid-alternating-row",
selectedRowStyle: "webgrid-selected-row",
rowStyle: "webgrid-row-style",
htmlAttributes: new { id = "MyGrid" },
mode: WebGridPagerModes.All,
columns: grid.Columns(
grid.Column(header: "#",
style: "text-align-center-col",
format: @<text>@(rowIndex++)</text>),
grid.Column(columnName: "FirstName", header: "First Name",
format: @<span style='font-weight: bold'>@item.FirstName</span>,
style: "text-align-center-col"),
grid.Column(columnName: "LastName", header: "Last Name"),
grid.Column(header: "Image",
style: "text-align-center-col",
format: @<text><img alt="@item.Id" src="@Url.Content("~/images/" + @item.Id + ".jpg")" /></text>),
grid.Column(columnName: "AddDate", header: "Start",
style: "text-align-center-col",
format: item =>
{
int ym = item.AddDate.Year;
int mm = item.AddDate.Month;
int dm = item.AddDate.Day;
var persianCalendar = new PersianCalendar();
int ys = persianCalendar.GetYear(new DateTime(ym, mm, dm, new GregorianCalendar()));
int ms = persianCalendar.GetMonth(new DateTime(ym, mm, dm, new GregorianCalendar()));
int ds = persianCalendar.GetDayOfMonth(new DateTime(ym, mm, dm, new GregorianCalendar()));
return ys + "/" + ms.ToString("00") + "/" + ds.ToString("00");

}),
grid.Column(columnName: "Salary", header: "Salary",
format: item =>
{
salaryPageSum += item.Salary;
return string.Format("${0:n0}", item.Salary);
},
style: "text-align-center-col"),
grid.Column(header: "Tax", canSort: true,
format: item =>
{
var tax = item.Salary * 0.2;
taxPageSum += tax;
return string.Format("${0:n0}", tax);
}),
grid.Column(header: "Projects", columnName: "Projects",
style: "text-align-center-col",
format: item =>
{
var subGrid = new WebGrid(
source: item.Projects,
canPage: false,
canSort: false
);
return subGrid.GetHtml(
htmlAttributes: new { id = "MySubGrid" },
tableStyle: "webgrid",
headerStyle: "webgrid-header",
footerStyle: "webgrid-footer",
alternatingRowStyle: "webgrid-alternating-row",
selectedRowStyle: "webgrid-selected-row",
rowStyle: "webgrid-row-style"
);
}),
grid.Column(header: "",
style: "text-align-center-col",
format: item => @Html.ActionLink(linkText: "Edit", actionName: "Edit",
controllerName: "Home", routeValues: new { id = item.Id },
htmlAttributes: null)),
grid.Column(header: "",
format: @<form action="/Home/Delete/@item.Id" method="post"><input type="submit"
onclick="return confirm('Do you want to delete this record?');"
value="Delete"/></form>),
grid.Column(header: "", format: item => item.GetSelectLink("Select"))
)
)

<strong>Page:</strong> @(grid.PageIndex + 1) / @grid.PageCount,
<strong>Records:</strong> @WebGridPageFirstItem(@grid) - @WebGridPageLastItem(@grid) of @grid.TotalRowCount

@*
@if (@grid.HasSelection)
{
@RenderPage("~/views/path/_partial_view.cshtml", new { Employee = grid.SelectedRow })
}
*@
</div>

@section script{
<script type="text/javascript">
$(function () {
$('#MyGrid tbody:first').append(
'<tr class="total-row"><td></td>\
<td></td><td></td><td></td>\
<td><strong>Total:</strong></td>\
<td>@string.Format("${0:n0}", @salaryPageSum)</td>\
<td>@string.Format("${0:n0}", @taxPageSum)</td>\
<td></td><td></td><td></td></tr>');
});
</script>
}


توضیحات ریز جزئیات View فوق


تعریف ابتدایی شیء WebGrid و مقدار دهی آن
در ابتدا نیاز است یک وهله از شیء WebGrid را ایجاد کنیم. در اینجا می‌توان تنظیم کرد که آیا نیاز است اطلاعات نمایش داده شده دارای صفحه بندی (canPage) خودکار باشند؟ منبع داده (source) کدام است. در صورت فعال سازی صفحه بندی خودکار، چه تعداد ردیف (rowsPerPage) در هر صفحه نمایش داده شود. آیا نیاز است بتوان با کلیک بر روی سر ستون‌ها، اطلاعات را بر اساس فیلد متناظر با آن مرتب (canSort) ساخت؟ همچنین در صورت نیاز به مرتب سازی، اولین باری که گرید نمایش داده می‌شود، بر اساس چه فیلدی (defaultSort) باید مرتب شده نمایش داده شود:

@{ 
var grid = new WebGrid(
source: Model,
canPage: true,
rowsPerPage: 10,
canSort: true,
defaultSort: "FirstName"
);
var salaryPageSum = 0;
var taxPageSum = 0;
var rowIndex = ((grid.PageIndex + 1) * grid.RowsPerPage) - (grid.RowsPerPage - 1);
}

در اینجا همچنین سه متغیر کمکی هم تعریف شده که از این‌ها برای تهیه جمع ستون‌های حقوق و مالیات و همچنین نمایش شماره ردیف جاری استفاده می‌شود. فرمول نحوه محاسبه اولین ردیف هر صفحه را هم ملاحظه می‌کنید. شماره ردیف‌های بعدی، rowIndex++ خواهند بود.


تعریف رنگ و لعاب گرید نمایش داده شده
در ادامه به کمک متد grid.GetHtml، رشته‌ای معادل اطلاعات HTML صفحه جاری، بازگشت داده می‌شود. در اینجا می‌توان یک سری خواص تکمیلی را تنظیم نمود. برای مثال:
tableStyle: "webgrid",
headerStyle: "webgrid-header",
footerStyle: "webgrid-footer",
alternatingRowStyle: "webgrid-alternating-row",
selectedRowStyle: "webgrid-selected-row",
rowStyle: "webgrid-row-style",
htmlAttributes: new { id = "MyGrid" },

هر کدام از این رشته‌ها در حین رندر نهایی گرید،‌ تبدیل به یک class خواهند شد. برای نمونه:

<div id="container">
<table class="webgrid" id="MyGrid">
<thead>
<tr class="webgrid-header">

به این ترتیب با اندکی ویرایش css سایت، می‌توان انواع و اقسام رنگ‌ها را به سطرها و ستون‌های گرید نهایی اعمال کرد. برای مثال اطلاعات زیر را به فایل css سایت اضافه نمائید:

/* Styles for WebGrid
-----------------------------------------------------------*/
.webgrid
{
width: 100%;
margin: 0px;
padding: 0px;
border: 0px;
border-collapse: collapse;
font-family: Tahoma;
font-size: 9pt;
}

.webgrid a
{
color: #000;
}

.webgrid-header
{
padding: 0px 5px;
text-align: center;
border-bottom: 2px solid #739ace;
height: 20px;
border-top: 2px solid #D6E8FF;
border-left: 2px solid #D6E8FF;
border-right: 2px solid #D6E8FF;
}

.webgrid-header th
{
background-color: #eaf0ff;
border-right: 1px solid #ddd;
}

.webgrid-footer
{
padding: 6px 5px;
text-align: center;
background-color: #e8eef4;
border-top: 2px solid #3966A2;
height: 25px;
border-bottom: 2px solid #D6E8FF;
border-left: 2px solid #D6E8FF;
border-right: 2px solid #D6E8FF;
}

.webgrid-alternating-row
{
height: 22px;
background-color: #f2f2f2;
border-bottom: 1px solid #d2d2d2;
border-left: 2px solid #D6E8FF;
border-right: 2px solid #D6E8FF;
}

.webgrid-row-style
{
height: 22px;
border-bottom: 1px solid #d2d2d2;
border-left: 2px solid #D6E8FF;
border-right: 2px solid #D6E8FF;
}

.webgrid-selected-row
{
font-weight: bold;
}

.text-align-center-col
{
text-align: center;
}

.total-row
{
background-color:#f9eef4;
}

همانطور که ملاحظه می‌کنید، رنگ‌های ردیف‌ها، هدر و فوتر گرید و غیره در اینجا تنظیم می‌شوند.
به علاوه اگر دقت کرده باشید در تعاریف گرید، htmlAttributes هم مقدار دهی شده است. در اینجا به کمک یک anonymously typed object، مقدار id گرید مشخص شده است. از این id در حین کار با jQuery‌ استفاده خواهیم کرد.


تعیین نوع Pager
پارامتر دیگری که در متد grid.GetHtml تنظیم شده است، mode: WebGridPagerModes.All می‌باشد. WebGridPagerModes یک enum با محتوای زیر است و توسط آن می‌توان نوع Pager گرید را تعیین کرد:

[Flags]
public enum WebGridPagerModes
{
Numeric = 1,
//
NextPrevious = 2,
//
FirstLast = 4,
//
All = 7,
}

نحوه تعریف ستون‌های گرید
اکنون به مهم‌ترین قسمت تهیه گزارش رسیده‌ایم. در اینجا با مقدار دهی پارامتر columns، نحوه نمایش اطلاعات ستون‌های مختلف مشخص می‌گردد. مقداری که باید در اینجا تنظیم شود، آرایه‌ای از نوع WebGridColumn می‌باشد و مرسوم است به کمک متد کمکی grid.Columns،‌ اینکار را انجام داد.
متد کمکی grid.Column، یک وهله از شیء WebGridColumn را بر می‌گرداند و از آن برای تعریف هر ستون استفاده خواهیم کرد. توسط پارامتر columnName آن،‌ نام فیلدی که باید اطلاعات ستون جاری از آن اخذ شود مشخص می‌شود. به کمک پارامتر header،‌ عبارت سرستون متناظر تنظیم می‌گردد. پارامتر format، مهم‌ترین و توانمندترین پارامتر متد grid.Column است:

grid.Column(columnName: "FirstName", header: "First Name",
format: @<span style='font-weight: bold'>@item.FirstName</span>,
style: "text-align-center-col"),
grid.Column(columnName: "LastName", header: "Last Name"),

پارامتر format، به نحو زیر تعریف شده است:

Func<dynamic, object> format

به این معنا که هر بار پیش از رندر سطر جاری، زمانیکه قرار است سلولی رندر شود، یک شیء dynamic در اختیار شما قرار می‌گیرد. این شیء dynamic یک رکورد از اطلاعات Model جاری است. به این ترتیب به اطلاعات تمام سلول‌های ردیف جاری دسترسی خواهیم داشت. بر این اساس هر نوع پردازشی را که لازم بود، انجام دهید (شبیه به فرمول نویسی در ابزارهای گزارش سازی، اما اینبار با کدهای سی شارپ) و مقدار فرمت شده نهایی را به صورت یک رشته بر گردانید. این رشته نهایتا در سلول جاری درج خواهد شد.
اگر از پارامتر فرمت استفاده نشود، همان مقدار فیلد جاری بدون تغییری رندر می‌گردد.
حداقل به دو نحو می‌توان پارامتر فرمت را مقدار دهی کرد:

format: @<span style='font-weight: bold'>@item.FirstName</span>
or
format: item =>
{
salaryPageSum += item.Salary;
return string.Format("${0:n0}", item.Salary);
}

مستقیما از توانمندی‌های Razor استفاده کنید. مثلا یک تگ کامل را بدون نیاز به محصور سازی آن بین "" شروع کنید. سپس @item به وهله‌ای از رکورد در دسترس اشاره می‌کند که در اینجا وهله‌ای از شیء کارمند است.
و یا همانند روشی که برای محاسبه جمع حقوق هر صفحه مشاهده می‌کنید، مستقیما از lambda expressions برای تعریف یک anonymous delegate استفاده کنید.


نحوه اضافه کردن ستون ردیف
ستون ردیف، یک ستون محاسبه شده (calculated field) است:

grid.Column(header: "#",
style: "text-align-center-col",
format: @<text>@(rowIndex++)</text>),

نیازی نیست حتما یک grid.Column، به فیلدی در کلاس کارمند اشاره کند. مقدار سفارشی آن را به کمک پارامتر format تعیین خواهیم کرد. هر بار که قرار است یک ردیف رندر شود، یکبار این پارامتر فراخوانی خواهد شد. فرمول محاسبه rowIndex ابتدای صفحه را نیز پیشتر ملاحظه نمودید.


نحوه اضافه کردن ستون سفارشی تصاویر کارمندها
ستون تصویر کارمندها نیز مستقیما در کلاس کارمند تعریف نشده است. بنابراین می‌توان آن‌را با مقدار دهی صحیح پارامتر format ایجاد کرد:

grid.Column(header: "Image",
style: "text-align-center-col",
format: @<text><img alt="@item.Id" src="@Url.Content("~/images/" + @item.Id + ".jpg")" /></text>),


در این مثال، تصاویر کارمندها در پوشه images واقع در ریشه سایت، قرار دارند. به همین جهت از متد Url.Content برای مقدار دهی صحیح آن استفاده کردیم. به علاوه در اینجا @item.Id به Id رکورد در حال رندر اشاره می‌کند.


نحوه تبدیل تاریخ‌ها به تاریخ شمسی
در ادامه بازهم به کمک پارامتر format، یک وهله از شیء dynamic اشاره کننده به رکورد در حال رندر را دریافت می‌کنیم. سپس فرصت خواهیم داشت تا بر این اساس، فرمول نویسی کنیم. دست آخر هم رشته مورد نظر نهایی را بازگشت می‌دهیم:

grid.Column(columnName: "AddDate", header: "Start",
style: "text-align-center-col",
format: item =>
{
int ym = item.AddDate.Year;
int mm = item.AddDate.Month;
int dm = item.AddDate.Day;
var persianCalendar = new PersianCalendar();
int ys = persianCalendar.GetYear(new DateTime(ym, mm, dm, new GregorianCalendar()));
int ms = persianCalendar.GetMonth(new DateTime(ym, mm, dm, new GregorianCalendar()));
int ds = persianCalendar.GetDayOfMonth(new DateTime(ym, mm, dm, new GregorianCalendar()));
return ys + "/" + ms.ToString("00") + "/" + ds.ToString("00");
}),


اضافه کردن ستون سفارشی مالیات
در کلاس کارمند، خاصیت حقوق وجود دارد اما مالیات خیر. با توجه به آن می‌توانیم به کمک پارامتر format، به اطلاعات شیء dynamic در حال رندر دسترسی داشته باشیم. بنابراین به اطلاعات حقوق دسترسی داریم و سپس با کمی فرمول نویسی، مقدار نهایی مورد نظر را بازگشت خواهیم داد. همچنین در اینجا می‌توان نحوه بازگشت مقدار حقوق را به صورت رشته‌ای حاوی جدا کننده‌های سه رقمی نیز مشاهده کرد:

grid.Column(columnName: "Salary", header: "Salary",
format: item =>
{
salaryPageSum += item.Salary;
return string.Format("${0:n0}", item.Salary);
},
style: "text-align-center-col"),
grid.Column(header: "Tax", canSort: true,
format: item =>
{
var tax = item.Salary * 0.2;
taxPageSum += tax;
return string.Format("${0:n0}", tax);
}),


اضافه کردن گردید‌های تو در تو
متد Grid.GetHtml، یک رشته را بر می‌گرداند. بنابراین در هر چند سطح که نیاز باشد می‌توان یک گرید را بر اساس اطلاعات دردسترس رندر کرد و سپس بازگشت داد:

grid.Column(header: "Projects", columnName: "Projects",
style: "text-align-center-col",
format: item =>
{
var subGrid = new WebGrid(
source: item.Projects,
canPage: false,
canSort: false
);
return subGrid.GetHtml(
htmlAttributes: new { id = "MySubGrid" },
tableStyle: "webgrid",
headerStyle: "webgrid-header",
footerStyle: "webgrid-footer",
alternatingRowStyle: "webgrid-alternating-row",
selectedRowStyle: "webgrid-selected-row",
rowStyle: "webgrid-row-style"
);
}),


در اینجا کار اصلی از طریق پارامتر format شروع می‌شود. سپس به کمک item.Projects به لیست پروژه‌های هر کارمند دسترسی خواهیم داشت. بر این اساس یک گرید جدید را تولید کرد و سپس رشته معادل با آن را به کمک متد subGrid.GetHtml دریافت و بازگشت می‌دهیم. این رشته در سلول جاری درج خواهد شد. به نوعی یک گزارش master detail یا sub report را تولید کرده‌ایم.


اضافه کردن دکمه‌های ویرایش، حذف و انتخاب
هر سه دکمه ویرایش، حذف و انتخاب در ستون‌هایی سفارشی قرار خواهند گرفت. بنابراین مقدار دهی header و format متد grid.Column کفایت می‌کند:

grid.Column(header: "",
style: "text-align-center-col",
format: item => @Html.ActionLink(linkText: "Edit", actionName: "Edit",
controllerName: "Home", routeValues: new { id = item.Id },
htmlAttributes: null)),
grid.Column(header: "",
format: @<form action="/Home/Delete/@item.Id" method="post"><input type="submit"
onclick="return confirm('Do you want to delete this record?');"
value="Delete"/></form>),
grid.Column(header: "", format: item => item.GetSelectLink("Select"))


نکته جدیدی که در اینجا وجود دارد متد item.GetSelectLink می‌باشد. این متد جزو متدهای توکار گرید است و کار آن بازگشت دادن شیء grid.SelectedRow می‌باشد. این شیء پویا، حاوی اطلاعات رکورد انتخاب شده است. برای مثال اگر نیاز باشد این اطلاعات به صفحه‌ای ارسال شود، می‌توان از روش زیر استفاده کرد:

@if (@grid.HasSelection)
{
@RenderPage("~/views/path/_partial_view.cshtml", new { Employee = grid.SelectedRow })
}


نمایش برچسب‌های صفحه x از n و رکوردهای x تا y از z
در یک گزارش خوب باید مشخص باشد که صفحه جاری، کدامین صفحه از چه تعداد صفحه کلی است. یا رکوردهای صفحه جاری چه بازه‌ای از تعداد رکوردهای کلی را تشکیل می‌دهند. برای این منظور چند متد کمکی به نام‌های WebGridPageFirstItem و WebGridPageLastItem تهیه شده‌اند که آن‌ها را در ابتدای View ارائه شده، مشاهده نمودید:

<strong>Page:</strong> @(grid.PageIndex + 1) / @grid.PageCount, 
<strong>Records:</strong> @WebGridPageFirstItem(@grid) - @WebGridPageLastItem(@grid) of @grid.TotalRowCount

نمایش جمع ستون‌های حقوق و مالیات در هر صفحه
گرید توکار همراه با ASP.NET MVC در این مورد راه حلی را ارائه نمی‌دهد. بنابراین باید اندکی دست به ابتکار زد. مثلا:

@section script{
<script type="text/javascript">
$(function () {
$('#MyGrid tbody:first').append(
'<tr class="total-row"><td></td>\
<td></td><td></td><td></td>\
<td><strong>Total:</strong></td>\
<td>@string.Format("${0:n0}", @salaryPageSum)</td>\
<td>@string.Format("${0:n0}", @taxPageSum)</td>\
<td></td><td></td><td></td></tr>');
});
</script>
}

در این مثال به کمک jQuery با توجه به اینکه id گرید ما MyGrid است، یک ردیف سفارشی که همان جمع محاسبه شده است، به tbody جدول نهایی تولیدی اضافه می‌شود. از tbody:first هم در اینجا استفاده شده است تا ردیف اضافه شده به گریدهای تو در تو اعمال نشود.
سپس فایل Views\Shared\_Layout.cshtml را گشوده و از section تعریف شده، برای مقدار دهی master page سایت، استفاده نمائید:

<head>
<title>@ViewBag.Title</title>
<link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
<script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
@RenderSection("script", required: false)
</head>

نظرات مطالب
اعتبارسنجی مبتنی بر کوکی‌ها در ASP.NET Core 2.0 بدون استفاده از سیستم Identity
- ابتدا باید هدر مورد انتظار را مشخص کنید:
public void ConfigureServices(IServiceCollection services)
{
   services.AddMvc();
   services.AddAntiforgery(options => options.HeaderName = "__RequestVerificationToken"); 
}
- بعد متدی را برای تولید توکن در فایل razor درج کنید:
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf

@functions{
    public string GetAntiXsrfRequestToken()
    {
        return Xsrf.GetAndStoreTokens(Context).RequestToken;
    }
}
- سپس اگر از jQuery استفاده می‌کنید، به صورت زیر این هدر با هر درخواستی به سمت سرور ارسال می‌شود:
<script type="text/javascript">
  $.ajaxPrefilter(function (options, originalOptions, jqXHR) {
      jqXHR.setRequestHeader("__RequestVerificationToken", '@GetAntiXsrfRequestToken()');
  });
</script>
مطالب
دو نکته کوتاه در مورد RSS های فارسی

اولین نکته مربوط به تاریخ هر مدخل (entry) می‌شود. این تاریخ نباید شمسی باشد! این تاریخ باید حتما استاندارد باشد. عموما یکی از دو استاندارد زیر باید مورد استفاده قرار گیرد:


RFC #822
http://www.ietf.org/rfc/rfc0822.txt
Standard for ARPA Internet Text Messages (Date and Time Specification)

RFC #3339
http://www.ietf.org/rfc/rfc3339.txt
Date and Time on the Internet (Timestamps)


برای مثال در دات نت برای تولید این فرمت استاندارد می‌توان به صورت زیر عمل کرد:
DateTime.Now.ToUniversalTime().ToString("r")

متاسفانه بسیاری از برنامه نویس‌های هم وطن این نکته را رعایت نمی‌کنند و برنامه‌های فیدخوان را دچار مشکل می‌کنند. (برای نمونه برنامه معروف feedDemon تاریخ چند سال پیش را ثبت خواهد کرد و در به روز رسانی و دنبال کردن مطالب سایت مورد نظر دچار مشکل خواهد شد)

چند مثال از این دست: (سورس صفحه را در مرورگر مطالعه نمائید)
http://www.faradade.com/Xml/RSS.xml
و یا
http://www.ayande.ir/atom.xml
و یا
http://www.tci-sk.ir/Rss.aspx
(ایشان بهتر است علاوه بر این مورد، از XmlTextWriter استفاده کنند و خروجی را به صورت یک فایل xml و نه html در مرورگر Flush کنند)

و یا بدتر از این بعضی از سایت‌ها آموزش‌های غلطی را هم ارائه می‌دهند:
http://www.faradade.com/Article.aspx?code=a726ae6a-f8e1-4b29-88b4-8e7a04e6d06d
به قسمت pubDate دقت کنید.
مطابق معمول این آموزش الان در 200 سایت کپی و پیست شده! عنوان آموزش را در گوگل جستجو کنید!
این کد آموزش داده شده یک ایراد دیگر هم دارد. آیا الزامی دارد که حتما قسمت con.Close به همین ترتیب نوشته شده اجرا شود؟ اگر این بین خطایی رخ دهد تکلیف این کانکشن باز و سایر موارد چه خواهد شد؟ کلا استفاده از try و finally و یا استفاده از using را برای چه هدفی اختراع کرده‌اند؟

و یا بعضی از سایت‌ها این مورد را رعایت می‌کنند اما به صورت نصفه و نیمه. برای مثال: (تاریخ ارائه شده کامل نیست. بنابراین استاندارد تلقی نخواهد شد)
http://www.srco.ir/Articles/RSSArticles.xml

برای آزمایش میزان استاندارد بودن خروجی فید خود می‌توان از سرویس زیر استفاده کرد:
http://validator.w3.org/feed/

مطلب دیگر ایراد نیست بلکه نکته‌ای است که حداقل از IE7 به بعد رعایت می‌شود:
لطفا زبان فید را مشخص کنید! بله، اگر این مورد را مشخص کنید، از IE7 به بعد فید فارسی به صورت خودکار از راست به چپ نمایش داده می‌شود و این امر سبب سهولت خواندن مطالب فارسی سایت شما خواهد شد.
مشاهده اصل مطلب که توسط یکی از اعضای تیم مربوطه مایکروسافت نوشته شده:
مشاهده

اصلاحیه برای RSS فارسی:
<language>fa-IR</language>

و برای Atom فارسی:
<feed xml:lang="fa">

و اگر می‌خواهید خروجی استانداردی داشته باشید، کتابخانه سورس باز زیر توصیه می‌شود:
http://www.codeplex.com/Argotic

با تشکر از همکاری شما!

مطالب
طراحی گردش کاری با استفاده از State machines - قسمت اول
State machine چیست؟

State machine مدلی است بیانگر نحوه واکنش سیستم به وقایع مختلف. یک ماشین حالت وضعیت جاری قسمتی از سیستم را نگهداری کرده و به ورودی‌های مختلف پاسخ می‌دهد. این ورودی‌ها در نهایت وضعیت سیستم را تغییر خواهند داد.
نحوه پاسخگویی یک ماشین حالت (State machine) را به رویدادی خاص، انتقال (Transition) می‌نامند. در یک انتقال مشخص می‌شود که ماشین حالت بر اساس وضعیت جاری خود، با دریافت یک رویداد، چه عکس العملی را باید بروز دهد. عموما (و نه همیشه) در حین پاسخگویی ماشین حالت به رویدادهای رسیده، وضعیت آن نیز تغییر خواهد کرد. در اینجا گاهی از اوقات پیش از انجام عملیاتی، نیاز است شرطی بررسی شده و سپس انتقالی رخ دهد. به این شرط، guard گفته می‌شود.
بنابراین به صورت خلاصه، یک ماشین حالت، مدلی است از رفتاری خاص، تشکیل شده از حالات، رویدادها، انتقالات، اعمال (actions) و شرط‌ها (Guards). در اینجا:
- یک حالت (State)، شرطی منحصربفرد در طول عمر ماشین حالت است. در هر زمان مشخصی، ماشین حالت در یکی از حالات از پیش تعریف شده خود قرار خواهد داشت.
- یک رویداد (Event)، اتفاقی است که به ماشین حالت اعمال می‌شود؛ یا همان ورودی‌های سیستم.
- یک انتقال (Transition)، بیانگر نحوه رفتار ماشین حالت جهت پاسخگویی به رویداد وارده بر اساس وضعیت جاری خود می‌باشد. در طی یک انتقال، سیستم از یک حالت به حالتی دیگر منتقل خواهد شد.
- برای انجام یک انتقال، نیاز است یک شرط (Guard/Conditional Logic) بررسی شده و در صورت true بودن آن، انتقال صورت گیرد.
- یک عمل (Action)، بیانگر نحوه پاسخگویی ماشین حالت در طول دوره انتقال است.


چگونه می‌توان الگوی ماشین حالت را تشخیص داد؟

اکثر برنامه‌های وب، متشکل از پیاده سازی چندین ماشین حالت می‌باشند؛ مانند ثبت نام در سایت، درخواست یک کتاب از کتابخانه، ارسال درخواست‌ها و پاسخگویی به آن‌ها و یا حتی ارسال یک مطلب در سایت، تائید و انتشار آن.
البته عموما در حین طراحی برنامه‌ها، کمتر به این نوع مسایل به شکل یک ماشین حالت نگاه می‌شود. به همین جهت بهتر است معیارهایی را برای شناخت زود هنگام آن‌ها مدنظر داشته باشیم:
- آیا در جدول بانک اطلاعاتی خود فیلدهایی مانند State (حالت) یا Status (وضعیت)دارید؟ اگر بله، به این معنا است که در حال کار با یک ماشین حالت هستید.
- عموما فیلدهای Bit و Boolean، بیانگر حضور ماشین‌های حالت هستند. مانند IsPublished ، IsPaid و یا حتی داشتن یک فیلد timeStamp که می‌تواند NULL بپذیرد نیز بیانگر استفاده از ماشین حالت است؛ مانند فیلدهای published_at، paid_at و یا confirmed_at.
- داشتن رکوردهایی که تنها در طول یک بازه زمانی خاص، معتبر هستند. برای مثال آبونه شدن در یک سایت در طول یک بازه زمانی مشخص.
- اعمال چند مرحله‌ای؛ مانند ثبت نام در سایت و دریافت ایمیل فعال سازی. سپس فعال سازی اکانت از طریق ایمیل.


مثالی ساده از یک ماشین حالت

یک کلید برق را در نظر بگیرید. این کلید دارای دو حالت (states) روشن و خاموش است. زمانی که خاموش است، با دریافت رخدادی (event)، به وضعیت (state/status) روشن، منتقل خواهد شد (Transition) و برعکس.


در اینجا حالات با مستطیل‌های گوشه گرد نمایش داده شده‌اند. انتقالات توسط فلش‌هایی انحناء دار که حالات را به یکدیگر متصل می‌کنند، مشخص گردیده‌اند. برچسب‌های هر فلش، مشخص کننده نام رویدادی است که سبب انتقال و تغییر حالت می‌گردد. با شروع یک ماشین حالت، این ماشین در یکی از وضعیت‌های از پیش تعیین شده‌اش قرار خواهد گرفت (initial state)؛ که در اینجا حالت خاموش است.
این نوع نمودارها می‌توانند شامل جزئیات بیشتری نیز باشند؛ مانند برچسب‌هایی که نمایانگر اعمال قابل انجام در طی یک انتقال هستند.


رسم ماشین‌های حالت در برنامه‌های وب، به کمک کتابخانه jsPlumb

کتابخانه‌های زیادی برای رسم فلوچارت، گردش‌های کاری، ماشین‌های حالت و امثال آن جهت برنامه‌های وب وجود دارند و یکی از معروف‌ترین‌های آن‌ها کتابخانه jsPlumb است. این کتابخانه به صورت یک افزونه jQuery طراحی شده است؛ اما به عنوان افزونه‌ای برای کتابخانه‌های MooTools و یا YUI3/Yahoo User Interface 3 نیز قابل استفاده می‌باشد. کتابخانه jsPlumb در مرورگرهای جدید از امکانات ترسیم SVG و یا HTML5 Canvas استفاده می‌کند. برای سازگاری با مرورگرهای قدیمی‌تر مانند IE8 به صورت خودکار به VML سوئیچ خواهد کرد. همچنین این کتابخانه امکانات ترسیم تعاملی قطعات به هم متصل شونده را نیز دارا است (شبیه به طراح یک گردش کاری). البته برای اضافه شدن امکاناتی مانند کشیدن و رها کردن در آن نیاز به jQuery-UI نیز خواهد داشت.
برای نمونه اگر بخواهیم مثال فوق را توسط jsPlumb ترسیم کنیم، روش کار به صورت زیر خواهد بود:
<!doctype html>
<html>
<head>
    <title>State Machine Demonstration</title>
    <style type="text/css">
        #opened
        {
            left: 10em;
            top: 5em;
        }
        
        #off
        {
            left: 12em;
            top: 15em;
        }
        
        #on
        {
            left: 28em;
            top: 15em;
        }
        
        .w
        {
            width: 5em;
            padding: 1em;
            position: absolute;
            border: 1px solid black;
            z-index: 4;
            border-radius: 1em;
            border: 1px solid #346789;
            box-shadow: 2px 2px 19px #e0e0e0;
            -o-box-shadow: 2px 2px 19px #e0e0e0;
            -webkit-box-shadow: 2px 2px 19px #e0e0e0;
            -moz-box-shadow: 2px 2px 19px #e0e0e0;
            -moz-border-radius: 0.5em;
            border-radius: 0.5em;
            opacity: 0.8;
            filter: alpha(opacity=80);
            cursor: move;
        }
        
        .ep
        {
            float: right;
            width: 1em;
            height: 1em;
            background-color: #994466;
            cursor: pointer;
        }
        
        .labelClass
        {
            font-size: 20pt;
        }
    </style>
    <script type="text/javascript" src="jquery.min.js"></script>
    <script type="text/javascript" src="jquery-ui.min.js"></script>
    <script type="text/javascript" src="jquery.jsPlumb-all-min.js"></script>
    <script type="text/javascript">
        $(document).ready(function () {

            jsPlumb.importDefaults({
                Endpoint: ["Dot", { radius: 5}],
                HoverPaintStyle: { strokeStyle: "blue", lineWidth: 2 },
                ConnectionOverlays: [
["Arrow", { location: 1, id: "arrow", length: 14, foldback: 0.8}]
]
            });

            jsPlumb.makeTarget($(".w"), {
                dropOptions: { hoverClass: "dragHover" },
                anchor: "Continuous"
            });

            $(".ep").each(function (i, e) {
                var p = $(e).parent();
                jsPlumb.makeSource($(e), {
                    parent: p,
                    anchor: "Continuous",
                    connector: ["StateMachine", { curviness: 20}],
                    connectorStyle: { strokeStyle: '#42a62c', lineWidth: 2 },
                    maxConnections: 2,
                    onMaxConnections: function (info, e) {
                        alert("Maximum connections (" + info.maxConnections + ") reached");
                    }
                });
            });

            jsPlumb.bind("connection", function (info) {
            });

            jsPlumb.draggable($(".w"));

            jsPlumb.connect({ source: "opened", target: "off" });
            jsPlumb.connect({ source: "off", target: "on", label: "Turn On" });
            jsPlumb.connect({ source: "on", target: "off", label: "Turn Off" });
        });
    </script>
</head>
<body>
    <div class="w" id="opened">
        Begin
        <div class="ep">
        </div>
    </div>
    <div class="w" id="off">
        Off
        <div class="ep">
        </div>
    </div>
    <div class="w" id="on">
        On
        <div class="ep">
        </div>
    </div>
</body>
</html>
مستندات کامل jsPlumb را در سایت آن می‌توان ملاحظه نمود.
در مثال فوق، ابتدا css و فایل‌های js مورد نیاز ذکر شده‌اند. توسط css، مکان قرارگیری اولیه المان‌های متناظر با حالات، مشخص می‌شوند.
سپس زمانیکه اشیاء صفحه در دسترس هستند، تنظیمات jsPlumb انجام خواهد شد. برای مثال در اینجا نوع نمایشی Endpoint‌ها به نقطه تنظیم شده است. موارد دیگری مانند مستطیل نیز قابل تنظیم است. سپس نیاز است منبع و مقصدها به کتابخانه jsPlumb معرفی شوند. به کمک متد jsPlumb.makeTarget، تمام المان‌های دارای کلاس w به عنوان منبع و با شمارش divهایی با class=ep، مقصدهای قابل اتصال تعیین شده‌اند (jsPlumb.makeSource). متد jsPlumb.bind یک callback function است و هربار که اتصالی برقرار می‌شود، فراخوانی خواهد شد. متد jsPlumb.draggable تمام عناصر دارای کلاس w را قابل کشیدن و رها کردن می‌کند و در آخر توسط متدهای jsPlumb.connect، مقصد و منبع‌های مشخصی را هم متصل خواهیم کرد. نمونه نهایی تهیه شده برای بررسی بیشتر.


برای مطالعه بیشتر
Finite-state machine
UML state machine
UML 2 State Machine Diagrams
مثال‌هایی در این مورد

مطالب
استفاده از Froala WYSIWYG Editor در ASP.NET
چندی قبل، معرفی ادیتور سبک وزن و مناسبی را تحت عنوان RedActor، در این سایت ملاحظه کردید. زمانیکه این‌کار انجام شد، این ادیتور هم رایگان بود و هم سورس آخرین نگارش آن به سادگی در دسترس. بعد از مدتی، هر دو ویژگی یاد شده‌ی RedActor حذف شدند. پس از آن ادیتور مدرن و بسیار مناسب دیگری به نام Froala منتشر شد که هرچند نگارش‌های تجاری هم دارد، اما سورس آخرین نگارش آن برای عموم قابل دریافت است. در ادامه مروری خواهیم داشت بر نحوه‌ی یکپارچه سازی آن با ASP.NET MVC و همچنین ASP.NET Web forms.


دریافت آخرین نگارش Froala WYSIWYG Editor
برای دریافت فایل‌های آخرین نگارش این ادیتور وب می‌توانید به سایت آن، قسمت دریافت فایل‌ها مراجعه نمائید.
http://editor.froala.com/download
و یا به این آدرس مراجعه کنید:
https://github.com/froala/wysiwyg-editor/releases 


ساختار پروژه و نحوه‌ی کپی فایل‌های آن
در هر دو مثالی که فایل‌های آن‌را از انتهای بحث می‌توانید دریافت کنید، این ساختار رعایت شده است:


فایل‌های CSS و فونت‌های آن، در پوشه‌ی Content قرار گرفته‌اند.
فایل‌های اسکریپت و زبان آن (که دارای زبان فارسی هم هست) در پوشه‌ی Scripts کپی شده‌اند.

یک نکته
فایل font-awesome.css را نیاز است کمی اصلاح کنید. مسیر پوشه‌ی فونت‌های آن اکنون با fonts شروع می‌شود.


تنظیمات اولیه

تفاوتی نمی‌کند که از وب فرم‌ها استفاده می‌کنید یا MVC، نحوه‌ی تعریف و افزودن پیش نیازهای این ادیتور به نحو ذیل است:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>

    <link href="Content/font-awesome.css" rel="stylesheet" />
    <link href="Content/froala_editor.css" rel="stylesheet" />

    <script src="Scripts/jquery-1.10.2.min.js"></script>
    <script src="Scripts/froala_editor.min.js"></script>
    <script src="Scripts/langs/fa.js"></script>
</head>
<body>
    <form id="form1" runat="server">
    </form>
</body>
</html>
دو فایل CSS دارد (آیکن‌های آن و همچنین شیوه نامه‌ی اصلی ادیتور) به همراه سه فایل JS (جی‌کوئری، ادیتور و فایل زبان فارسی آن) که باید در فایل master یا layout سایت اضافه شوند.


استفاده از Froala WYSIWYG Editor در ASP.NET MVC

در ادامه نحوه‌ی فعال سازی ادیتور وب Froala را در یک View برنامه‌های ASP.NET MVC ملاحظه می‌کنید:
@{
    ViewBag.Title = "Index";
}

<style type="text/css">
    /*تنظیم فونت پیش فرض ادیتور*/
    .froala-element {
    }
</style>

@using (Html.BeginForm(actionName: "Index", controllerName: "Home"))
{
    @Html.TextArea(name: "Editor1")
    <input type="submit" value="ارسال" />
}

@section Scripts
{
    <script type="text/javascript">
        $(function () {
            $('#Editor1').editable({
                buttons: ["bold", "italic", "underline", "strikeThrough", "fontFamily",
                    "fontSize", "color", "formatBlock", "align", "insertOrderedList",
                    "insertUnorderedList", "outdent", "indent", "selectAll", "createLink",
                    "insertImage", "insertVideo", "undo", "redo", "html", "save", "inserthorizontalrule"],
                inlineMode: false,
                inverseSkin: true,
                preloaderSrc: '@Url.Content("~/Content/img/preloader.gif")',
                allowedImageTypes: ["jpeg", "jpg", "png"],
                height: 300,
                language: "fa",
                direction: "rtl",
                fontList: ["Tahoma, Geneva", "Arial, Helvetica", "Impact, Charcoal"],
                autosave: true,
                autosaveInterval: 2500,
                saveURL: '@Url.Action("FroalaAutoSave", "Home")',
                saveParams: { postId: "123" },
                spellcheck: true,
                plainPaste: true,
                imageButtons: ["removeImage", "replaceImage", "linkImage"],
                borderColor: '#00008b',
                imageUploadURL: '@Url.Action("FroalaUploadImage", "Home")',
                imageParams: { postId: "123" },
                enableScript: false
            });
        });
    </script>
}
اگر می‌خواهید فونت پیش فرض آن را تنظیم کنید، باید مطابق کدهای ابتدای فایل، ویژگی‌های froala-element را تغییر دهید.
سپس این ادیتور را بر روی المان TextArea قرار گرفته در صفحه، فعال می‌کنیم.
در قسمت مقادیر buttons، تمام حالات ممکن پیش بینی شده‌اند. هر کدام را که نیاز ندارید، حذف کنید.
نحوه‌ی تعریف زبان و راست به چپ بودن این ادیتور را با مقدار دهی پارامترهای language و direction ملاحظه می‌کنید.

پارامترهای autosave، saveURL و saveParams کار تنظیم ارسال خودکار محتوای ادیتور را جهت ذخیره‌ی آن در سرور به عهده دارند. بر اساس مقدار autosaveInterval می‌توان مشخص کرد که هر چند میلی ثانیه یکبار این‌کار باید انجام شود.
        /// <summary>
        /// ذخیره سازی خودکار
        /// </summary>
        [HttpPost]
        [ValidateInput(false)]
        public ActionResult FroalaAutoSave(string body, int? postId) // نام پارامتر بادی را تغییر ندهید
        {
            //todo: save body ...
            return new EmptyResult();
        }
در قسمت سمت سرور هم می‌توان این مقادیر ارسالی را در اکشن متدی که ملاحظه می‌کنید، دریافت کرد.
چون قرار است تگ‌های HTML به سرور ارسال شوند، ویژگی ValidateInput به false تنظیم شده‌است.
saveParams آن، برای مقدار دهی پارامترهای اضافی است که نیاز می‌باشند تا به سرور ارسال شوند. مثلا شماره مطلب جاری نیز به سرور ارسال گردد.
در اینجا نام پارامتری که ارسال می‌گردد، دقیقا مساوی body است. بنابراین آن‌را تغییر ندهید.

پارامترهای imageUploadURL و imageParams برای فعال سازی ذخیره تصاویر آن در سرور کاربرد دارند.
اکشن متد مدیریت کننده‌ی آن به نحو ذیل می‌تواند تعریف شود:
        // todo: مسایل امنیتی آپلود را فراموش نکنید
        /// <summary>
        /// ذخیره سازی تصاویر ارسالی
        /// </summary>
        [HttpPost]
        public ActionResult FroalaUploadImage(HttpPostedFileBase file, int? postId) // نام پارامتر فایل را تغییر ندهید
        {
            var fileName = Path.GetFileName(file.FileName);
            var rootPath = Server.MapPath("~/images/");
            file.SaveAs(Path.Combine(rootPath, fileName));
            return Json(new { link = "images/" + fileName }, JsonRequestBehavior.AllowGet);
        }
در اینجا نام پارامتری که به سرور ارسال می‌گردد، دقیقا معادل file است. بنابراین آن‌را تغییر ندهید.
خروجی آن برای مشخص سازی محل ذخیره سازی تصویر در سرور باید یک خروجی JSON دارای خاصیت و پارامتر link به نحو فوق باشد (این مسیر، یک مسیر نسبی است؛ نسبت به ریشه سایت).
imageParams آن برای مقدار دهی پارامترهای اضافی است که نیاز می‌باشند تا به سرور ارسال شوند. مثلا شماره مطلب جاری نیز به سرور ارسال گردد.


استفاده از Froala WYSIWYG Editor در ASP.NET Web forms
تمام نکاتی که در قسمت تنظیمات ASP.NET MVC در مورد ویژگی‌های سمت کلاینت این ادیتور ذکر شد، در مورد وب فرم‌ها نیز صادق است. فقط قسمت مدیریت سمت سرور آن اندکی تفاوت دارد.
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
    ValidateRequest="false"
    EnableEventValidation="false"
    AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="FroalaWebFormsTest.Default" %>

<%--اعتبارسنجی ورودی غیرفعال شده چون باید تگ ارسال شود--%>
<%--همچنین در وب کانفیگ هم تنظیم دیگری نیاز دارد--%>

<asp:Content ID="Content1" ContentPlaceHolderID="head" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" runat="server">
    <%--حالت کلاینت آی دی بهتر است تنظیم شود در اینجا--%>
    <asp:TextBox ID="txtEditor" ClientIDMode="Static"
        runat="server" Height="199px" TextMode="MultiLine" Width="447px"></asp:TextBox>
    <br />
    <asp:Button ID="btnSave" runat="server" OnClick="btnSave_Click" Text="ارسال" />

    <style type="text/css">
        /*تنظیم فونت پیش فرض ادیتور*/
        .froala-element {
        }
    </style>

    <script type="text/javascript">
        $(function () {
            $('#txtEditor').editable({
                buttons: ["bold", "italic", "underline", "strikeThrough", "fontFamily",
                    "fontSize", "color", "formatBlock", "align", "insertOrderedList",
                    "insertUnorderedList", "outdent", "indent", "selectAll", "createLink",
                    "insertImage", "insertVideo", "undo", "redo", "html", "save", "inserthorizontalrule"],
                inlineMode: false,
                inverseSkin: true,
                preloaderSrc: 'Content/img/preloader.gif',
                allowedImageTypes: ["jpeg", "jpg", "png"],
                height: 300,
                language: "fa",
                direction: "rtl",
                fontList: ["Tahoma, Geneva", "Arial, Helvetica", "Impact, Charcoal"],
                autosave: true,
                autosaveInterval: 2500,
                saveURL: 'FroalaHandler.ashx',
                saveParams: { postId: "123" },
                spellcheck: true,
                plainPaste: true,
                imageButtons: ["removeImage", "replaceImage", "linkImage"],
                borderColor: '#00008b',
                imageUploadURL: 'FroalaHandler.ashx',
                imageParams: { postId: "123" },
                enableScript: false
            });
        });
    </script>
</asp:Content>
همانطور که ملاحظه می‌کنید،  ValidateRequest صفحه به false تنظیم شده و همچنین در وب کانفیگ httpRuntime requestValidationMode به نگارش 2 تنظیم گردیده‌است تا بتوان توسط این ادیتور تگ‌های ارسالی را به سرور ارسال کرد.
به علاوه ClientIDMode=Static نیز تنظیم شده‌است، تا بتوان از ID تکست باکس قرار گرفته در صفحه، به سادگی در کدهای سمت کاربر جی‌کوئری استفاده کرد.
اگر دقت کرده باشید، save urlها اینبار به فایل FroalaHandler.ashx اشاره می‌کنند. محتوای این Genric handler را ذیل مشاهده می‌کنید:
using System.IO;
using System.Web;
using System.Web.Script.Serialization;

namespace FroalaWebFormsTest
{
    public class FroalaHandler : IHttpHandler
    {
        //todo: برای اینکارها بهتر است از وب ای پی آی استفاده شود
        //todo: یا دو هندلر مجزا یکی برای تصاویر و دیگری برای ذخیره سازی متن

        public void ProcessRequest(HttpContext context)
        {
            var body = context.Request.Form["body"];
            var postId = context.Request.Form["postId"];
            if (!string.IsNullOrWhiteSpace(body) && !string.IsNullOrWhiteSpace(postId))
            {
                //todo: save changes

                context.Response.ContentType = "text/plain";
                context.Response.Write("");
                context.Response.End();
            }

            var files = context.Request.Files;
            if (files.Keys.Count > 0)
            {
                foreach (string fileKey in files)
                {
                    var file = context.Request.Files[fileKey];
                    if (file == null || file.ContentLength == 0)
                        continue;

                    //todo: در اینجا مسایل امنیتی آپلود فراموش نشود
                    var fileName = Path.GetFileName(file.FileName);
                    var rootPath = context.Server.MapPath("~/images/");
                    file.SaveAs(Path.Combine(rootPath, fileName));


                    var json = new JavaScriptSerializer().Serialize(new { link = "images/" + fileName });
                    // البته اینجا یک فایل بیشتر ارسال نمی‌شود
                    context.Response.ContentType = "text/plain";
                    context.Response.Write(json);
                    context.Response.End();
                }
            }

            context.Response.ContentType = "text/plain";
            context.Response.Write("");
            context.Response.End();
        }

        public bool IsReusable
        {
            get { return false; }
        }
    }
}
در اینجا نحوه‌ی مدیریت سمت سرور auto save و همچنین ارسال تصاویر ادیتور Froala ، ذکر شده‌اند. با استفاده از context.Request.Form می‌توان به عناصر ارسالی به سرور دسترسی پیدا کرد. همچنین توسط context.Request.Files، اگر فایلی ارسال شده بود، ذخیره شده و نهایتا خروجی JSON مدنظر بازگشت داده می‌شود.


یک نکته‌ی امنیتی مهم
<location path="upload">
  <system.webServer>
    <handlers accessPolicy="Read" />
  </system.webServer>
</location>
تنظیم فوق را در web.config سایت، جهت Read only کردن پوشه‌ی ارسال تصاویر، حتما مدنظر داشته باشید. در اینجا فرض شده‌است که پوشه‌ی uploads قرار است قابلیت اجرای فایل‌های پویا را نداشته باشد.


کدهای کامل این مطلب را در ادامه می‌توانید دریافت کنید
Froala-Sample
 
مطالب
اعتبار سنجی سمت کاربر wysiwyg-editor ها در ASP.NET MVC
تفاوتی نمی‌کند که از کدامیک از HTML Editorها یا به عبارتی wysiwyg-editorهای موجود، جهت ورود اطلاعات استفاده می‌کنید. هیچکدام از آن‌ها سبب فراخوانی اعتبارسنجی Required سمت کاربر نمی‌شوند. چرا؟
علت اینجا است که با فعال سازی wysiwyg-editorهای موجود، المان HTML پیش فرض مانند یک TextArea حالت مخفی پیدا می‌کند:


و اگر به سورس کد فایل jquery.validate.js مراجعه کنید، یک چنین تعریف پیش فرضی در آن موجود است:
$.extend( $.validator, {
defaults: {
ignore: ":hidden",
به این معنا که در حین اعتبارسنجی سمت کاربر، از کلیه المان‌های hidden بر روی صفحه صرفنظر خواهد شد.
برای رفع این مشکل کافی است بنویسیم:
 jQuery.validator.setDefaults({ ignore: ":hidden:not(textarea)" });
در اینجا پیش فرض‌های jQuery validator بازنویسی شده و در آن از textareaهای مخفی صرفنظر نمی‌شود.
اگر می‌خواهید کلا از چیزی صرفنظر نشود، مقدار آن‌را به "" تنظیم کنید.

مشکل دوم! متد required پیش فرض، فقط به رشته‌های خالی یا نال واکنش نشان می‌دهد. اما اگر کاربری در اینجا <p><br/></p> را وارد کرد، چطور؟
در این حالت نیاز است متد rqeuired پیش فرض jQuery validator را به نحو ذیل بازنویسی کنیم:
    <script type="text/javascript">
        // حذف تمام تگ‌های یک قطعه متن
        function removeAllTagsAndTrim(html) {
            return !html ? "" : jQuery.trim(html.replace(/(<([^>]+)>)/ig, ""));
        }

        // این تنظیم برای پردازش ادیتور مخفی وب لازم است
        jQuery.validator.setDefaults({ ignore: ":hidden:not(textarea)" });

        // متد اصلی اعتبارسنجی را ابتدا ذخیره می‌کنیم
        jQuery.validator.methods.originalRequired = jQuery.validator.methods.required;
        // نحوه بازنویسی متد توکار اعتبار سنجی جهت استفاده از یک متد سفارشی
        jQuery.validator.addMethod("required", function (value, element, param) {
            value = removeAllTagsAndTrim(value);
            if (!value) {
                return false;
            }
            //  فراخوانی متد اصلی اعتبار سنجی در صورت شکست تابع سفارشی
            return jQuery.validator.methods.originalRequired.call(this, value, element, param);
        }, jQuery.validator.messages.required);
    </script>
متد removeAllTagsAndTrim کلیه تگ‌های یک عبارت HTML ایی را حذف می‌کند. متد trim جی‌کوئری نیز سبب حذف فواصل خالی در ابتدا و انتهای یک رشته می‌شود. اکنون نیاز است این متد سفارشی را جهت تمیزسازی عبارت ورودی به متد توکار required اعمال کنیم.
برای اینکار نیاز است متد اصلی required را در جایی ذخیره کنیم تا در صورت شکست اعتبارسنجی سفارشی، همان متد اصلی فراخوانی گردد. در ادامه با فراخوانی jQuery.validator.addMethod و بکارگیری نام required، دقیقا همان متد اصلی required موجود بازنویسی خواهد شد. در ابتدای کار تگ‌های ورودی پاک شده و سپس اعتبارسنجی می‌شوند.
در ادامه فراخوانی متد اصلی required را ملاحظه می‌کنید. همچنین با استفاده از jQuery.validator.messages.required اصل پیام خطای تنظیم شده به کاربر نمایش داده خواهد شد.
مطالب
پشتیبانی آنلاین سایت با SignalR ،ASP.NET MVC و AngularJS
  پشتیبانی آنلاین سایت، روشی مناسب برای افزایش سطح تماس مشتریان با فروشندگان، برای جلوگیری از اتلاف وقت در برقراری تماس میباشد.
قصد داریم در این بخش پشتیبانی آنلاین سایت را با استفاده از AngularJs /Asp.Net Mvc / Signalr تهیه کنیم.
امکانات این برنامه:
* امکان مکالمه متنی به همراه ارسال شکلک
* امکان انتقال مکالمه
* مشاهده آرشیو گفتگوها
* امکان ارسال فایل (بزودی)
* امکان ذخیره گفتگو و ارسال گفتگو به ایمیل  (بزودی)
* امکان ارسال تیکت در صورت آفلاین بودن کارشناسان (بزودی) 
* رعایت مسائل امنیتی(بزودی)

مراحل نحوه اجرای برنامه:
1-  باز کردن دو tab، یکی برای کارشناس یکی  برای مشتری .
2-  تعدادی کارشناس تعریف شده است که با کلیک بر روی هر کدام وارد پنل کارشناس خواهیم شد.
3- شروع مکالمه توسط مشتری با کلیک بر روی chatbox پایین صفحه (سمت راست پایین).
4- شروع کردن مکالمه توسط کارشناس. 
5- ادامه،خاتمه یا انتقال مکالمه توسط کارشناس.

نصب کتابخانه‌های زیر:
//client
Install-Package angularjs 
Install-Package angular-strap 
Install-Package Microsoft.AspNet.SignalR.JS 
install-package AngularJs.SignalR.Hub 
Install-Package jQuery.TimeAgo
Install-Package FontAwesome
Install-Package toastr
Install-Package Twitter.Bootstrap.RTL 
bower install angular-smilies  

//server
Install-Package Newtonsoft.Json
Install-Package Microsoft.AspNet.SignalR 
Install-Package EntityFramework

گام‌های برنامه:
1-ایجاد جداول 
جدول Message: هر پیام دارای فرستنده و گیرنده‌ای، به همراه زمان ارسال میباشد.
جدول Session: شامل لیستی از پیام‌ها به همراه ارجاعی به خود (استفاده هنگام انتقال مکالمه )
 public partial class Message
    {
        public int Id { get; set; }
        public string Sender { get; set; }
        public string Receiver { get; set; }
        public string Body { get; set; }
        public DateTimeOffset? CreationTime { get; set; }
        public int? SessionId { get; set; }
        public virtual Session Session { get; set; }
    }
    public partial class Session
    {
        public Session()
        {
           Messages = new List<Message>();
           Sessions = new List<Session>();
        }
        public int Id { get; set; }
        public string AgentName { get; set; }
        public string CustomerName { get; set; }
        public DateTime CreatedDateTime { get; set; }
        public int? ParentId { get; set; }
        public virtual Session Parent { get; set; }
        public virtual ICollection<Message> Messages { get; set; }
        public virtual ICollection<Session> Sessions { get; set; }
    }

2- ایجاد ویو مدلهای زیر
    public class UserInformation
    {
        public string ConnectionId { get; set; }
        public bool IsOnline { get; set; }
        public string UserName { get; set; }
    }
    public class ChatSessionVm
    {
        public string Key { get; set; }
        public List<string> Value { get; set; }
    }
    public class AgentViewModel
    {
        public int Id { get; set; }
        public string CustomerName { get; set; }
        public int Lenght { get; set; }
        public DateTimeOffset? Date { get; set; }
    }

3- ایجاد Hub در سرور
 [HubName("chatHub")]
    public class ChatHub : Microsoft.AspNet.SignalR.Hub
    {
    }

4- فراخوانی chathub توسط کلاینت: برای آشنایی با سرویس  hub کلیک نمایید.

listeners متدهای سمت کلاینت
methods آرایه ای از متدهای سمت سرور

 $scope.myHub = new hub("chatHub", {
  listeners: {},
  methods: []
})
در صورت موفقیت آمیز بودن اتصال به هاب، متد init سمت سرور فراخوانی میشود و وضعیت آنلاین بودن کارشناسان برای کلاینت مشخص میشود.
 $scope.myHub.promise.done(function () {
     $scope.myHub.init();
     $scope.myHub.promise.done(function () { });
  });
 public void Init()
        {
            _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>());
            _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>());
            Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }

5-وضعیت کارشناسان :
در صورت آنلاین بودن کارشناسان: ارسال اولین پیام و تقاضای شروع مکالمه
در صورت آفلاین بودن کارشناسان: ارسال تیکت(بزودی)
اگر برای اولین بار  پیامی را ارسال میکنید، برای شما session ایی ایجاد نشده است. در اینصورت مکان تقاضای مشتری از سایت http://ipinfo.io دریافت شده و به سرور ارسال می‌گردد و  متد logvist سرور، تقاضای شروع مکالمه مشتری را به اطلاع  تمام کارشناسان میرساند و وضعیت chatbox را تغییر میدهد.
اگر session برای مشتری تعریف شده باشد، مکالمه مشتری با کارشناس مربوطه انجام میگردد.
 $scope.requestChat = function (msg) {
                if (!defaultCustomerUserName) {
                    //گرفتن کاربر لاگین شده
                    //ما از آرایه تصادفی استفاده میکنیم
                    var nameDefaultArray = [
                        'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه'
                    ];
                    defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)];
                }
                var userName = defaultCustomerUserName;
                if (!$scope.chatId) {
                    $scope.chatId = sessionStorage.getItem(chatKey);
                    $http.get("http://ipinfo.io")
                      .success(function (response) {
                          $scope.myHub.logVisit(response.city, response.country, msg, userName);
                      }).error(function (e, status, headers, config) {
                          $scope.myHub.logVisit("Tehran", "Ir", msg, userName)
                      });
                    $scope.myHub.requestChat(msg);
                    $scope.chatTitle = $scope.options.waitingForOperator;
                    $scope.pendingRequestChat = true;
                } else {
                    $scope.myHub.clientSendMessage(msg, userName);
                };
                $scope.message = "";
            };

6-مشاهده تقاضای مکالمه کاربران  توسط کارشناسان
کارشناسان در صورت تمایل، شروع به مکالمه با کاربر مینمایند و مکالمه آغاز میگردد.با شروع مکالمه توسط کارشناس، متد acceptRequestChat  سرور فراخوانی میشود.
 پیام‌های مناسب برای کارشناس مربوطه، برای مشتری و تمام کارشناسان (به تمام کارشناسان اطلاع داده می‌شود که مشتری با چه کارشناسی در حال مکالمه میباشد) ارسال میگردد و مقادیر مربوطه در دیتابیس ذخیره میگردد.
public void AcceptRequestChat(string customerConnectionId, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key));
            if (session == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = agent.Key,
                    Value = new List<string> { customerConnectionId }
                });
            }
            else
            {
                session.Value.Add(customerConnectionId);
            }
            Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName);
            Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName);
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId);
            }
       var session = _db.Sessions.Add(new Session
            {
                AgentName = agent.Key,
                CustomerName = userName,
                CreatedDateTime = DateTime.Now
            });
            _db.SaveChanges();

            var message = new Message
            {
                CreationTime = DateTime.Now,
                Sender = agent.Key,
                Receiver = userName,
                body=body,
                Session = session
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
7-خاتمه مکالمه توسط کارشناس یا مشتری امکان پذیر میباشد:
متد closeChat  سرور فراخوانی میگردد. پیام مناسبی به مشتری و تمام کارشناسان ارسال میگردد.
public void CloseChat(string id)
        {
            var findAgent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
            if (session == null) return;
            Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است");

            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id);
            }
            _chatSessions.Remove(session);
        }

8-انتقال مکالمه مشتری به کارشناسی دیگر
مکالمه از کارشناس فعلی گرفته شده و به کارشناس جدید داده می‌شود؛ به همراه ارسال پیام‌های مناسب به طرف‌های مربوطه
   public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId)
        {
            #region remove session of current agent
            var currentAgent = FindAgent(Context.ConnectionId);
            var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId));
            if (currentSession != null)
            {
                _chatSessions.Remove(currentSession);
            }
            #endregion

            #region add  session to new agent
            var newAgent = FindAgent(newAgentId);
            var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key));
            if (newSession == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = newAgent.Key,
                    Value = new List<string> { cumtomerId }
                });
            }
            else
            {
                newSession.Value.Add(cumtomerId);
            }
            #endregion

            Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key,
                "ادامه مکالمه به کارشناس  " + newAgent.Key + "مقابل  منتقل شد");
            Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key,
                "لطفا مکالمه را ادامه دهید.با تشکر");

            Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName,
                "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key);

            var session = _db.Sessions.FirstOrDefault
                (item => item.AgentName.Equals(currentAgent.Value.UserName)
                 && item.CustomerName.Equals(customerName));
            if (session != null)
            {
                var sessionId = session.Id;
                var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId));
                var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings);
                Clients.Client(newAgentId).visitorSwitchConversation
                    (Context.ConnectionId, customerName, result, clientSessionId);
            }
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = newAgent.Key,
                CustomerName = customerName,
                CreatedDateTime = DateTime.Now,
                Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key)
                      && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault()
            });
            _db.SaveChanges();
        }
از آنجاییکه اسم متدها کاملا گویا میباشد، به نظر نیازی به توضیح بیشتری ندارند.
فایل کامل  app.js 
var app = angular.module("app", ["SignalR", 'ngRoute', 'ngAnimate', 'ngSanitize', 'mgcrea.ngStrap', 'angular-smilies']); 

app.config(["$routeProvider", "$provide", "$httpProvider", "$locationProvider",
        function ($routeProvider, $provide, $httpProvider, $locationProvider) {
            $routeProvider.
               when('/', { templateUrl: 'app/views/home.html', controller: "HomeCtrl" }).
               when('/agent', { templateUrl: 'app/views/agent.html', controller: "ChatCtrl" })
                .otherwise({
                    redirectTo: "/"
                });;
        }]);
app.controller("HomeCtrl", ["$scope", function ($scope) {
    $scope.title = "home";
}])
app.controller("ChatCtrl", ["$scope", "Hub", "$location", "$http", "$rootScope",
    function ($scope, hub, $location, $http, $rootScope) {
        if (!$scope.myHub) {
            var chatKey = "angular-signalr";
            var defaultCustomerUserName = null;
            function getid(id) {
                var find = false;
                var position = null;
                angular.forEach($scope.chatConversation, function (index, i) {
                    if (index.id === id && !find) {
                        find = true;
                        position = i;
                        return;
                    }
                });
                return position;
            }
            function apply() {
                $scope.$apply();
            }
            $scope.boxheader = function () {
                var height = 0;
                $("#chat-box").slideToggle('slow', function () {
                    if ($("#chat-box-header").css("bottom") === "0px") {
                        height = $("#chat-box").height() + 20;
                    } else {
                        height = 0;
                    }
                    $("#chat-box-header").css("bottom", height);
                });
            };
            var init = function () {
                $scope.agent = {
                    id: "", name: "", isOnline: false
                };
                $rootScope.msg = "";
                $scope.alarmStatus = false;
                $scope.options = {
                    offlineTitle: "آفلاین",
                    onlineTitle: "آنلاین",
                    waitingForOperator: "لطفا منتظر بمانید تا به اپراتور وصل شوید",
                    emailSent: "ایمیل ارسال گردید",
                    emailFailed: "متاسفانه ایمیل ارسال نگردید",
                    logOut: "خروج",
                    setting: "تنظیمات",
                    conversion: "آرشیو",
                    edit: "ویرایش",
                    alarm: "قطع/وصل کردن صدا",
                    complete: "تکمیل",
                    pending: "منتظر ماندن",
                    reject: "عدم پذیرش",
                    lock: "آنلاین شدن",
                    unlock: "آفلاین شدن",
                    alarmOn: "روشن",
                    alarmOff: "خاموش",
                    upload: "آپلود"
                };
                $scope.chatConversation = [];
                $scope.chatSessions = [];
                $scope.customerVisit = [];

                $scope.agentClientMsgs = [];
                $scope.clientAgentMsg = [];
            }();
//تعریف هاب به همراه متدهای آن
            $scope.myHub = new hub("chatHub", {
                listeners: {
                    "clientChat": function (id, agentName) {
                        $scope.clientAgentMsg.push({ name: agentName, msg: "با سلام در خدمت میباشم" });
                        $scope.chatTitle = "کارشناس: " + agentName;
                        $scope.pendingRequestChat = false;
                        sessionStorage.setItem(chatKey, id);
                    }, "agentChat": function (id, firstComment, customerName) {
                        var date = new Date();
                        var position = getid(id);
                        if (position > 0) {
                            $scope.chatSessions[position].length = $scope.chatConversation[position].length + 1;
                            $scope.chatSessions[position].date = date.toISOString();
                            return;
                        }
                        else {
                            $scope.chatConversation.push({
                                id: id,
                                sessions: [{
                                    name: customerName,
                                    msg: firstComment,
                                    date: date
                                }],
                                agentName: $scope.agent.name,
                                customerName: customerName,
                                dateStartChat: date.getHours() + ":" + date.getMinutes(),
                            });
                            $scope.chatSessions.push({
                                id: id,
                                length: 1,
                                userName: customerName,
                                date: date.toISOString()
                            });
                        }
                        sessionStorage.setItem(chatKey, id);
                        apply();
                    }, 
//برروز رسانی لیست برای کارشناسان
"refreshChatWith": function (agentName, customerConnectionId) {
                        angular.forEach($scope.customerVisit, function (index, i) {
                            if (index.connectionId === customerConnectionId) {
                                $scope.customerVisit[i].chatWith = agentName;
                            }
                        });
                        apply();
                    },
//برروز رسانی لیست برای کارشناسان
 "refreshLeaveChat": function (agentName, customerConnectionId) {
                        angular.forEach($scope.customerVisit, function (index, i) {
                            if (index.connectionId === customerConnectionId) {
                                $scope.customerVisit[i].chatWith =agentName + "---" + "  به مکالمه خاتمه داده است ";
                            }
                        });
                        apply();
                    }
//وضعیت آنلاین بودن کارشناسان
                    , "onlineStatus": function (state) {
                        if (state) {
                            $scope.chatTitle = $scope.options.onlineTitle;
                            $scope.hasOnline = true;
                            $scope.hasOffline = false;
                        } else {
                            $scope.chatTitle = $scope.options.offlineTitle;
                            $scope.hasOffline = true;
                            $scope.hasOnline = false;
                        }
                        $scope.$apply()
                    }, "loginResult": function (status, id, name) {
                        if (status) {
                            $scope.agent.id = id;
                            $scope.agent.name = name;
                            $scope.agent.isOnline = true;
                            $scope.userIsLogin = $scope.agent;
                            $scope.$apply(function () {
                                $location.path("/agent");
                            });
                        } else {
                            $scope.agent = null;
                            toastr.error("کارشناسی با این مشخصات وجود ندارد");
                            return;
                        }
                    }, "newVisit": function (userName, city, country, chatWith, connectionId, firstComment) {
                        var exist = false;
                        angular.forEach($scope.customerVisit, function (index) {
                            if (index.connectionId === connectionId) {
                                exist = true;
                                return;
                            }
                        });
                        if (!exist) {
                            var date = new Date();
                            $scope.customerVisit.unshift({
                                userName: userName,
                                date: date,
                                city: city,
                                country: country,
                                chatWith: chatWith,
                                connectionId: connectionId,
                                firstComment: firstComment
                            });
                            if ($scope.alarmStatus) {
                                var snd = new Audio("/App/assets/sounds/Sedna.ogg");
                                snd.play();
                            }
                            toastr.success("تقاضای جدید دریافت گردید");
                            apply();
                        }
                    }, "addMessage": function (id, from, value) {
                        if ($scope.alarmStatus) {
                            var snd = new Audio("/App/assets/sounds/newmsg.mp3");
                            snd.play();
                        }
                        $scope.agentUserMsgs = [];
                        var date = new Date();
                        var position = getid(id);
                        if ($scope.chatConversation.length > 0 && position != null) {
                            $scope.chatConversation[position].sessions.push({ name: from, msg: value, date: date });
                        }
                        var item = $scope.chatConversation[position];
                        if (item) {
                            angular.forEach(item.sessions, function (index) {
                                $scope.agentUserMsgs.push({ name: index.name, msg: index.msg, date: date });
                            });
                            $scope.chatSessions[position].length = $scope.chatSessions[position].length + 1;
                        }
                        apply();
                    }, "clientAddMessage": function (id, from) {
                        if ($scope.alarmStatus) {
                            var snd = new Audio("/App/assets/sounds/newmsg.mp3");
                            snd.play();
                        }
                        $scope.clientAgentMsg.push({ name: id, msg: from });
                        apply();
                    }, "visitorSwitchConversation": function (id, customerName, sessions, sessionId) {
                        sessions = JSON.parse(sessions);
                        var date = new Date();
                        var sessionList = [];
                        angular.forEach(sessions, function (index) {
                            sessionList.push({
                                name: index.sender,
                                msg: index.body,
                                date: index.creationTime
                            });
                        });
                        $scope.chatConversation.push({
                            id: sessionId,
                            sessions: sessionList,
                            customerName: customerName,
                            dateStartChat: date.getHours() + ":" + date.getMinutes(),
                            agentName: $scope.agent.name
                        });
                        $scope.chatSessions.push({
                            id: sessionId,
                            length: sessions.length,
                            date: date
                        });
                    }, "receiveTicket": function (items) {
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.ticketList = [];
                            $scope.ticketList.push(index);
                        });
                    }, 
//آرشیو گفته گوهای کارشناس
"receiveHistory": function (items) {
                        $scope.agentHistory = [];
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.agentHistory.push(index);
                        });
                        apply();
                    }, 
//جزییات آرشیو گفتگوها
"detailsHistory": function (items) {
                        $scope.historyMsg = [];
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.historyMsg.push({ name: index.sender, msg: index.body, date: index.creationTime });
                        });
                        $("#detailsAgentHistory").modal();
                        apply();
                    }, 
//لیست کارشناسان آنلاین
"agentList": function (items) {
                        $scope.agentList = [];
                        angular.forEach(items, function (index) {
                            if ($scope.agent.name != index.Key) {
                                $scope.agentList.push({ name: index.Key, id: index.Value.ConnectionId });
                            }
                        });
                        $("#agentList").modal();
                        apply();
                    }
                },
                methods: ["agentConnect", "sendTicket", "requestChat", "clientSendMessage", "closeChat", "init", "logVisit",
                    "agentChangeStatus", "engageVisitor", "agentSendMessage", "transfer", "leaveChat", "acceptRequestChat",
                    "leaveChat", "detailsSessoinMessage", "showAgentList", "getAgentHistoryChat"
                ], errorHandler: function (error) {
                    console.error(error);
                }
            });
            $scope.myHub.promise.done(function () {
                $scope.myHub.init();
                $scope.myHub.promise.done(function () { });
            });

            $scope.LeaveChat = function () {
                $scope.myHub.LeaveChat();
            };
            $scope.loginAgent = function (userName) {
                // username :security user username from agent role
                if (userName == "hossein" || userName == "ali") {
                    $scope.myHub.promise.done(function () {
                        $scope.myHub.agentConnect(userName).then(function (result) {
                            $scope.agent.name = userName;
                            $scope.agent.isOnline = true;
                        });
                    });
                }
            };
            $scope.requestChat = function (msg) {
                if (!defaultCustomerUserName) {
                    //گرفتن کاربر لاگین شده
                    //ما از آرایه تصادفی استفاده میکنیم
                    var nameDefaultArray = [
                        'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه'
                    ];
                    defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)];
                }
                var userName = defaultCustomerUserName;
                if (!$scope.chatId) {
                    $scope.chatId = sessionStorage.getItem(chatKey);
                    $http.get("http://ipinfo.io")
                      .success(function (response) {
                          $scope.myHub.logVisit(response.city, response.country, msg, userName);
                      }).error(function (e, status, headers, config) {
                          $scope.myHub.logVisit("Tehran", "Ir", msg, userName)
                      });
                    $scope.myHub.requestChat(msg);
                    $scope.chatTitle = $scope.options.waitingForOperator;
                    $scope.pendingRequestChat = true;
                } else {
                    $scope.myHub.clientSendMessage(msg, userName);
                };
                $scope.message = "";
            };
            $scope.acceptRequestChat = function (customerConnectionId, firstComment, customerName) {
                $scope.myHub.acceptRequestChat(customerConnectionId, firstComment, customerName);
            };
            $scope.changeAgentStatus = function () {
                $scope.agent.isOnline = !$scope.agent.isOnline;
                $scope.myHub.agentChangeStatus($scope.agent.isOnline);
            };
            $scope.detailsChat = function (chatId, userName) {
                $scope.agentUserMsgs = [];
                angular.forEach($scope.chatConversation, function (index) {
                    if (index.id === chatId) {
                        $scope.dateStartChat = index.dateStartChat;
                        angular.forEach(index.sessions, function (value) {
                            $scope.agentUserMsgs.push({ name: value.name, msg: value.msg, date: value.date });
                        });
                    };
                });
                $scope.agentChatWithUser = chatId;
                $scope.customerName = userName;
                $("#agentUserChat").modal();
            };
            $scope.ticket = {
                submit: function () {
                    var name = $scope.ticket.name;
                    var email = $scope.ticket.email;
                    var comment = $scope.ticket.comment;
                    $scope.myHub.sendTicket(name, email, comment);
                }
            };
            $scope.showHistory = function () {
                $scope.myHub.getAgentHistoryChat($scope.agent.name);
            };
            $scope.detailsChatHistory = function (id) {
                $scope.myHub.detailsSessoinMessage(id, $scope.agent.id);
            };
            $scope.agentMsgToUser = function (msg) {
                var chatId = $scope.agentChatWithUser;
                var customerName = $scope.customerName;
                if (!customerName) {
                    angular.forEach($scope.customerVisit, function (index) {
                        if (index.connectionId == chatId) {
                            customerName = index.userName;
                        }
                    });
                }
                if (chatId !== "" && msg !== "") {
                    $scope.myHub.agentSendMessage(chatId, msg, customerName);
                }
                //not bind to scope.msg! not correctly work
                $scope.msg = "";
                $("#post-msg").val("");
            };
            $scope.closeChat = function (chatId) {
                var item = $scope.chatConversation[getid(chatId)];
                $scope.myHub.closeChat(chatId);
            };
            $scope.engageVisitor = function (newAgentId) {
                var customerId = $scope.customerId;
                var customerName = $scope.customerName;
                var clientSessionId = $scope.clientSessionId;
                $scope.myHub.engageVisitor(newAgentId, customerId, customerName, clientSessionId);
                $("[data-dismiss=modal]").trigger({ type: "click" });
            };
            $scope.selectVisitor = function (customerId, customerName, clientSessionId) {
                $scope.customerId = customerId;
                $scope.customerName = customerName;
                $scope.clientSessionId = clientSessionId;
                $scope.myHub.showAgentList();
            };
            $scope.setClass = function (item) {
                if (item === "من")
                    return "question";
                else
                    return "response";
            };
            $scope.setdirectionClass = function (item) {
                if (item === $scope.agent.name)
                    return { "float": "left" };
                else
                    return { "float": "right" };
            };
            $scope.setArrowClass = function (item) {
                if (item === $scope.agent.name)
                    return "left-arrow";
                else
                    return "right-arrow";
            };
            $scope.setAlarm = function () {
            $scope.alarmStatus = !$scope.alarmStatus;
            };
        }
    }]);
app.directive("showtab", function () {
    return {
        link: function (scope, element, attrs) {
            element.click(function (e) {
                e.preventDefault();
                $(element).addClass("active");
                $(element).tab("show");
            });
        }
    };
});
//زمان ارسال پیام
app.directive("timeAgo", function ($q) {
    return {
        restrict: "AE",
        scope: false,
        link: function (scope, element, attrs) {
            jQuery.timeago.settings.strings =
            {
                prefixAgo: null,
                prefixFromNow: null,
                suffixAgo: "پیش",
                suffixFromNow: "از حالا",
                seconds: "کمتر از یک دقیقه",
                minute: "در حدود یک دقیقه",
                minutes: "%d دقیقه",
                hour: "حدود یگ ساعت",
                hours: "حدود %d ساعت ",
                day: "یک روز",
                days: "%d روز",
                month: "حدود یک ماه",
                months: "%d ماه",
                year: "حدود یک سال",
                years: "%d سال",
                wordSeparator: " ",
                numbers: []
            }
            var parsedDate = $q.defer();
            parsedDate.promise.then(function () {
                jQuery(element).timeago();
            });
            attrs.$observe("title", function (newValue) {
                parsedDate.resolve(newValue);
            });
        }
    };
});
فایل chathub.cs
برای آشنایی بیشتر مقاله نگاهی به SignalR Hubs   مفید خواهد بود.
   [HubName("chatHub")]
    public class ChatHub : Microsoft.AspNet.SignalR.Hub
    {
        private readonly ApplicationDbContext _db = new ApplicationDbContext();
        private static ConcurrentDictionary<string, UserInformation> _agents;
        private static List<ChatSessionVm> _chatSessions;
        private readonly JsonSerializerSettings _settings = new JsonSerializerSettings
        {
            ContractResolver = new CamelCasePropertyNamesContractResolver(),
            ReferenceLoopHandling = ReferenceLoopHandling.Ignore
        };
        public void Init()
        {
            _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>());
            _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>());
            Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }
        public void AgentConnect(string userName)
        {
            //ما برای ساده کردن مقایسه ساده ای انجام دادیم فقط کاربر حسین یا علی میتواند کارشناس باشد
            if (userName == "hossein" || userName == "ali")
            {
                var agent = new UserInformation();
                if (_agents.Any(item => item.Key == userName))
                {
                    agent = _agents[userName];
                    agent.ConnectionId = Context.ConnectionId;
                }
                else
                {
                    agent.ConnectionId = Context.ConnectionId;
                    agent.UserName = userName;
                    agent.IsOnline = true;
                    _agents.TryAdd(userName, agent);

                }
                Clients.Caller.loginResult(true, agent.ConnectionId, agent.UserName);
                Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
            }
            else
            {
                Clients.Caller.loginResult(false, null, null);
            }
        }
        public void AgentChangeStatus(bool status)
        {
            var agent = _agents.FirstOrDefault(x => x.Value.ConnectionId == Context.ConnectionId).Value;
            if (agent == null) return;
            agent.IsOnline = status;
            Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }
        public void LogVisit(string city, string country, string firstComment, string userName)
        {
            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).newVisit(userName, city, country, null, Context.ConnectionId, firstComment);
            }
        }
        public void AcceptRequestChat(string customerConnectionId, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key));
            if (session == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = agent.Key,
                    Value = new List<string> { customerConnectionId }
                });
            }
            else
            {
                session.Value.Add(customerConnectionId);
            }
            Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName);
            Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName);
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = agent.Key,
                CustomerName = userName,
                CreatedDateTime = DateTime.Now
            });
            _db.SaveChanges();

            var message = new Message
            {
                CreationTime = DateTime.Now,
                Sender = agent.Key,
                Receiver = userName,
                Body = body,
                //ConnectionId = _agents.FirstOrDefault(item => item.Value.UserName == userName).Key,
                Session = _db.Sessions.OrderByDescending(item => item.Id)
                .FirstOrDefault(item => item.AgentName.Equals(agent.Key) && item.CustomerName.Equals(userName))
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void GetAgentHistoryChat(string userName)
        {
            var dic = new Dictionary<int, int>();
            var lenght = 0;
            var chats = _db.Sessions.OrderBy(item => item.Id).Include(item => item.Parent)
                .Where(item => item.AgentName.Equals(userName)).ToList();

            foreach (var session in chats)
            {
                Result(session, ref lenght);
                dic.Add(session.Id, lenght);
                lenght = 0;
            }
            if (!chats.Any()) return;

            var historyResult = chats.Select(item => new AgentViewModel
            {
                Id = item.Id,
                CustomerName = item.CustomerName,
                Date = item.CreatedDateTime,
                Lenght = dic.Any(di => di.Key.Equals(item.Id)) ? dic.FirstOrDefault(di => di.Key.Equals(item.Id)).Value : 0,
            }).OrderByDescending(item => item.Id).ToList();
            Clients.Caller.receiveHistory(JsonConvert.SerializeObject(historyResult, new Formatting(), _settings));
        }
        public void DetailsSessoinMessage(int sessionId, string agentId)
        {
            var session = _db.Sessions.FirstOrDefault(item => item.Id.Equals(sessionId));
            if (session == null) return;
            var list = new List<Message>();
            GetAllMessages(session, list);
            var result = JsonConvert.SerializeObject(list.OrderBy(item => item.Id), new Formatting(), _settings);
            Clients.Client(Context.ConnectionId).detailsHistory(result);
        }
        public void ClientSendMessage(string body, string userName)
        {
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(Context.ConnectionId));
            if (session == null || session.Key == null) return;
            var agentId = _agents.FirstOrDefault(item => item.Key.Equals(session.Key)).Value.ConnectionId;
            Clients.Caller.clientAddMessage("من", body);
            Clients.Client(agentId).addMessage(Context.ConnectionId, userName, body);
            var message = new Message
            {
                Sender = FindAgent(agentId).Key,
                Receiver = userName,
                Body = body,
                CreationTime = DateTime.Now,
                Session = FindSession(userName, FindAgent(agentId).Key)
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void AgentSendMessage(string id, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            Clients.Caller.addMessage(id, agent.Value.UserName, body);
            Clients.Client(id).clientAddMessage(agent.Value.UserName, body);
            var message = new Message
            {
                Sender = agent.Key,
                Receiver = userName,
                Body = body,
                Session = FindSession(agent.Key, userName),
                CreationTime = DateTime.Now
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void CloseChat(string id)
        {
            var findAgent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
            if (session == null) return;
            Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است");

            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id);
            }
            _chatSessions.Remove(session);
        }
        public void RequestChat(string message)
        {
            Clients.Caller.clientAddMessage("من", message);
        }
        public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId)
        {
            #region remove session of current agent
            var currentAgent = FindAgent(Context.ConnectionId);
            var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId));
            if (currentSession != null)
            {
                _chatSessions.Remove(currentSession);
            }
            #endregion

            #region add  session to new agent
            var newAgent = FindAgent(newAgentId);
            var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key));
            if (newSession == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = newAgent.Key,
                    Value = new List<string> { cumtomerId }
                });
            }
            else
            {
                newSession.Value.Add(cumtomerId);
            }
            #endregion

            Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key,
                "ادامه مکالمه به کارشناس  " + newAgent.Key + "مقابل  منتقل شد");
            Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key,
                "لطفا مکالمه را ادامه دهید.با تشکر");

            Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName,
                "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key);

            var session = _db.Sessions.FirstOrDefault
                (item => item.AgentName.Equals(currentAgent.Value.UserName)
                 && item.CustomerName.Equals(customerName));
            if (session != null)
            {
                var sessionId = session.Id;
                var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId));
                var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings);
                Clients.Client(newAgentId).visitorSwitchConversation
                    (Context.ConnectionId, customerName, result, clientSessionId);
            }
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = newAgent.Key,
                CustomerName = customerName,
                CreatedDateTime = DateTime.Now,
                Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key)
                      && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault()
            });
            _db.SaveChanges();
        }
        public void ShowAgentList()
        {
            Clients.Caller.agentList(_agents.ToList());
        }
        public override Task OnDisconnected(bool stopCalled)
        {
            var id = Context.ConnectionId;
            var isAgent = _agents != null && _agents.Any(item => item.Value.ConnectionId.Equals(id));
            if (isAgent)
            {
                UserInformation agent;
                var currentAgentConnectionId = FindAgent(id).Key;
                if (currentAgentConnectionId == null)
                    return base.OnDisconnected(stopCalled);
                if (_chatSessions.Any())
                {
                    var sessions = _chatSessions.FirstOrDefault(item => item.Key.Equals(currentAgentConnectionId));
                    //اطلاع دادن به تمام کاربرانی که در حال مکالمه با کارشناس هستند
                    if (sessions != null)
                    {
                        var result = sessions.Value.ToList();
                        for (var i = 0; i < result.Count(); i++)
                        {
                            var localId = result[i];
                            Clients.Client(localId).clientAddMessage(currentAgentConnectionId, "ارتباط شما با مشاور مورد نظر قطع شده است");
                        }
                    }
                }
                _agents.TryRemove(currentAgentConnectionId, out agent);
                Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
                Clients.Client(id).loginResult(false, null, null);
            }
            else
            {
                if (_chatSessions == null ||
                    !_chatSessions.Any(item => item.Value.Contains(id)
                        && _agents == null))
                    return base.OnDisconnected(stopCalled);

                var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
                if (session == null)
                    return base.OnDisconnected(stopCalled);

                var agentName = session.Key;
                var agent = _agents.FirstOrDefault(item => item.Key.Equals(agentName));
                if (agent.Key != null)
                {
                    Clients.Client(agent.Value.ConnectionId).addMessage(id, "کاربر", "اتصال با کاربر قطع شده است");
                }
            }
            return base.OnDisconnected(stopCalled);
        }


        private KeyValuePair<string, UserInformation> FindAgent(string connectionId)
        {
            return _agents.FirstOrDefault(item => item.Value.ConnectionId.Equals(connectionId));
        }
        private Session FindSession(string key, string userName)
        {
            return _db.Sessions.Where(item => item.AgentName.Equals(key) && item.CustomerName.Equals(userName))
                .OrderByDescending(item => item.Id).FirstOrDefault();
        }
        private static void Result(Session parent, ref int lenght)
        {
            while (true)
            {
                if (parent == null)
                    return;
                lenght += parent.Messages.Count();
                parent = parent.Parent;
            }
        }
        private static List<Message> GetAllMessages(Session node, List<Message> list)
        {
            if (node == null) return null;
            list.AddRange(node.Messages);
            if (node.Parent != null)
            {
                GetAllMessages(node.Parent, list);
            }
            return null;
        }
    }
فایل agent.html
<div>
    <div>
        <h2>
            خوش آمدید
            <span ng-bind="agent.name">
            </span>
            <a ng-click="changeAgentStatus()">
                <i ng-if="changeStatus==null"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.lock"></i>
                <i ng-if="changeStatus==true"
                   data-placement="bottom"
                   data-trigger="hover"
                   bs-tooltip="options.unlock"></i>
            </a>
        </h2>
        <div style="float: left">
            <a ng-click="setAlarm()">
                <i ng-show="alarmStatus"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.alarmOn"></i>

                <i ng-show="!alarmStatus"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.alarmOff"></i>
            </a>
            <!--<a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.conversion" ng-click="showHistory()"><i></i></a>-->

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.edit"><i></i><span></span></a>

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.setting"><i></i></a>

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.signOut" ng-click="LeaveChat()"><i></i><span></span></a>
        </div>
    </div>
    <div>
        <div>
            <div id="chat-content">
                <div>
                    <ul>
                        <li>
                            <a showtab href="#online-list">آنلاین</a>
                        </li>
                        <li>
                            <a ng-click="showHistory()" showtab href="#conversation">آرشیو گفتگوها</a>
                        </li>
                    </ul>
                    <div>
                        <div id="online-list">
                            <div>
                                <h2>
                                    <i></i><span></span>
                                    <span>نمایش آنلاین مراجعه ها</span>
                                </h2>
                            </div>
                            <div>
                                <div id="agent-chat">
                                    <div id="real-time-visits">
                                        <table id="current-visits">
                                            <thead>
                                                <tr>
                                                    <th>نام کاربر</th>
                                                    <th>زمان اولین تقاضا</th>
                                                    <th>منطقه</th>
                                                    <th>پاسخ</th>
                                                </tr>
                                            </thead>
                                            <tbody>
                                                <tr id="{{item.connectionId}}" ng-animate="animate" ng-repeat="item in customerVisit ">
                                                    <td ng-bind="item.userName"></td>
                                                    <td>
                                                        <span time-ago title="{{item.date}}"></span>
                                                    </td>
                                                    <td>
                                                        <span ng-bind="item.country"></span> /<span ng-bind="item.city"> </span>
                                                    </td>
                                                    <td>
                                                        <a style="cursor: pointer" ng-if="item.chatWith== null"
                                                           ng-click="acceptRequestChat(item.connectionId,item.firstComment,item.userName)">
                                                            شروع مکالمه
                                                        </a>
                                                        <span ng-if="item.chatWith ">
                                                            وضعیت:
                                                            <span>در حال مکالمه با</span>
                                                            <span ng-bind="item.chatWith"></span>
                                                            <a ng-show="item.chatWith==agent.name"
                                                              
                                                               ng-click="selectVisitor(item.connectionId,item.userName,item.connectionId)">
                                                                انتقال مکالمه
                                                            </a>
                                                        </span>
                                                        <ul ng-repeat="session in chatSessions track by $index" style="padding:0px;">
                                                            <li ng-if="session.id==item.connectionId" id="{{session.id}}">
                                                                <div>
                                                                    <p>
                                                                        تاریخ شروع مکالمه:
                                                                        <span time-ago title="{{session.date}}"></span>
                                                                    </p>
                                                                    <p>
                                                                        تعداد پیام ها:
                                                                        <span ng-bind="session.length"></span>
                                                                    </p>
                                                                </div>
                                                                <p>
                                                                    <a ng-click="detailsChat(session.id,session.userName)">جزییات </a>
                                                                    <a ng-click="closeChat(session.id)">
                                                                        خاتمه عملیات
                                                                    </a>
                                                                </p>
                                                            </li>
                                                        </ul>
                                                    </td>
                                                </tr>
                                            </tbody>
                                        </table>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div id="conversation">
                            <div>
                                <h2>
                                    <i></i><span></span>
                                    <span>آرشیو گفتگوهای </span>
                                    {{agent.name}}
                                </h2>
                            </div>
                            <div>
                                <div>
                                    <table id="current-visits">
                                        <thead>
                                            <tr>
                                                <th>شناسه مشتری</th>
                                                <th>نام مشتری</th>
                                                <th>تعداد محاوره ها</th>
                                                <th>تاریخ</th>
                                                <th>جزئیات</th>
                                            </tr>
                                        </thead>
                                        <tbody>
                                            <tr ng-repeat="item in agentHistory track by $index">
                                                <td ng-bind="item.id"></td>
                                                <td ng-bind="item.customerName"></td>
                                                <th ng-bind="item.lenght"></th>
                                                <td><span time-ago title="{{item.date}}"></span></td>
                                                <th>
                                                    <ang-click="detailsChatHistory(item.id)" >مشاهده جزییات گفتگو</a>
                                                </th>
                                            </tr>
                                        </tbody>
                                    </table>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div id="detailsAgentHistory" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>تاریخچه گفتگو
                    </h2>
                </div>
                <div>
                    <div style="display: block">
                        <ul ng-repeat="item in historyMsg">
                            <li>
                                <span ng-bind="item.name" ng-style="setdirectionClass(item.name)">
                                </span>
                                <span ng-style="setdirectionClass(item.name)">
                                    <span ng-class="setArrowClass(item.name)"></span>
                                    <span time-ago title="{{item.date}}"></span>
                                    <span>
                                        <p ng-bind-html="item.msg | smilies"></p>
                                    </span>
                                </span>
                            </li>
                        </ul>
                    </div>

                </div>
            </div>
        </div>
    </div>
    <div id="agentList" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>لیست تمام کارشناسان
                    </h2>
                </div>
                <div>
                    <div style="display: block;">
                        <div ng-show="agentList.length==0">
                            کارشناس آنلاینی وجود ندارد
                        </div>
                        <ul ng-repeat="item in agentList">
                            <li>
                                <span>
                                    <a ng-click="engageVisitor(item.id)">{{item.name}}</a>
                                </span>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div id="agentUserChat"  tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>گفتگو
                    </h2>
                </div>
                <div>
                    <div>
                        <div>
                            <div style="display: block;">
                                <label>شروع چت در </label>:
                                <span ng-bind="dateStartChat"></span>

                                <ul>
                                    <li ng-repeat="item in agentUserMsgs">
                                        <span ng-bind="item.name" ng-style="setdirectionClass(item.name)">

                                        </span>
                                        <span ng-style="setdirectionClass(item.name)">
                                            <span ng-class="setArrowClass(item.name)"></span>
                                            <span time-ago title="{{item.date}}"></span>

                                            <span>
                                                <p ng-bind-html="item.msg | smilies"></p>
                                            </span>
                                        </span>
                                    </li>
                                </ul>
                                <div>
                                    <div>
                                        <textarea id="post-msg" ng-model="msg" placeholder="متن خود را وارد نمایید" style="overflow: hidden; word-wrap: break-word; resize: horizontal; height: 80px; max-width: 100%"></textarea>
                                        <span smilies-selector="msg" smilies-placement="right" smilies-title="Smilies"></span>
                                    </div>
                                    <div style="text-align: center; margin-top: 5px">
                                        <button ng-click="agentMsgToUser(msg)">ارسال</button>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
فایل index.cshtml
<html ng-app="app">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Live Support</title>

    <link href="~/Content/bootstrap-rtl.css" rel="stylesheet" />
    <link href="~/Scripts/smilies/angular-smilies-embed.css" rel="stylesheet" />
    <link href="~/Content/font-awesome.css" rel="stylesheet" />

    <link href="~/Content/toastr.css" rel="stylesheet" />
    <link href="~/Content/liveSupport.css" rel="stylesheet" />

    <script src="~/Scripts/jquery-1.10.2.js"></script>
    <script src="~/Scripts/toastr.js"></script>
    <script src="~/Scripts/jquery.timeago.js"></script>

    <script src="~/Scripts/angular.js"></script>
    <script src="~/Scripts/angular-animate.js"></script>
    <script src="~/Scripts/angular-sanitize.js"></script>
    <script src="~/Scripts/angular-route.js"></script>

    <script src="~/Scripts/angular-strap.js"></script>
    <script src="~/Scripts/angular-strap.tpl.js"></script>

    <script src="~/Scripts/smilies/angular-smilies.js"></script>

    <script src="~/Scripts/jquery.signalR-2.2.0.js"></script>
    <script src="~/Scripts/angular-signalr-hub.js"></script>

    <script src="~/app/app.js"></script>
    @Scripts.Render("~/bundles/bootstrap")

</head>
<body ng-controller="ChatCtrl">
    <div ng-view>
    </div>

    <div id="chat-box-header" ng-click="boxheader()">
        {{chatTitle}}
    </div>
    <div id="chat-box">
        <div ng-show="hasOnline">
            <div id="style-1" style="min-height:100px;">
                <div ng-repeat="item in clientAgentMsg  track by $index">
                    <span ng-class="setClass(item.name)">
                        {{item.name}}
                    </span>
                    <br />
                    <p ng-bind-html="item.msg | smilies"></p>
                </div>
            </div>
            <div>
                <label>پیام</label>
                <div style="text-align: left; clear: both">
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.alarm" ng-click="alarm()"><i></i></a>
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.signOut" href="signOut()"><i></i><span></span></a>
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.upload" href="fileupload()">
                        <span><i></i></span>
                    </a>
                </div>
                <div>
                    <textarea style="height: 150px; max-height: 160px;" ng-model="message" placeholder=" متن خود را وارد نمایید"></textarea>
                    <span smilies-selector="message" smilies-placement="right" smilies-title="Smilies"></span>
                </div>
            </div>
            <div style="text-align: center">
                <button type="button" ng-disabled="pendingRequestChat" ng-click="requestChat(message)">ارسال </button>
            </div>
        </div>
        <div ng-show="hasOffline">
            <div>
                <form name="Ticket" id="form1">
                    <fieldset>
                        <div>
                            <label>نام</label>
                            <input name="email"
                                   ng-model="ticket.name"
                                  >
                        </div>
                        <div>
                            <label>ایمیل</label>
                            <input name="email"
                                   ng-model="ticket.email"
                                  >
                        </div>
                        <div>
                            <label>پیام</label>
                        </div>
                        <div>
                            <textarea ng-model="ticket.comment" placeholder="متن خود را وارد نمایید"></textarea>
                            <span smilies-selector="ticket.comment" smilies-placement="right" smilies-title="Smilies"></span>
                        </div>
                    </fieldset>
                    <div style="text-align: center">
                        <button type="button"
                                ng-click="ticket.submit(ticket)">
                            ارسال
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </div>
   
</body>

</html>
LiveSupport.zip 

نکات تکمیلی :
نگاشت  Hub‌ها به برنامه در مسیر ("signalr /") در فایل  ConfigureAuth.Cs 
  app.MapSignalR();