- راه اندازی Git داخل شبکه داخلی ویندوز | آرش میلانی | www.arashmilani.ir
- Ajax Control Toolkit November 2011 Release | stephenwalther.com
- Export OneNote to PDF | onenote2pdf.codeplex.com
- Mono for Android | blog.xamarin.com
- بهینه سازیهای وب در ویژوال استودیو | blogs.msdn.com
- مصاحبه با تیم پروژه Roslyn | channel9.msdn.com
هدف، ارائه راهحلی برای نمایش جدولی اطلاعات، جستجو، مرتب سازی و صفحه بندی و همچنین انجام عملیات ثبت، ویرایش و حذف بر روی آنها به صورت Ajaxای در بخش back office نرم افزار میباشد.
پیش نیازها:
- طراحی یک گرید با Angular و ASP.NET Core - قسمت اول - پیاده سازی سمت سرور (قسمت اول این سری 3 قسمتی، شامل توضیحات کاملی در مورد دلیل وجود یکسری واسط، کلاس پایه و متدهای کمکی است که در مقاله جاری هم آنها را مشاهده خواهید کرد.)
ایده کار به این شکل میباشد که برای نمایش اطلاعات به صورت جدولی با قابلیتهای مذکور، لازم است یک اکشن Index برای نمایش اولیه و صفحه اول اطلاعات صفحه بندی شده و اکشن متدی به نام List برای پاسخ به درخواستهای صفحه بندی، مرتب سازی، تغییر تعداد آیتمها در هر صفحه و همچنین جستجو، داشته باشیم که این اکشن متد List، بعد از واکشی اطلاعات مورد نظر از منبع داده، آنها را به همراه اطلاعاتی که در کوئری استرینگ درخواست جاری وجود دارد در قالب یک PartialView به کلاینت ارسال کند.
ایجاد مدلهای پایه
همانطور که در مقاله «طراحی و پیاده سازی ServiceLayer به همراه خودکارسازی Business Validationها» مطرح شد، برای پیاده سازی متدهای GetPagedList در ApplicationServiceها از الگوی Request/Response استفاده میکنیم. برای این منظور واسط و کلاسهای زیر را خواهیم داشت:
واسط IPagedQueryModel
public interface IPagedQueryModel { int Page { get; set; } int PageSize { get; set; } /// <summary> /// Expression of Sorting. /// </summary> /// <example> /// Examples: /// "Name_ASC" /// </example> string SortExpression { get; set; } }
این واسط قراردادی میباشد برای نوع و نام پارامترهایی که توسط کلاینت به سرور ارسال میشود. پراپرتی SortExpression آن، نام و ترتیب مرتب سازی را مشخص میکند؛ برای این منظور FieldName_ASC و FieldName_DESC به ترتیب برای حالات مرتب سازی صعودی و نزولی براساس FieldName مقدار دهی خواهد شد.
برای جلوگیری از تکرار این خصوصیات در مدلهای کوئری مربوط به موجودیتها، میتوان کلاس پایهای به شکل زیر در نظر گرفت که پیاده ساز واسط بالا میباشد:
public class PagedQueryModel : IPagedQueryModel, IShouldNormalize { public int Page { get; set; } public int PageSize { get; set; } /// <summary> /// Expression of Sorting. /// </summary> /// <example> /// Examples: /// "Name_ASC" /// </example> public string SortExpression { get; set; } public virtual void Normalize() { if (Page < 1) Page = 1; if (PageSize < 1) PageSize = 10; if (SortExpression.IsEmpty()) SortExpression = "Id_DESC"; } }
مدل بالا علاوه بر پیاده سازی واسط IPagedQueryModel، پیاده ساز واسط IShouldNormalize نیز میباشد؛ دلیل وجود چنین واسطی در مقاله «طراحی و پیاده سازی ServiceLayer به همراه خودکارسازی Business Validationها» توضیح داده شده است:
پیاده سازی واسط IShouldNormalize باعث خواهد شد که قبل از اجرای خود متد، این نوع پارامترها با استفاده از یک Interceptor شناسایی شده و متد Normalize آنها اجرا شود.
کلاس PagedQueryResult
public class PagedQueryResult<TModel> { public PagedQueryResult() { Items = new List<TModel>(); } public IEnumerable<TModel> Items { get; set; } public long TotalCount { get; set; } }
دلیل وجود کلاس بالا در مقاله «طراحی یک گرید با Angular و ASP.NET Core - قسمت اول - پیاده سازی سمت سرور» توضیح داده شده است:
عموما ساختار اطلاعات صفحه بندی شده، شامل تعداد کل آیتمهای تمام صفحات (خاصیت TotalItems) و تنها اطلاعات ردیفهای صفحهی جاری درخواستی (خاصیت Items) است و چون در اینجا این Items از هر نوعی میتواند باشد، بهتر است آنرا جنریک تعریف کنیم.
کلاس PagedListModel
همانطور که در اول بحث توضیح داده شد، لازم است اطلاعاتی را که کلاینت از طریق کوئری استرینگ برای صفحه بندی و ... ارسال کرده بود نیز به PartialView ارسال کنیم. این قسمت کار ایده اصلی این روش را در بر میگیرد؛ اگر نخواهیم اطلاعات کوئری استرینگ دریافتی از کلاینت را دوباره به PartialView ارسال کنیم، مجبور خواهیم بود تمام کارهای مربوط به تشخیص آیکن مرتب سازی ستونهای جدول، ریست کردن المنتهای مربوط به صفحه بندی و مرتب سازی را در در زمان انجام جستجو و یکسری کارهای از این قبل را در سمت کلاینت مدیریت کنیم که هدف مقاله جاری پیاده سازی این روش نمیباشد.
public class PagedListModel<TModel> { public IPagedQueryModel Query { get; set; } public PagedQueryResult<TModel> Result { get; set; } }
پراپرتی Query در برگیرنده پارامتر ورودی اکشن متد List میباشد که پراپرتیهای آن با مقادیر موجود در کوئری استرینگ درخواست جاری مقدار دهی شدهاند؛ البته بدون وجود کلاس بالا نیز به کمک ViewBag میشود این اطلاعات ترکیبی را به ویو ارسال کرد که پیشنهاد نمیشود.
متد GetPagedListAsync موجود در CrudApplicationService
public abstract class CrudApplicationService<TEntity, TModel, TCreateModel, TEditModel, TDeleteModel, TPagedQueryModel, TDynamicQueryModel> : ApplicationService, ICrudApplicationService<TModel, TCreateModel, TEditModel, TDeleteModel, TPagedQueryModel, TDynamicQueryModel> where TEntity : Entity, new() where TCreateModel : class where TEditModel : class, IModel where TModel : class, IModel where TDeleteModel : class, IModel where TPagedQueryModel : PagedQueryModel, new() where TDynamicQueryModel : DynamicQueryModel { #region Properties protected IQueryable<TEntity> UnTrackedEntitySet => EntitySet.AsNoTracking(); public IUnitOfWork UnitOfWork { get; set; } public IMapper Mapper { get; set; } protected IDbSet<TEntity> EntitySet => UnitOfWork.Set<TEntity>(); #endregion #region ICrudApplicationService Members #region Methods public virtual async Task<PagedQueryResult<TModel>> GetPagedListAsync(TPagedQueryModel model) { Guard.ArgumentNotNull(model, nameof(model)); var query = ApplyFiltering(model); var totalCount = await query.LongCountAsync().ConfigureAwait(false); var result = query.ProjectTo<TModel>(Mapper.ConfigurationProvider); result = result.ApplySorting(model); result = result.ApplyPaging(model); return new PagedQueryResult<TModel> { Items = await result.ToListAsync().ConfigureAwait(false), TotalCount = totalCount }; } #endregion #endregion #region Protected Methods /// <summary> /// Apply Filtering To GetPagedList and GetPagedListAsync /// </summary> /// <param name="model"></param> /// <returns></returns> protected virtual IQueryable<TEntity> ApplyFiltering(TPagedQueryModel model) { Guard.ArgumentNotNull(model, nameof(model)); return UnTrackedEntitySet; } #endregion }
در بدنه این متد، ابتدا عملیات جستجو توسط متد ApplyFiltering انجام میشود. این متد به صورت پیش فرض هیچ شرطی را بر روی کوئری ارسالی به منبع داده اعمال نمیکند؛ مگر اینکه توسط زیر کلاسها بازنویسی شود و فیلترهای مورد نیاز اعمال شوند. سپس تعداد کل آیتمهای فیلتر شده محاسبه شده و بعد از عملیات Projection، مرتب سازی و صفحه بندی انجام میگیرد. برای مباحث مرتب سازی و صفحه بندی از دو متد زیر کمک گرفته شدهاست:
public static class QueryableExtensions { public static IQueryable<TModel> ApplySorting<TModel>(this IQueryable<TModel> query, IPagedQueryModel request) { Guard.ArgumentNotNull(request, nameof(request)); Guard.ArgumentNotNull(query, nameof(query)); return query.OrderBy(request.SortExpression.Replace('_', ' ')); } public static IQueryable<TModel> ApplyPaging<TModel>(this IQueryable<TModel> query, IPagedQueryModel request) { Guard.ArgumentNotNull(request, nameof(request)); Guard.ArgumentNotNull(query, nameof(query)); return request != null ? query.Page((request.Page - 1) * request.PageSize, request.PageSize) : query; } }
به منظور مرتب سازی از کتابخانه System.Liq.Dynamic کمک گرفته شدهاست.
نکته: مشخص است که این روش، وابستگی به وجود متد GetPagedListAsync ندارد و صرفا برای تشریح ارتباط مطالبی که قبلا منتشر شده بود، مطرح شد.
پیاده سازی اکشن متدهای Index و List
public partial class RolesController : BaseController { #region Fields private readonly IRoleService _service; private readonly ILookupService _lookupService; #endregion #region Constractor public RolesController(IRoleService service, ILookupService lookupService) { Guard.ArgumentNotNull(service, nameof(service)); Guard.ArgumentNotNull(lookupService, nameof(lookupService)); _service = service; _lookupService = lookupService; } #endregion #region Index / List [HttpGet] public virtual async Task<ActionResult> Index() { var query = new RolePagedQueryModel(); var result = await _service.GetPagedListAsync(query).ConfigureAwait(false); var pagedList = new PagedListModel<RoleModel> { Query = query, Result = result }; var model = new RoleIndexViewModel { PagedListModel = pagedList, Permissions = _lookupService.GetPermissions() }; return View(model); } [HttpGet, AjaxOnly, NoOutputCache] public virtual async Task<ActionResult> List(RolePagedQueryModel query) { var result = await _service.GetPagedListAsync(query).ConfigureAwait(false); var model = new PagedListModel<RoleModel> { Query = query, Result = result }; return PartialView(MVC.Administration.Roles.Views._List, model); } #endregion }
به عنوان مثال در بالا کنترلر مربوط به گروههای کاربری را مشاهده میکنید. به دلیل اینکه علاوه بر مباحث صفحه بندی و مرتب سازی، امکان جستجو بر اساس نام و دسترسیهای گروه کاربری را نیز نیاز داریم، لازم است مدل زیر را ایجاد کنیم:
public class RolePagedQueryModel : PagedQueryModel { public string Name { get; set; } public string Permission { get; set; } }
در این مورد خاص لازم است لیست دسترسیهای موجود درسیستم به صورت لیستی برای انتخاب در فرم جستجو مهیا باشد. فرم جستجو در ویو مربوط به اکشن Index قرار میگیرد و قرار نیست به همراه پارشال ویو List_ در هر درخواستی از سرور دریافت شود. لذا لازم است مدلی برای ویو Index در نظر بگیریم که به شکل زیر میباشد:
public class RoleIndexViewModel { public RoleIndexViewModel() { Permissions = new List<LookupItem>(); } public IReadOnlyList<LookupItem> Permissions { get; set; } public PagedListModel<RoleModel> PagedListModel { get; set; } }
پراپرتی PagedListModel در برگیرنده اطلاعات مربوط به نمایش اولیه جدول اطلاعات میباشد و پراپرتی Permissions لیست دسترسیهای موجود درسیستم را به ویو منتقل خواهد کرد. اگر ویو ایندکس شما به داده اضافه ای نیاز ندارد، از ایجاد مدل بالا صرف نظر کنید.
ویو Index.cshtml
@model RoleIndexViewModel @{ ViewBag.Title = L("Administration.Views.Role.Index.Title"); ViewBag.ActiveMenu = AdministrationMenuNames.RoleManagement; } <div class="row"> <div class="col-md-12"> <div id="filterPanel" class="panel-collapse collapse" role="tabpanel" aria-labelledby="filterPanel"> <div class="panel panel-default margin-bottom-5"> <div class="panel-body"> @using (Ajax.BeginForm(MVC.Administration.Roles.List(), new AjaxOptions { UpdateTargetId = "RolesList", HttpMethod = "GET" }, new { id = "filterForm", data_submit_on_reset = "true" })) { <div class="row"> <div class="col-md-3"> <input type="text" name="Name" class="form-control" value="" placeholder="@L("Administration.Role.Fields.Name")" /> </div> <div class="col-md-3"> @Html.DropDownList("Permission", Model.Permissions.ToSelectListItems(), L("Administration.Views.Role.FilterBy.Permission"),new {@class="form-control"}) </div> <div class="col-md-3"> <button type="submit" role="button" class="btn btn-info"> @L("Commands.Filter") </button> <button type="reset" role="button" class="btn btn-default"> <i class="fa fa-close"></i> @L("Commands.Reset") </button> </div> </div> } </div> </div> </div> </div> </div> <div class="row"> <div class="col-md-12" id="RolesList"> @{Html.RenderPartial(MVC.Administration.Roles.Views._List, Model.PagedListModel);} </div> </div>
فرم جستجو باید دارای ویژگی data_submit_on_reset با مقدار "true" باشد. به منظور پاکسازی فرم جستجو و ارسال درخواست جستجو با فرمی خالی از داده، برای بازگشت به حالت اولیه از تکه کد زیر استفاده خواهد شد:
$(document).on("reset", "form[data-submit-on-reset]", function () { var form = this; setTimeout(function () { $(form).submit(); }); });
در ادامه پارشال ویو List_ با داده ارسالی به ویو Index، رندر شده و کار نمایش اولیه اطلاعات به صورت جدولی به اتمام میرسد.
پارشال ویو List.cshtml_
@model PagedListModel<RoleModel> @{ Layout = null; var rowNumber = (Model.Query.Page - 1) * Model.Query.PageSize + 1; var refreshUrl = Url.Action(MVC.Administration.Roles.List().AddRouteValues(new RouteValueDictionary(Model.Query.ToDictionary()))); } <div class="panel panel-default margin-bottom-5"> <table class="table table-bordered table-hover" id="RolesListTable" data-ajax-refresh-url="@refreshUrl" data-ajax-refresh-update="#RolesList"> <thead> <tr> <th style="width: 5%;"> # </th> <th class="col-md-3 sortable"> @Html.SortableColumn("Name", L("Administration.Role.Fields.Name"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) </th> <th class="col-md-3 sortable"> @Html.SortableColumn("DisplayName", L("Administration.Role.Fields.DisplayName"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) </th> <th class="col-md-3 sortable"> @Html.SortableColumn("IsDefault", L("Administration.Role.Fields.IsDefault"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) </th> <th style="width: 5%;"></th> </tr> </thead> <tbody> @foreach (var role in Model.Result.Items) { <tr> <td>@(rowNumber++.ToPersianNumbers())</td> <td>@role.Name</td> <td>@role.DisplayName</td> <td class="text-center">@Html.DisplayFor(a => role.IsDefault)</td> <td class="text-center operations"> <div class="btn-group"> <span class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span> <ul class="dropdown-menu dropdown-menu-left"> <li> <a href="#" role="button" data-ajax="true" data-ajax-method="GET" data-ajax-update="#main-modal div.modal-content" data-ajax-url="@Url.Action(MVC.Administration.Roles.Edit(role.Id))" data-toggle="modal" data-target="#main-modal"> <i class="fa fa-pencil"></i> @L("Commands.Edit") </a> </li> <li> <a href="#" role="button" id="delete-@role.Id" data-delete-url="@Url.Action(MVC.Administration.Roles.Delete())" data-delete-model='{"Id":"@role.Id","RowVersion":"@Convert.ToBase64String(role.RowVersion)"}'> <i class="fa fa-trash"></i> @L("Commands.Delete") </a> </li> </ul> </div> </td> </tr> } </tbody> </table> </div> <div class="row"> <div class="col-md-8"> @Html.Pager(Model, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) @Html.PageSize("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), new { @class = "margin-right-5" }, "filterForm") @Html.Refresh("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), L("Commands.Refresh")) </div> </div>
به ترتیب فایل بالا را بررسی میکنیم:
var refreshUrl = Url.Action(MVC.Administration.Roles.List().AddRouteValues(new RouteValueDictionary(Model.Query.ToDictionary())));
refreshUrl برای ارسال درخواست به اکشن متد List در نظر گرفته شدهاست که در کوئری استرینگ مربوط به خود، اطلاعاتی (مرتب سازی، شماره صفحه، اطلاعات جستجو و همچنین تعداد آیتمهای موجود در هر صفحه) را دارد که حالت فعلی گرید را میتوانیم دوباره از سرور درخواست کنیم.
<table class="table table-bordered table-hover" id="RolesListTable" data-ajax-refresh-url="@refreshUrl" data-ajax-refresh-update="#RolesList">
دو ویژگی data-ajax-refresh-url و data-ajax-refresh-update برای جدولی که لازم است عملیات CRUD را پشتیبانی کند، لازم میباشد. در قسمت دوم به استفاده از این دو ویژگی در هنگام عملیات ثبت، ویرایش و حذف خواهیم پرداخت.
<th class="col-md-3 sortable"> @Html.SortableColumn("Name", L("Administration.Role.Fields.Name"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) </th>
ستونی که امکان مرتب سازی را دارد باید th آن، کلاس sortable را داشته باشد. همچنین باید از هلپری که پیاده سازی آن را در ادامه خواهیم دید، استفاده کنیم. این هلپر، نام فیلد، عنوان ستون، مدل Query و همچین یک urlFactory را در قالب یک Func<RouteValueDictionary,string> دریافت میکند.
پیاده سازی هلپر SortableColumn
public static MvcHtmlString SortableColumn(this HtmlHelper html, string columnName, string columnDisplayName, IPagedQueryModel queryModel, string updateTargetId, Func<RouteValueDictionary, string> urlFactory) { var dictionary = queryModel.ToDictionary(); var routeValueDictionary = new RouteValueDictionary(dictionary) { ["SortExpression"] = !queryModel.SortExpression.StartsWith(columnName) ? $"{columnName}_DESC" : queryModel.SortExpression.EndsWith("DESC") ? $"{columnName}_ASC" : queryModel.SortExpression.EndsWith("ASC") ? string.Empty : $"{columnName}_DESC" }; var url = urlFactory(routeValueDictionary); var aTag = new TagBuilder("a"); aTag.Attributes.Add("href", "#"); aTag.Attributes.Add("data-ajax", "true"); aTag.Attributes.Add("data-ajax-method", "GET"); aTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}"); aTag.Attributes.Add("data-ajax-url", url); aTag.InnerHtml = columnDisplayName; var iconCssClass = !queryModel.SortExpression.StartsWith(columnName) ? "fa-sort" : queryModel.SortExpression.EndsWith("DESC") ? "fa-sort-down" : "fa-sort-up"; var iTag = new TagBuilder("i"); iTag.AddCssClass($"fa {iconCssClass}"); return new MvcHtmlString($"{aTag}\n{iTag}"); }
ابتدا مدل Query با متد الحاقی زیر تبدیل به دیکشنری میشود. این کار از این جهت مهم است که پراپرتیهای لیست موجود در مدل Query، لازم است به فرم خاصی به سرور ارسال شوند که در تکه کد زیر مشخص میباشد.
public static IDictionary<string, object> ToDictionary(this object source) { return source.ToDictionary<object>(); } public static IDictionary<string, T> ToDictionary<T>(this object source) { if (source == null) throw new ArgumentNullException(nameof(source)); var dictionary = new Dictionary<string, T>(); foreach (PropertyDescriptor property in TypeDescriptor.GetProperties(source)) { AddPropertyToDictionary(property, source, dictionary); } return dictionary; } private static void AddPropertyToDictionary<T>(PropertyDescriptor property, object source, IDictionary<string, T> dictionary) { var value = property.GetValue(source); var items = value as IEnumerable; if (items != null && !(items is string)) { var i = 0; foreach (var item in items) { dictionary.Add($"{property.Name}[{i++}]", (T)item); } } else if (value is T) { dictionary.Add(property.Name, (T)value); } }
در متد بالا، از TypeDescriptor که یکی دیگر از ابزارهای دسترسی به متا دیتای انوع دادهای است، استفاده شده و خروجی نهایی آن یک دیکشنری با کلیدهایی با اسامی پراپرتیهای وهله ورودی میباشد.
در ادامه پیاده سازی هلپر SortableColumn، از دیکشنری حاصل، یک وهله از RouteValueDictionary ساخته میشود. در زمان رندر شدن PartialView لازم است مشخص شود که برای دفعه بعدی که بر روی این ستون کلیک میشود، باید چه مقداری با پارامتر SortExpression موجود در کوئری استرینگ ارسال شود. از این جهت برای پشتیبانی ستون، از حالتهای مرتب سازی صعودی، نزولی و برگشت به حالت اولیه بدون مرتب سازی، کد زیر را خواهیم داشت:
var routeValueDictionary = new RouteValueDictionary(dictionary) { ["SortExpression"] = !queryModel.SortExpression.StartsWith(columnName) ? $"{columnName}_DESC" : queryModel.SortExpression.EndsWith("DESC") ? $"{columnName}_ASC" : queryModel.SortExpression.EndsWith("ASC") ? string.Empty : $"{columnName}_DESC" };
در ادامه urlFactory با routeValueDictionary حاصل، Invoke میشود تا url نهایی برای مرتب سازیهای بعدی را از طریق یک لینک تزئین شده با data اتریبیوتهای Unobtrusive Ajax در th مربوطه قرار دهیم.
برای مباحث صفحه بندی، بارگزاری مجدد و تغییر تعداد آیتمها در هر صفحه، از سه هلپر زیر کمک خواهیم گرفت:
<div class="row"> <div class="col-md-8"> @Html.Pager(Model, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary))) @Html.PageSize("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), new { @class = "margin-right-5" }, "filterForm") @Html.Refresh("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), L("Commands.Refresh")) </div> </div>
پیاده سازی هلپر Pager
public static MvcHtmlString Pager<TModel>(this HtmlHelper html, PagedListModel<TModel> model, string updateTargetId, Func<RouteValueDictionary, string> urlFactory) { return html.PagedListPager( new StaticPagedList<TModel>(model.Result.Items, model.Query.Page, model.Query.PageSize, (int)model.Result.TotalCount), page => { var dictionary = model.Query.ToDictionary(); var routeValueDictionary = new RouteValueDictionary(dictionary) { ["Page"] = page }; return urlFactory(routeValueDictionary); }, PagedListRenderOptions.EnableUnobtrusiveAjaxReplacing( new PagedListRenderOptions { DisplayLinkToFirstPage = PagedListDisplayMode.Always, DisplayLinkToLastPage = PagedListDisplayMode.Always, DisplayLinkToPreviousPage = PagedListDisplayMode.Always, DisplayLinkToNextPage = PagedListDisplayMode.Always, MaximumPageNumbersToDisplay = 6, DisplayItemSliceAndTotal = true, DisplayEllipsesWhenNotShowingAllPageNumbers = true, ItemSliceAndTotalFormat = $"تعداد کل: {model.Result.TotalCount.ToPersianNumbers()}", FunctionToDisplayEachPageNumber = page => page.ToPersianNumbers(), }, new AjaxOptions { AllowCache = false, HttpMethod = "GET", InsertionMode = InsertionMode.Replace, UpdateTargetId = updateTargetId })); }
در متد بالا از کتابخانه PagedList.Mvc استفاده شدهاست. یکی از overloadهای متد PagedListPager آن، یک پارامتر از نوع Func<int, string> به نام generatePageUrl را دریافت میکند که امکان شخصی سازی فرآیند تولید لینک به صفحات بعدی و قبلی را به ما میدهد. ما نیز از این امکان برای افزودن اطلاعات موجود در مدل Query، به کوئری استرینگ لینکهای تولیدی استفاده کردیم و صرفا برای لینکهای ایجادی لازم بود مقادیر پارامتر Page موجود در کوئری استرینگ تغییر کند که در کد بالا مشخص میباشد.
پیاده سازی هلپر PageSize
public static MvcHtmlString PageSize(this HtmlHelper html, string updateTargetId, IPagedQueryModel queryModel, Func<RouteValueDictionary, string> urlFactory, object htmlAttributes = null, string filterFormId = null, params int[] numbers) { if (numbers.Length == 0) numbers = new[] { 10, 20, 30, 50, 100 }; var dictionary = queryModel.ToDictionary(); var routeValueDictionary = new RouteValueDictionary(dictionary) { [nameof(IPagedQueryModel.Page)] = 1 }; routeValueDictionary.Remove(nameof(IPagedQueryModel.PageSize)); var url = urlFactory(routeValueDictionary); var formTag = new TagBuilder("form"); formTag.Attributes.Add("action", url); formTag.Attributes.Add("method", "GET"); formTag.Attributes.Add("data-ajax", "true"); formTag.Attributes.Add("data-ajax-method", "GET"); formTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}"); formTag.Attributes.Add("data-ajax-url", url); if (htmlAttributes != null) formTag.MergeAttributes(HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)); formTag.AddCssClass("form-inline inline"); var items = numbers.Select(number => new SelectListItem { Value = number.ToString(), Text = number.ToString().ToPersianNumbers(), Selected = queryModel.PageSize == number }); formTag.InnerHtml = html.DropDownList(nameof(IPagedQueryModel.PageSize), items, new { @class = "form-control page-size", onchange = "$(this.form).submit();" }).ToString(); if (filterFormId.IsEmpty()) return new MvcHtmlString($"{formTag}"); // ReSharper disable once MustUseReturnValue var scriptBlock = $"<script type=\"text/javascript\"> if(window.jQuery){{$('form#{filterFormId}').find('input[name=\"{nameof(IPagedQueryModel.PageSize)}\"]').remove();\n $('form#{filterFormId}').append(\"<input type='hidden' name='{nameof(IPagedQueryModel.PageSize)}' value='{queryModel.PageSize}'/>\")}}</script>"; return new MvcHtmlString($"{formTag}\n{scriptBlock}"); }
ایده کار به این صورت است که یک المنت select، درون یک المنت form قرار میگیرد و در زمان change آن، فرم مربوطه submit میشود.
formTag.InnerHtml = html.DropDownList(nameof(IPagedQueryModel.PageSize), items, new { @class = "form-control page-size", onchange = "$(this.form).submit();" }).ToString();
در زمان تغییر تعداد نمایشی آیتمها در هر صفحه، لازم است حالت فعلی گرید حفظ شود و صرفا پارامتر Page ریست شود.
نکته مهم: در این طراحی اگر فرم جستجویی دارید، در زمان جستجو هیچیک از پارامترهای مربوط به صفحه بندی و مرتب سازی به سرور ارسال نخواهند شد (در واقع ریست میشوند) و کافیست یک درخواست GET معمولی با ارسال محتویات فرم به سرور صورت گیرد؛ ولی لازم است PageSize تنظیم شده، در زمان اعمال فیلتر نیز به سرور ارسال شود. از این جهت اسکریپتی برای ایجاد یک input مخفی در فرم جستجو نیز هنگام رندر شدن PartialView در صفحه تزریق میشود.
var scriptBlock = $"<script type=\"text/javascript\"> if(window.jQuery){{$('form#{filterFormId}').find('input[name=\"{nameof(IPagedQueryModel.PageSize)}\"]').remove();\n $('form#{filterFormId}').append(\"<input type='hidden' name='{nameof(IPagedQueryModel.PageSize)}' value='{queryModel.PageSize}'/>\")}}</script>";
پیاده سازی هلپر Refresh
public static MvcHtmlString Refresh(this HtmlHelper html, string updateTargetId, IPagedQueryModel queryModel, Func<RouteValueDictionary, string> urlFactory, string label = null) { var dictionary = queryModel.ToDictionary(); var routeValueDictionary = new RouteValueDictionary(dictionary); var url = urlFactory(routeValueDictionary); var aTag = new TagBuilder("a"); aTag.Attributes.Add("href", "#"); aTag.Attributes.Add("role", "button"); aTag.Attributes.Add("data-ajax", "true"); aTag.Attributes.Add("data-ajax-method", "GET"); aTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}"); aTag.Attributes.Add("data-ajax-url", url); aTag.AddCssClass("btn btn-default"); var iTag = new TagBuilder("i"); iTag.AddCssClass("fa fa-refresh"); aTag.InnerHtml = $"{iTag} {label}"; return new MvcHtmlString(aTag.ToString()); }
متد بالا نیز به مانند refreshUrl که پیشتر مطرح شد، برای بارگزاری مجدد حالت فعلی گرید استفاده میشود و از این جهت است که مقادیر مربوط به کلیدهای routeValueDictionary را تغییر ندادهایم.
روش دیگر برای مدیریت این چنین کارهایی، استفاده از یک المنت form و قرادادن کل گرید به همراه یک سری input مخفی معادل با پارامترهای دریافتی اکشن متد List و مقدار دهی آنها در زمان کلیک بر روی دکمههای صفحه بندی، بارگزاری مجدد، دکمه اعمال فیلتر و لیست آبشاری تنظیم تعداد آیتمها، درون آن نیز میتواند کار ساز باشد؛ اما در زمان پیاده سازی خواهید دید که پیاده سازی آن خیلی سرراست، به مانند پیاده سازی موجود در مطلب جاری نخواهد بود.
در قسمت دوم، به پیاده سازی عملیات ثبت، ویرایش و حذف برپایه مودالهای بوت استرپ و افزونه Unobtrusive Ajax خواهیم پرداخت.
کدهای کامل قسمت جاری، بعد از انتشار قسمت دوم، در مخزن گیت هاب شخصی قرار خواهد گرفت.
یکی دیگر از تغییرات عمدهی ASP.NET Core با نگارشهای قبلی آن، نحوهی مدیریت مسیریابیهای سیستم است. در نگارشهای قبلی مبتنی بر HTTP Moduleها، مسیریابیها توسط یک HTTP Module مخصوص، با pipeline اصلی ASP.NET یکپارچه شدهاند و زمانیکه مسیر درخواستی با تنظیمات سیستم تطابق داشته باشد، پردازش کار به HTTP Handler مخصوص ASP.NET MVC منتقل میشود:
اما در ASP.NET Core مبتنی بر میان افزارها، زیر ساخت مسیریابی به صورت زیر تغییر کردهاست:
میان افزار ASP.NET MVC را که در قسمت قبل فعال کردیم، باید بتواند کنترلر و اکشن متد متناظر با URL درخواستی را مشخص کند. این تصمیم گیری نیز بر اساس تنظیماتی به نام Routing انجام میشود. در قسمت قبل، حالت ساده و پیش فرض این تنظیمات را مورد استفاده قرار دادیم
app.UseMvcWithDefaultRoute();
public static IApplicationBuilder UseMvcWithDefaultRoute(this IApplicationBuilder app) { if (app == null) throw new ArgumentNullException("app"); return app.UseMvc((Action<IRouteBuilder>) (routes => routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}"))); }
روش دیگر معرفی این تنظیمات، استفاده از Attribute routing است:
[Route("[controller]/[action]")]
مسیریابیهای قراردادی
در قسمت قبل، یک POCO Controller را به صورت ذیل تعریف کردیم و این کنترلر، بدون تعریف هیچ نوع مسیریابی خاصی در دسترس بود:
namespace Core1RtmEmptyTest.Controllers { public class HomeController { public string Index() { return "Running a POCO controller!"; } } }
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); });
در این تعاریف، هر کدام از قسمتهای قرارگرفتهی داخل {}، مشخص کنندهی قسمتی از URL دریافتی بوده و نامهای controller و action در اینجا جزو نامهای از پیش مشخص شده هستند و برای نگاشت اطلاعات مورد استفاده قرار میگیرند. برای مثال اگر آدرس home/index/ درخواست شد، برنامه به کلاس HomeController و متد عمومی Index آن هدایت میشود. همچنین قسمت آخر این پردازش به ?id ختم شدهاست. وجود ?، به معنای اختیاری بودن این پارامتر است و اگر در URL ذکر شود، به پارامتر id این اکشن متد، نگاشت خواهد شد. مواردی که پس از = ذکر شدهاند، مقادیر پیش فرض مسیریابی هستند. برای مثال اگر صرفا آدرس home/ درخواست شود، مقدار اکشن متد آن با مقدار پیش فرض index جایگزین خواهد شد و اگر تنها مسیر / درخواست شود، کنترل Home و اکشن متد Index آن پردازش میشوند.
در اینجا به هر تعدادی که نیاز است میتوان متدهای routes.MapRoute را فراخوانی و استفاده کرد؛ اما ترتیب تعریف آنها حائز اهمیت است. هر مسیریابی که در ابتدای لیست اضافه شود، حق تقدم بالاتری خواهد داشت و هر تطابقی با یکی از مسیریابیهای تعریف شده، در همان سطح سبب خاتمهی پردازش سایر مسیریابیها میشود.
استفاده از Attributes برای تعریف مسیریابیها
بجای تعریف قرار دادهای پیش فرض مسیریابی در کلاس آغازین برنامه، میتوان از ویژگی Route نیز استفاده کرد. هرچند روش تعریف مسیریابیهای قراردادی، از نگارشهای آغازین ASP.NET MVC به همراه آن بودهاند، اما با زیاد شدن تعداد کنترلرها و مسیریابیهای سفارشی هر کدام، اینبار با نگاه کردن به یک کنترلر، سریع نمیتوان تشخیص داد که چه مسیریابیهای خاصی به آن مرتبط هستند. برای ساده سازی مدیریت برنامههای بزرگ و ساده سازی تعاریف مسیریابیهای خاص آنها، استفاده از ویژگی Route نیز به ASP.NET MVC اضافه شدهاست.
یک مثال: کنترلر About را درنظر بگیرید:
using Microsoft.AspNetCore.Mvc; namespace Core1RtmEmptyTest.Controllers { public class AboutController : Controller { public ActionResult Hello() { return Content("Hello from DNT!"); } public ActionResult SiteName() { return Content("DNT"); } } }
اما اگر آدرس /About/ را درخواست دهیم چطور؟ چون در مسیریابی پیش فرض، تعریف {action=Index} را داریم، یعنی هر زمانیکه در URL درخواستی، قسمت action آن ذکر نشد، آنرا با index جایگزین کن و این کنترلر دارای متد Index نیست. در ادامه اگر بخواهیم متد Hello را تبدیل به متد پیش فرض این کنترلر کنیم، میتوان با استفاده از ویژگی Route به صورت ذیل عمل کرد:
using Microsoft.AspNetCore.Mvc; namespace Core1RtmEmptyTest.Controllers { [Route("About")] public class AboutController : Controller { [Route("")] public ActionResult Hello() { return Content("Hello from DNT!"); } [Route("SiteName")] public ActionResult SiteName() { return Content("DNT"); } } }
AmbiguousActionException: Multiple actions matched. The following actions matched route data and had all constraints satisfied: Core1RtmEmptyTest.Controllers.AboutController.Hello (Core1RtmEmptyTest) Core1RtmEmptyTest.Controllers.AboutController.SiteName (Core1RtmEmptyTest)
روش بهتر و refactoring friendly آن نیز به صورت ذیل است:
using Microsoft.AspNetCore.Mvc; namespace Core1RtmEmptyTest.Controllers { [Route("[controller]")] public class AboutController : Controller { [Route("")] public ActionResult Hello() { return Content("Hello from DNT!"); } [Route("[action]")] public ActionResult SiteName() { return Content("DNT"); } } }
یک نکته: در حین تعریف مسیریابی یک کنترلر میتوان پیشوندهایی را نیز ذکر کرد؛ برای مثال:
[Route("api/[controller]")]
تعریف قیود، برای مسیریابیهای تعریف شده
فرض کنید به کنترلر About فوق، اکشن متد ذیل را که یک خروجی JSON را بازگشت میدهد، اضافه کردهایم:
//[Route("/Users/{userid}")] [Route("Users/{userid}")] public IActionResult GetUsers(int userId) { return Json(new { userId = userId }); }
و اگر مسیریابی Users/{userid} را تعریف کنیم، یعنی این مسیریابی پس از ذکر کنترلر about، به عنوان یک اکشن متد آن مفهوم پیدا میکند:
http://localhost:7742/about/users/1
در هر دو حالت، ذکر پارامتر userid الزامی است (چون با ? مشخص نشدهاست)؛ مانند:
[Route("/Users/{userid:int?}")]
[Route("Users/{userid:int}")]
قیودی را که در اینجا میتوان ذکر کرد به شرح زیر هستند:
• alpha - معادل است با (a-z, A-Z).
• bool - برای تطابق با مقادیر بولی.
• datetime - برای تطابق با تاریخ میلادی.
• decimal - برای تطابق با ورودیهای اعشاری.
• double - برای تطابق با اعداد اعشاری 64 بیتی.
• float - برای تطابق با اعداد اعشاری 32 بیتی.
• guid - برای تطابق با GUID ها
• int - برای تطابق با اعداد صحیح 32 بیتی.
• length - برای تعیین طول رشته.
• long - برای تطابق با اعداد صحیح 64 بیتی.
• max - برای ذکر حداکثر مقدار یک عدد صحیح.
• maxlength - جهت ذکر حداکثر طول رشتهی مجاز ورودی.
• min - برای ذکر حداقل مقدار یک عدد صحیح.
• minlength - جهت ذکر حداقل طول رشتهی مجاز ورودی.
• range - ذکر بازهی اعداد صحیح مجاز.
• regex - ذکر یک عبارت با قاعده جهت مشخص سازی الگوی قابل پذیرش.
برای ترکیب چندین قید مختلف نیز میتوان از : استفاده کرد:
[Route("/Users/{userid:int:max(1000):min(10)}")]
ذکر نام Route برای ساده سازی تعریف آدرسی به آن
در حین تعریف یک Route میتوان نام دلخواهی را نیز به آن انتساب داد (همانند نام default مسیریابی ثبت شدهی در کلاس آغازین برنامه):
[Route("/Users/{userid:int}", Name="GetUserById")]
string uri = Url.Link("GetUserById", new { userid = 1 });
مشخص سازی ترتیب پردازش مسیریابیها
ترتیب مسیریابیهای ثبت شدهی در کلاس آغازین برنامه، همان ترتیب افزوده شدن و ذکر آنها است.
در اینجا میتوان از خاصیت order نیز استفاده کرد و اعداد کوچکتر، ابتدا پردازش میشوند (مقدار پیش فرض آن نیز صفر است):
[Route("/Users/{userid:int}", Name = "GetUserById", Order = 1)]
امکان تعریف قیود سفارشی
اگر قیودی که تا اینجا ذکر شدند، برای کار شما مناسب نبودند و نیاز بود تا الگوریتم خاصی را جهت محدود سازی دسترسی به یک مسیریابی خاص پیاده سازی کنید، میتوان به صورت ذیل عمل کرد:
using System; using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; namespace Core1RtmEmptyTest { public class CustomRouteConstraint : IRouteConstraint { public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { object value; if (!values.TryGetValue(routeKey, out value) || value == null) { return false; } long longValue; if (value is long) { longValue = (long)value; return longValue != 10; } var valueString = Convert.ToString(value, CultureInfo.InvariantCulture); if (long.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out longValue)) { return longValue != 10; } return false; } } }
public class CustomRouteConstraint : IRouteConstraint
در آخر برای ثبت و معرفی آن باید به متد ConfigureServices کلاس آغازین برنامه مراجعه کرد:
public void ConfigureServices(IServiceCollection services) { services.AddRouting(options =>options.ConstraintMap.Add("Custom", typeof(CustomRouteConstraint)));
[Route("/Users/{userid:int:custom}")]
یک نکته: اگر به سورس ASP.NET Core مراجعه کنید ، تمام قیودی را که پیشتر نام بردیم (مانند int، guid و امثال آن) نیز به همین روش تعریف و پیشتر ثبت شدهاند.
معرفی بستهی نیوگت Microsoft.AspNetCore.SpaServices
مسیریابیهای پیش فرض ASP.NET Core با مسیریابیهای برنامههای SPA مانند AngularJS (و امثال آن) تداخل دارند؛ از این جهت که درخواستهای رسیدهی به سرور، ابتدا به موتور پردازشی ASP.NET وارد میشوند و اگر یافت نشدند، کاربر با پیام 404 مواجه خواهد شد و دیگر در اینجا برنامه به مسیریابی خاص مثلا AngularJS 2.0 هدایت نمیشود.
برای این موارد مرسوم است که یک fallback route را در انتهای مسیریابیهای موجود اضافه کنند (به آن catch all هم میگویند)
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); routes.MapRoute( name: "spa-fallback", template: "{*url}", defaults: new { controller = "Home", action = "Index" }); });
برای حل این مشکل مایکروسافت بستهای را به نام Microsoft.AspNetCore.SpaServices ارائه داده است.
برای افزودن آن بر روی گره references کلیک راست کرده و گزینهی manage nuget packages را انتخاب کنید. سپس در برگهی browse آن Microsoft.AspNetCore.SpaServices را جستجو کرده و نصب نمائید:
انجام این مراحل معادل هستند با افزودن یک سطر ذیل به فایل project.json برنامه:
{ "dependencies": { //same as before "Microsoft.AspNetCore.SpaServices": "1.0.0-beta-000007" },
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); routes.MapSpaFallbackRoute("spa-fallback", new { controller = "Home", action = "Index" }); });
// Serve wwwroot as root app.UseFileServer(); // Serve /node_modules as a separate root (for packages that use other npm modules client side) app.UseFileServer(new FileServerOptions { // Set root of file server FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "node_modules")), // Only react to requests that match this path RequestPath = "/node_modules", // Don't expose file system EnableDirectoryBrowsing = false });
استفاده از Kendo UI templates
@Html.ActionLink("#=Name #", "Index", "Home").ToHtmlString(),
columns: [ { field: "Id", title: "id" }, { field: "Name", title: "Product Name", template: "<a href='@Url.Action("test", "Home")/#=Id#'>#= Name #</a>" },
Attribute Routing در ASP.NET MVC 5
الان من در قسمت route.config به این صورت کدهام تعریف شده
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapMvcAttributeRoutes(); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "List", id = UrlParameter.Optional } ); }
[Route("Page/{page?}")] public ActionResult List(int page=1) {
خوب این همون حالت پیش فرضی هست که توضیح دادن: «هنگام validate کردن فرم متوجه میشوید که فقط کنترلهای تب جاری اعتبارسنجی میشوند و بدون توجه به سایر کنترل هایی که در تبهای دیگر هستند». این tab جاری منظور همون به ازای هر tab جداگانه هست.
برای نمایش خودکار تب بعدی هم باید کدنویسی کنید. فقط کافی هست که کلاس active رو به div تب مورد نظر اضافه کنید:
$("#home").removeClass("active"); // this deactivates the home tab $("#profile").addClass("active"); // this activates the profile tab
CSS پویا در ASP.NET MVC
public class CSS { public string Color { set; get; } }
public ActionResult GetDynamicStyle() { var color = "White"; if (DateTime.Now.Hour > 18 || DateTime.Now.Hour < 8) { color = "Black"; } this.Response.ContentType = "text/css"; return PartialView(viewName: "~/Views/Home/_CSS.cshtml", model: new CSS { Color = color }); }
@model DynamicMvcCSS.Controllers.CSS .foo { color: @Model.Color; }
ASP.NET MVC #18
[AllowAnonymous] public class HomeController : Controller { [HttpGet] public ActionResult Index() { return View(); } } [AllowAnonymous] public class SecurityController : Controller { [HttpGet] public ActionResult LogOn(string returnUrl) { if (User.Identity.IsAuthenticated) //remember me { if (CanRedirect(returnUrl)) { return Redirect(returnUrl); } return Redirect(FormsAuthentication.DefaultUrl); } return View(); // show the login page } }
ASP.NET MVC #9
routes.MapRoute( "Default", // Route name "{controller}/{action}/{id}", // URL with parameters new { controller = "Home", action = "Index", id = UrlParameter.Optional, area = "" } // Parameter defaults );