Aprovechando las Partial Views en ASP.NET Core (Parte 1)

Actualización: Aquí muestro ejemplos que incluyen jQuery, que cambié a  JavaScript plano con llamados al backend usando el método fetch. La idea central sigue intacta.


Si trabajan con ASP.NET Core, seguramente han separado los componentes de su interfaz gráfica usando Partial Views o Partials. Son una característica básica del framework junto a las Sections y los Layouts.

Cuando se empezaron a popularizar los frameworks de front-end como Angular, Ractive, React o Vue (y recientemente el estándar WebAssembly), las partials quedaron en un segundo plano porque al ser renderizadas desde el servidor, "no son dinámicas". Ese dinamismo lo están agregando con los Razor Components y Blazor, pero esa es otra historia.

Y aquí viene la opinión impopular: muy pocas aplicaciones son tan complejas como para justificar un framework de front-end. A eso agregaría: muy pocas aplicaciones son tan complejas como para justificar una conexión permanente con el servidor, que es como funcionan los Razor Components. Y una más: que un elemento aparezca o desaparezca de una lista, sin recargar la página, no justifica ninguna complejidad accidental.

Con un poco de javascript se pueden aprovechar las partials para construir aplicaciones web suficientemente dinámicas.

Tomemos de ejemplo una aplicación que permite asignar objetivos a una empresa. Cada usuario puede tener múltiples empresas, pero las administra de forma independiente. El controlador responde a la URL:

/{controller=Company}/{companyId:guid}/{action=Index}/{id?}

El método (action) Index se ve más o menos así:

public async Task<IActionResult> Index(Guid companyId)
{
    var objectives = await _objectiveService.GetObjectives(UserId, companyId);
    var model = objectives.Select(o => ObjectiveViewModel.FromObjective(o));
    return View(model);
}

La view usa un @foreach para renderizar la lista desde el servidor y cada vez que agregamos un nuevo objetivo, se recarga la página. El sitio se demora en cargar y no hay ningún spinner. ¡Horror!. ¿Justifica usar un framework de front-end? Yo creo que no. ¿Justifica hacer todo con javascript y web APIs? Tampoco. Sigamos usando las herramientas que tiene el framework.

Lo primero es crear un método que retorna la lista de objetivos ya renderizada:

public async Task<IActionResult> ObjectivesComponent(Guid companyId)
{
    var objectives = await _objectiveService.GetObjectives(UserId, companyId);
    var model = objectives.Select(o => ObjectiveViewModel.FromObjective(o));
    return PartialView("_ObjectivesPartial", model);
}

Podemos simplificar el Index:

public IActionResult Index(Guid companyId)
{
    var model = new IndexViewModel(companyId);
    return View(model);
}

La view tiene un contenedor que muestra el spinner y carga otras dos partials, una con el script y otra con el modal que se despliega para agregar un nuevo objetivo:

@model IndexViewModel

<div id="objectivesHolder">
    <div class="spinner-border text-primary" role="status">
        <span class="sr-only">Cargando...</span>
    </div>
</div>

<button data-toggle="modal" data-target="#addObjectiveModal" class="btn btn-primary">
    Agregar objetivo
</button>

@section Scripts
{
    @{ await Html.RenderPartialAsync("_IndexScripts", Model.Id); }
}

@section EndOfPage
{
    @{ await Html.RenderPartialAsync("_AddObjectiveModal"); }
}

La partial _IndexScripts tiene un manejador de interfaz muy pequeño:

@model Guid

<script text="text/javascript">
    $(() => {
        // No me gusta inyectar valores del modelo directamente al script,
        // así que los paso como parámetros y desde un solo sitio
        var mrg = new IndexUIMgr("@Html.Raw(Model)");
    });

    class IndexUIMgr {
        constructor(id) {
            this.companyId = id;
            this.configureModal();
            this.loadObjectives();
        }

        configureModal() { }

        async loadObjectives() { }
    }
</script>

El método que carga la partial es loadObjectives:

async loadObjectives() {
    let response = await fetch(`/company/${this.companyId}/objectivesComponent`);

    if(response.ok) {
        let content = await response.text();
        $("#objectivesHolder").html(content);
        // Habilitar otros botones, por ejemplo,
        // los de editar o completar objetivo
    } else {
        $("#objectivesHolder").html("No fue posible cargar el contenido");
    }
}

¡Listo! Ya cargamos la partial de forma dinámica. Ahora sigue el botón de agregar. Yo uso un método genérico para habilitar los formularios:

enableModalForm(modalSelector, submitBtnSelector, formSelector, callback) {
    let modal = $(modalSelector);
    let btn = $(submitBtnSelector);
    let form = $(formSelector);

    btn.on("click", () => {

        // jquery-validation
        if (!form.valid())
        {
            return false;
        }

        form.submit((ev)=>{
            // Evitar múltiples clicks
            btn.prop("disabled", true);

            // Submit sin recargar la página
            $.ajax({
                type: form.attr("method"),
                url: form.attr("action"),
                data: form.serialize(),
                success: (data) => {
                    modal.modal("hide");
                    form.trigger("reset");
                    form.off("submit");
                    callback();
                    btn.prop("disabled", false);
                },
                error: (err) => {
                    form.off("submit");
                    btn.prop("disabled", false);
                }
            });

            ev.preventDefault();
        });
    });
}

El método configureModal llama a enableModalForm:

let context = this;
context.enableModalForm(
    "#addObjectiveModal",
    "#addObjectiveBtn",
    "#addObjectiveForm",
    () => { context.loadObjectives(); });

Y por último, el endpoint para recibir el formulario:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<bool> AddObjective(Guid companyId, AddObjectiveViewModel model)
{
    var created = await _objectiveService.AddObjective(
        userId: UserId,
        companyId: companyId,
        title: model.Title
    );

    return created;
}

Para el botón de editar, basta con llenar el formulario (ej. #editObjectiveForm) antes de mostrar el modal; el resto es igual. Si los formularios son muy grandes, se puede integrar un template engine que ayude a mapear los valores, pero creo que en ese caso es mejor usar una página separada.

Cuando se agrega un nuevo objetivo, el componente se actualiza completamente. La página no se recarga y el scroll no se va hacia arriba. No hay que preocuparse por renderizar un elemento falso mientras se crea "por detrás" el definitivo.

Esta idea no es nueva y hay herramientas muy interesantes como turbolinks que ayudan en el proceso. Pero me parece que en ASP.NET no está suficientemente documentada.

¿Cuándo usar todo un framework? En mi opinión, cuando hay un builder como el de Lucidchart, para herramientas colaborativas como Google Docs, o para aplicaciones en donde hay muchos parámetros e interacciones, como en los buscadores. ¿Qué tanta interacción se puede manejar sin framework? Puedo decir que la suficiente. Todo este sitio está hecho así:


Comentarios

Entradas más populares de este blog

Ampliar el sistema de archivos de una máquina virtual Ubuntu

Adviento C# 2020: Diario de ASP.NET

Carga de muchos datos hacia SQL Server, desde C#