JS MVCT4

Wcześniej omówiłem skrypt T4, który tworzy klasy na bazie kontrolerów ASP.NET MVC. Czy istnieje skrypt T4, który utworzy pomocniczy kod tylko po stronie JavaScript.

Na czym polega problem? Chodzi o ścieżki URL do akcji poszczególnych kontrolerów MVC.

Istnieje też problem z powtarzalnością kodu JavaScript wywołującym zapytania AJAX do serwera.

Taki skrypt istnieje i dzisiaj go omówię.

Skrypt istnieje w systemie paczek NuGet. Jest on jednak obecnie uszkodzony i nie działa z najnowszym ASP.NET MVC.

Podobny skrypt znalazłem w galerii “Tangible T4”. Jeśli chcesz działać ze skryptami T4 na poważnie, dobrze jest zainstalować dodatek do Visual Studio “Tangible T4”.

Tangible T4

Sama galeria nie jest doskonała, ale można w niej znaleźć ciekawe rzeczy.

Gallery Tangible T4

Oto skrypt T4, który generuje kod JavaScript z adresami URL do akcji poszczególnych kontrolerów.

Poza tym tworzy on także gotowe funkcje do wywołań AJAX, do akcji kontrolerów. Muszą one być tylko oznaczone atrybutem [HttpPost] lub [HttpGet].

Akcje też muszą  zwracać klasę pochodną od “ActionResult”. Można to jednak poprawić.

<#@ template debug="true" hostSpecific="true" #>
<#@ output extension=".js" #>
<#@ assembly Name="System.Core.dll" #>
<#@ assembly name="System.Web.Mvc" #>
<#@ assembly name="System.Web.Extensions" #>
<#@ assembly name="EnvDTE" #>
<#@ assembly name="EnvDTE100" #>
<#@ import namespace="EnvDTE" #>
<#@ import namespace="EnvDTE100" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
<#@ import namespace="System.Reflection" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Web.Mvc" #>
<#@ import namespace="System.Web.Script.Serialization" #>
/*global AjaxRequest:false */

<#
    IServiceProvider serviceProvider = (IServiceProvider)this.Host;
    DTE dte = serviceProvider.GetService(typeof(EnvDTE.DTE)) as DTE;  
    Project proj = dte.ActiveDocument.ProjectItem.ContainingProject;
    OutputControllers(proj);
#>

(function (AjaxRequest, undefined) {
    AjaxRequest.Post = function (jsonData, url, successCallback, errorCallback) {
        $.ajax({
            url: url,
            type: "POST",
            data: jsonData,
            datatype: "json",
            contentType: "application/json charset=utf-8",
            success: function (data) {
                successCallback(data);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                if (errorCallback) {
                    errorCallback(jqXHR,textStatus,errorThrown);
                }
            }
        });
    };


    AjaxRequest.Get = function (jsonData, url, successCallback, errorCallback) {
        $.ajax({
            url: url,
            type: "GET",
            data: jsonData,
            datatype: "json",
            contentType: "application/json charset=utf-8",
            success: function (data) {
                    successCallback(data);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                if (errorCallback) {
                    errorCallback(jqXHR,textStatus,errorThrown);
                }
            }

        });
    };

}(window.AjaxRequest = window.AjaxRequest || {}));
<#+

    public void OutputControllers(Project proj)
    {
        WriteLine("var Controllers = {");
        PushIndent("\t");
        WriteLine("relativePath: \"\",");
        WriteLine("SetRelativePath : function(relPath){");
        PushIndent("\t");
        WriteLine("/// <summary>Sets the relative path for Ajax calls to the controller actions</summary>");
        WriteLine("/// <param name=\"relPath\" type=\"String\">The relative path that is prefixed before the controller action URL</param>");
        WriteLine("Controllers.relativePath = relPath;");
        PopIndent();
        Write("}");
        
        //There was a problem with duplicate definitions of javascript functions
        //This is a simple fix
        List<string> rep = new List<string>();

        var classes = GetCodeClassesInProject(proj.ProjectItems);
        if(classes.Count > 0)
            Write(",\r\n");

        for(int i = 0; i < classes.Count; i++)
        {    
            CodeClass cls = classes[i];
            if(HasBaseClass(cls.Bases, "Controller"))
            {
                var name = cls.Name.Replace("Controller","");

                //Checking if name already exist
                if (rep.Contains(name))
                    continue;

                WriteLine("'" + name  +"' :");

                rep.Add(name);

                WriteLine("{");
                PushIndent("\t");

                var actions = GetActionsForController(cls);

                // write URLs
                foreach (var action in actions)
                {
                    Write("'{1}URL' : '{0}/{1}'",cls.Name.Replace("Controller",""), action.Name);
                    if(action != actions.Last())
                        Write(",\r\n");
                }

                //Important. if actions/methods inside of a Controller don't have this atributes they will be not printed
                var getActions = actions.Where( a => HasAttribute(a.Attributes, "HttpGet"));
                var postActions = actions.Where(a => HasAttribute(a.Attributes, "HttpPost"));

                if(getActions.Any())
                    Write(",\r\n");

                //Writes GET AJAX Actions that exist in Controller
                foreach (var action in getActions)
                {
                    WriteAjaxWrapper("Get",action, cls);
                    if(action != getActions.Last())
                        Write(",\r\n");
                }

                if(postActions.Any())
                    Write(",\r\n");

                //Writes POST AJAX Actions that exist in Controller
                foreach (var action in postActions)
                {
                    WriteAjaxWrapper("Post",action, cls);
                    if(action != postActions.Last())
                        Write(",\r\n");
                }

                PopIndent();

                if(i < classes.Count -1)
                    WriteLine("},");
                else
                    WriteLine("}");

            }
        }
        PopIndent();
        WriteLine("};");
    }

    public void WriteAjaxWrapper(string method, CodeFunction action, CodeClass controller)
    {
        var paramList = GetParameters(action);

        WriteLine("'{0}{1}' : function({2}successCallback, errorCallback) {{", action.Name, method,paramList);
        PushIndent("\t");
        WriteLine("/// <summary>HTTP {0} request Ajax wrapper for the {1} action</summary>", method, action.Name);

        for (int i = 1; i <= action.Parameters.Count; i++)
        {
            WriteLine("/// <param name=\"{0}JSON\" type=\"{1}\" >The data for the {1} parameter serialized as JSON</param>",
            action.Parameters.Item(i).Name,
            (action.Parameters.Item(i) as CodeParameter).Type.CodeType.Name);
        }

        WriteLine("/// <param name=\"successCallback\" >Optional,function to be run upon success of the call</param>");
        WriteLine("/// <param name=\"errorCallback\" >Optional, function to be run there was an error with the call</param>");
        WriteLine("AjaxRequest.{0}({1} Controllers.relativePath + Controllers.{2}.{3}URL, successCallback, errorCallback);",
            method,
            string.IsNullOrEmpty(paramList) ? "undefined,": paramList,
            controller.Name.Replace("Controller",""),
            action.Name);
        PopIndent();
        Write("}");
    }

    public string GetParameters(CodeFunction action)
    {
        var paramList = "";
        for (int i = 1; i <= action.Parameters.Count; i++)
        {
            paramList += action.Parameters.Item(i).Name + "JSON";
            if(i <= action.Parameters.Count)
                paramList += ", ";
        }

        return paramList;
    }

    public bool HasAttribute(CodeElements attributes, string attr)
    {
        for (int i = 1; i <= attributes.Count; i++)
        {
            var attrElem = attributes.Item(i);
            if(attrElem.Name == attr)
            {
                return true;
            }
        }
        return false;
    }


    public List<CodeFunction> GetActionsForController(CodeClass cls)
    {
        var actions = new List<CodeFunction>();
        for (int i = 1; i <= cls.Members.Count; i++)
        {
            var member = cls.Members.Item(i);

            var func = member as CodeFunction;

            if(func != null && (func.Type.CodeType.Name == "ActionResult" || HasBaseClass(func.Type.CodeType.Bases, "ActionResult") ||
                HasBaseClass(func.Type.CodeType.Bases, "ContentResult")))
            {
                actions.Add(func);
            }
        }


        return actions;
    }

    public bool HasBaseClass(CodeElements bases, string baseClass)
    {
        var allBases = GetBaseClasses(bases);
        foreach (var bs in allBases)
        {
            if(bs.Name == baseClass)
                return true;
        }
        return false;
    }

    public List<CodeElement> GetBaseClasses(CodeElements bases)
    {
        var allBases = new List<CodeElement>();
        if(bases.Count >0)
        {
            for (int i = 1; i <= bases.Count; i++)
            {
                var bs = bases.Item(i);
                allBases.Add(bs);
                if(bs is CodeClass)
                {
                    allBases.AddRange(GetBaseClasses((bs as CodeClass).Bases));
                }
            }
        }
        return allBases;
    }

    public List<CodeClass> GetCodeClassesInProject(ProjectItems items)
    {
        var classes = new List<CodeClass>();

        for (int i=1; i <= items.Count; i++)
        {
            
            ProjectItem item = items.Item(i);
            if(item.ProjectItems != null && item.ProjectItems.Count > 0 )
            {
                classes.AddRange(GetCodeClassesInProject(item.ProjectItems));
            }

            if(item.FileCodeModel != null)
            {
                classes.AddRange(GetClassesInCodeModel(item.FileCodeModel.CodeElements));
            }
        }
        return classes;
    }

    public List<CodeClass> GetClassesInCodeModel(CodeElements elements)
    {
        var classes = new List<CodeClass>();

        for(int i = 1; i <= elements.Count; i++)
        {
            CodeElement element = elements.Item(i);
            if(element.Kind == vsCMElement.vsCMElementClass)
            {
                classes.Add(element as CodeClass);
            } 

            var members = GetCodeElementMembers(element);
            if(members != null)
            {
                classes.AddRange(GetClassesInCodeModel(members));
            }
        }

        return classes;
    }

    public EnvDTE.CodeElements GetCodeElementMembers(CodeElement elem)
    {
        EnvDTE.CodeElements elements = null;

        if (elem is CodeNamespace)
        {
            elements = (elem as CodeNamespace).Members;
        }
        else if (elem is CodeType)
        {
            elements = (elem as CodeType).Members;
        }
        else if (elem is CodeFunction)
        {
            elements = (elem as CodeFunction).Parameters;
        }

        return elements;
    }
#>

Sam skrypt trochę poprawiłem.

Z jakiegoś powodu tworzył on dwa razy definicje funkcji kontrolerów. Dlatego do skryptu dodałem prostą listę, która sprawdza, czy definicja danego kontrolera już istnieje.

Sam skrypt T4 po prostu skanuje wszystkie kontrolery i ich akcję. Jeśli pewne warunki są  spełnione to zostają one wydrukowane do pliku JavaScript.

//There was a problem with duplicate definitions of javascript functions
//This is a simple fix
List<string> rep = new List<string>();

var classes = GetCodeClassesInProject(proj.ProjectItems);
if(classes.Count > 0)
    Write(",\r\n");

for(int i = 0; i < classes.Count; i++)
{    
    CodeClass cls = classes[i];
    if(HasBaseClass(cls.Bases, "Controller"))
    {
        var name = cls.Name.Replace("Controller","");

        //Checking if name already exist
        if (rep.Contains(name))
            continue;

        WriteLine("'" + name  +"' :");

        rep.Add(name);

        WriteLine("{");
        PushIndent("\t");

        var actions = GetActionsForController(cls);

        // write URLs
        foreach (var action in actions)
        {
            Write("'{1}URL' : '{0}/{1}'",cls.Name.Replace("Controller",""), action.Name);
            if(action != actions.Last())
                Write(",\r\n");
        }

        //Important. if actions/methods inside of a Controller don't have this atributes they will be not printed
        var getActions = actions.Where( a => HasAttribute(a.Attributes, "HttpGet"));
        var postActions = actions.Where(a => HasAttribute(a.Attributes, "HttpPost"));

Później skrypt T4 wypisuje definicję funkcji wszystkich akcji, które posiadają odpowiednie atrybuty.

//Writes GET AJAX Actions that exist in Controller
foreach (var action in getActions)
{
    WriteAjaxWrapper("Get",action, cls);
    if(action != getActions.Last())
        Write(",\r\n");
}

if(postActions.Any())
    Write(",\r\n");

//Writes POST AJAX Actions that exist in Controller
foreach (var action in postActions)
{
    WriteAjaxWrapper("Post",action, cls);
    if(action != postActions.Last())
        Write(",\r\n");
}

Do prezentacji tego skryptu utworzyłem dwa proste kontrolery.

public partial class DemoController : Controller
{
    [HttpPost]
    public virtual JsonResult Users()
    {
        return new JsonResult();
    }

    [HttpPost]
    public virtual JsonResult Transactions()
    {
        return new JsonResult();
    }

Kontrolery te posiadają metody oznaczone odpowiednimi atrybutami.

public partial class SecondPageController : Controller
{

    [HttpGet]
    public virtual JsonResult Contracts()
    {
        return new JsonResult();
    }

    [HttpGet]
    public virtual JsonResult Products()
    {
        return new JsonResult();
    }

Wygenerowany skrypt JS wgląda tak:

var Controllers = {
    relativePath: "",
    SetRelativePath : function(relPath){
        /// <summary>Sets the relative path for Ajax calls to the controller actions</summary>
        /// <param name="relPath" type="String">The relative path that is prefixed before the controller action URL</param>
        Controllers.relativePath = relPath;
    },
    'Demo' :
    {
        'UsersURL' : 'Demo/Users',
        'TransactionsURL' : 'Demo/Transactions',
        'IndexURL' : 'Demo/Index',
        'ContactURL' : 'Demo/Contact',
        'OptionsURL' : 'Demo/Options',
        'UsersPost' : function(successCallback, errorCallback) {
            /// <summary>HTTP Post request Ajax wrapper for the Users action</summary>
            /// <param name="successCallback" >Optional,function to be run upon success of the call</param>
            /// <param name="errorCallback" >Optional, function to be run there was an error with the call</param>
            AjaxRequest.Post(undefined, Controllers.relativePath + Controllers.Demo.UsersURL, successCallback, errorCallback);
        },
        'TransactionsPost' : function(successCallback, errorCallback) {
            /// <summary>HTTP Post request Ajax wrapper for the Transactions action</summary>
            /// <param name="successCallback" >Optional,function to be run upon success of the call</param>
            /// <param name="errorCallback" >Optional, function to be run there was an error with the call</param>
            AjaxRequest.Post(undefined, Controllers.relativePath + Controllers.Demo.TransactionsURL, successCallback, errorCallback);
        }},
    'SecondPage' :
    {
        'ContractsURL' : 'SecondPage/Contracts',
        'ProductsURL' : 'SecondPage/Products',
        'IndexURL' : 'SecondPage/Index',
        'ContractsGet' : function(successCallback, errorCallback) {
            /// <summary>HTTP Get request Ajax wrapper for the Contracts action</summary>
            /// <param name="successCallback" >Optional,function to be run upon success of the call</param>
            /// <param name="errorCallback" >Optional, function to be run there was an error with the call</param>
            AjaxRequest.Get(undefined, Controllers.relativePath + Controllers.SecondPage.ContractsURL, successCallback, errorCallback);
        },
        'ProductsGet' : function(successCallback, errorCallback) {
            /// <summary>HTTP Get request Ajax wrapper for the Products action</summary>
            /// <param name="successCallback" >Optional,function to be run upon success of the call</param>
            /// <param name="errorCallback" >Optional, function to be run there was an error with the call</param>
            AjaxRequest.Get(undefined, Controllers.relativePath + Controllers.SecondPage.ProductsURL, successCallback, errorCallback);
        }},
    'T4MVC_Demo' :
    {
        'IndexURL' : 'T4MVC_Demo/Index',
        'ContactURL' : 'T4MVC_Demo/Contact',
        'OptionsURL' : 'T4MVC_Demo/Options'},
    'T4MVC_SecondPage' :
    {
        'IndexURL' : 'T4MVC_SecondPage/Index'},
};

(function (AjaxRequest, undefined) {
    AjaxRequest.Post = function (jsonData, url, successCallback, errorCallback) {
        $.ajax({
            url: url,
            type: "POST",
            data: jsonData,
            datatype: "json",
            contentType: "application/json charset=utf-8",
            success: function (data) {
                successCallback(data);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                if (errorCallback) {
                    errorCallback(jqXHR,textStatus,errorThrown);
                }
            }
        });
    };


    AjaxRequest.Get = function (jsonData, url, successCallback, errorCallback) {
        $.ajax({
            url: url,
            type: "GET",
            data: jsonData,
            datatype: "json",
            contentType: "application/json charset=utf-8",
            success: function (data) {
                    successCallback(data);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                if (errorCallback) {
                    errorCallback(jqXHR,textStatus,errorThrown);
                }
            }

        });
    };

}(window.AjaxRequest = window.AjaxRequest || {}));

Zanim zaczniesz korzystać z gotowych funkcji AJAX, warto powiedzieć coś o ścieżce relatywnej.

Ścieżki do kontrolerów rzeczywiście się tworzą, ale są one relatywne do samej aplikacji. Ten brakujący fragment  musisz dodać. Masz w ten sposób gwarancję, że aplikacja będzie działała tak samo niezależnie od tego, gdzie umieścisz swoją aplikację.

Oto przykład ustawienie ścieżki relatywnej przy użyciu @Url.Action i T4MVC.

<script src="~/Scripts/controllerscript.js"></script>
<script>

    Controllers.SetRelativePath("@Url.Action("Index")");
    Controllers.SetRelativePath("@Url.Action(MVC.Demo.ActionNames.Index)");

</script>

Wygenerowany skrypt JavaScript ma w sobie adresy do akcji kontrolerów jak i definicję wywołania funkcji AJAX.

Czasem te wygenerowane funkcję przyjmują parametr, jeśli nasza akcja w kontrolerze tego wymaga.

'UsersURL' : 'Demo/Users',
'TransactionsURL' : 'Demo/Transactions',
'IndexURL' : 'Demo/Index',
'ContactURL' : 'Demo/Contact',
'OptionsURL' : 'Demo/Options',
'UsersPost' : function(successCallback, errorCallback) {
    /// <summary>HTTP Post request Ajax wrapper for the Users action</summary>
    /// <param name="successCallback" >Optional,function to be run upon success of the call</param>
    /// <param name="errorCallback" >Optional, function to be run there was an error with the call</param>
    AjaxRequest.Post(undefined, Controllers.relativePath + Controllers.Demo.UsersURL, successCallback, errorCallback);
},

Wygenerowane funkcję AJAX  wywołują bazowo tę funkcję.

(function (AjaxRequest, undefined) {
    AjaxRequest.Post = function (jsonData, url, successCallback, errorCallback) {
        $.ajax({
            url: url,
            type: "POST",
            data: jsonData,
            datatype: "json",
            contentType: "application/json charset=utf-8",
            success: function (data) {
                successCallback(data);
            },
            error: function (jqXHR, textStatus, errorThrown) {
                if (errorCallback) {
                    errorCallback(jqXHR,textStatus,errorThrown);
                }
            }
        });
    };

Użycie wygenerowanej funkcji wygląda tak.

Controllers JavaScript

Ten skrypt T4 bardzo ułatwia życie, zwłaszcza, gdy część twojej aplikacji MVC jest traktowane jak WEB API.