La gestión de formularios que involucran tablas principales y secundarias simultáneamente en ASP.NET MVC es un requisito común en el desarrollo de sistemas empresariales. A pesar de su frecuencia, las soluciones completas y eficientes en línea son escasas. Este artículo explora una implementación para manejar estas operaciones y busca fomentar la discusión sobre enfoques más optimizados.
Ejemplo de Implementación
La estructura de las entidades involucra una entidad principal (Orden) y una entidad secundaria (Detalle de Orden). La interfaz de usuario presenta un formulario principal para la cabecera de la orden y una tabla dinámica para los detalles. La edición de los detalles se realiza a través de ventanas modales emergentes utilizando Bootstrap.
Capa de Vista (View)
Las vistas como Index, Create y Edit manejan la presentación y la interacción del usuario. La vista parcial EditForm se reutiliza en las vistas de creación y edición. El formulario de detalles de la orden (_OrderDetailForm) se presenta en una ventana modal.
La recopilación de datos del encabezado y los detalles de la orden se realiza mediante jQuery, serializando la información en un objeto JSON para enviarlo al controlador vía AJAX POST.
var $orderDetailTable = {}; // Tabla de detalles de la orden
var objectState = "Added"; // Estado del objeto (Añadido, Modificado, Eliminado)
$(document).ready(function () {
$('#orderForm').submit(function (event) {
event.preventDefault(); // Prevenir el envío tradicional del formulario
var actionUrl = $(this).attr('action');
var orderDetails = $orderDetailTable.bootstrapTable('getData'); // Obtener datos de la tabla de detalles
var orderData = {
Id: $('#OrderId').val() || 0, // Obtener ID de la orden, o 0 si es nuevo
Customer: $('#Customer').val(),
ShippingAddress: $('#ShippingAddress').val(),
OrderDate: $('#OrderDate').val(),
ObjectState: objectState, // Estado de la orden principal
OrderDetails: orderDetails.map(function(detail) {
// Asegurarse de que cada detalle tenga su estado correcto
return {
Id: detail.Id || 0,
ProductId: detail.ProductId,
Quantity: detail.Quantity,
UnitPrice: detail.UnitPrice,
ObjectState: detail.ObjectState || "Added" // Estado del detalle
};
})
};
console.log(orderData); // Log para depuración
$.ajax({
url: actionUrl,
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify(orderData),
success: function (result) {
console.log("Éxito:", result);
// Redirigir o mostrar mensaje de éxito
window.location.href = "/Orders/Index";
},
error: function (xhr, status, error) {
console.error("Error:", status, error);
alert("La operación falló. Consulte la consola para más detalles.");
}
});
});
// Inicialización de la tabla de detalles (ejemplo básico)
$orderDetailTable = $('#orderDetailTable').bootstrapTable({
// Opciones de configuración de Bootstrap Table
columns: [
{ field: 'Id', title: 'ID', visible: false },
{ field: 'ProductId', title: 'Product ID', visible: false },
{ field: 'ProductName', title: 'Producto' },
{ field: 'Quantity', title: 'Cantidad' },
{ field: 'UnitPrice', title: 'Precio Unitario' },
{
field: 'operate',
title: 'Acciones',
align: 'center',
formatter: operateFormatter,
events: operateEvents
}
]
});
});
// Función para el formato de botones de acción en la tabla
function operateFormatter(value, row, index) {
return [
'<a class="edit" href="javascript:void(0)" title="Editar">',
'<i class="glyphicon glyphicon-edit"></i>',
'</a> ',
'<a class="remove" href="javascript:void(0)" title="Eliminar">',
'<i class="glyphicon glyphicon-remove"></i>',
'</a>'
].join('');
}
// Eventos para los botones de acción
window.operateEvents = {
'click .edit': function (e, value, row, index) {
console.log("Editar fila:", row, "Índice:", index);
// Lógica para abrir modal de edición con datos de la fila 'row'
editOrderDetail(row, index);
},
'click .remove': function (e, value, row, index) {
console.log("Eliminar fila:", row, "Índice:", index);
// Lógica para eliminar la fila de la tabla temporalmente
// y marcarla para eliminación en el backend.
deleteOrderDetail(index);
}
};
// Función para añadir nuevo detalle de orden
$('#newOrderDetailButton').on('click', function (e) {
e.preventDefault();
if ($("#orderForm").valid()) { // Validar el formulario principal antes de añadir
var url = "/Orders/CreateOrderDetailPartial"; // Acción que devuelve el Partial View del formulario de detalle
$.get(url, function (data) {
$('#orderDetailModalBody').html(data);
$('#orderDetailModal').modal('show');
// Inicializar campos del modal si es necesario
$('#orderDetailRowIndex').val(-1); // -1 indica nueva fila
$('#orderDetailId').val(0); // ID 0 para nueva fila
});
}
});
// Función para editar un detalle de orden existente
function editOrderDetail(rowData, rowIndex) {
var url = "/Orders/EditOrderDetailPartial/" + rowData.Id; // Acción que devuelve el Partial View para editar
$.get(url, function (data) {
$('#orderDetailModalBody').html(data);
$('#orderDetailModal').modal('show');
$('#orderDetailRowIndex').val(rowIndex); // Guardar el índice de la fila a editar
$('#orderDetailId').val(rowData.Id); // Guardar el ID de la fila
});
}
// Función para marcar una fila para eliminación
function deleteOrderDetail(rowIndex) {
var data = $orderDetailTable.bootstrapTable('getData');
if (rowIndex >= 0 && rowIndex < data.length) {
var row = data[rowIndex];
// Marcar el objeto para eliminación si ya existe en la base de datos
if (row.Id && row.Id > 0) {
row.ObjectState = "Deleted";
// Opcionalmente, ocultar visualmente la fila
$('#orderDetailTable').bootstrapTable('updateRow', {
index: rowIndex,
row: { ...row, // Copiar propiedades existentes
// Añadir una clase o estilo para indicar eliminación
css: { 'text-decoration': 'line-through', 'color': 'grey' }
}
});
// También puedes eliminarla de los datos si la lógica del backend
// maneja los estados y no necesita la fila presente en el POST
// $orderDetailTable.bootstrapTable('removeByUniqueId', row.Id); // Si tienes un ID único configurado
// O simplemente actualizar el array de datos si no usas un ID único para bootstrap table
data.splice(rowIndex, 1);
$orderDetailTable.bootstrapTable('load', data); // Recargar tabla con datos actualizados
} else {
// Si es una fila recién añadida y no guardada, simplemente la removemos
$orderDetailTable.bootstrapTable('removeRow', { index: rowIndex });
}
}
}
// Función para guardar el detalle de orden desde el modal
function saveOrderDetail() {
var form = $('#orderDetailForm');
if (form.valid()) {
var detailData = form.serializeObject(); // Asumiendo que tienes una función serializeObject para jQuery
var rowIndex = parseInt($('#orderDetailRowIndex').val());
var existingId = parseInt($('#orderDetailId').val());
var objectState = (existingId > 0) ? "Modified" : "Added";
detailData.ObjectState = objectState;
if (existingId > 0) {
detailData.Id = existingId;
}
if (rowIndex === -1) {
// Añadir nueva fila a la tabla
$orderDetailTable.bootstrapTable('append', detailData);
} else {
// Actualizar fila existente
$orderDetailTable.bootstrapTable('updateRow', {
index: rowIndex,
row: detailData
});
}
$('#orderDetailModal').modal('hide');
}
}
// Adaptador simple para serializeObject si no está disponible
$.fn.serializeObject = function() {
var o = {};
var a = this.serializeArray();
$.each(a, function() {
if (o[this.name] !== undefined) {
if (!o[this.name].push) {
o[this.name] = [o[this.name]];
}
o[this.name].push(this.value || '');
} else {
o[this.name] = this.value || '';
}
});
return o;
};
Nota sobre la serialización JSON: La dificultad para serializar el campo ObjectState del tipo enumerado desde el cliente al servidor es un desafío común. Esto a menudo requiere lógica adicional en el controlador para reestablecer el estado de la entidad antes de la persistencia.
Capa de Controlador (Controller)
El método Create (POST) en el controlador es responsable de procesar los datos enviados. Dado que el estado ObjectState no se serializa correctamente desde el cliente, se implementa un bucle foreach para establecer manualmente el estado de la orden principal y sus detalles antes de llamar al servicio de inserción/actualización.
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using YourProject.Models; // Asegúrate de incluir tus namespaces
using YourProject.Services; // Namespace para tus servicios
using YourProject.Infrastructure; // Namespace para UnitOfWork
public class OrdersController : Controller
{
private readonly IOrderService _orderService;
private readonly IUnitOfWork _unitOfWork;
// Constructor para inyección de dependencias
public OrdersController(IOrderService orderService, IUnitOfWork unitOfWork)
{
_orderService = orderService;
_unitOfWork = unitOfWork;
}
// GET: Orders/Create
public ActionResult Create()
{
// Inicializar ViewBag para listas desplegables si son necesarias
var orderRepository = _unitOfWork.Repository<Order>();
ViewBag.OrderId = new SelectList(orderRepository.Queryable(), "Id", "Customer");
var productRepository = _unitOfWork.Repository<Product>();
ViewBag.ProductId = new SelectList(productRepository.Queryable(), "Id", "Name");
return View();
}
// POST: Orders/Create
[HttpPost]
// [ValidateAntiForgeryToken] // Descomentar si usas ValidateAntiForgeryToken
public ActionResult Create(Order order) // El modelo ahora puede incluir OrderDetails directamente
{
if (ModelState.IsValid)
{
// Lógica para manejar el estado de los objetos
order.ObjectState = ObjectState.Added; // Estado para la orden principal
if (order.OrderDetails != null)
{
foreach (var detail in order.OrderDetails)
{
// Asegurar que los detalles también tengan su estado correcto
detail.ObjectState = ObjectState.Added;
// Si el producto está incluido y necesita ser marcado como Desvinculado
if (detail.Product != null)
{
detail.Product.ObjectState = ObjectState.Detached;
}
}
}
// Llamar al servicio para insertar o actualizar el gráfico de entidades
_orderService.InsertOrUpdateGraph(order);
_unitOfWork.SaveChanges(); // Guardar todos los cambios en la base de datos
// Podrías querer retornar un JSON de éxito o redirigir
// return RedirectToAction("Index");
return Json(new { Status = "Success", Message = "Orden creada exitosamente." }, JsonRequestBehavior.AllowGet);
}
// Si el modelo no es válido, devolver la vista con errores
// Es posible que necesites volver a poblar los ViewBag si los usas
var orderRepo = _unitOfWork.Repository<Order>();
ViewBag.OrderId = new SelectList(orderRepo.Queryable(), "Id", "Customer", order.Id);
var productRepo = _unitOfWork.Repository<Product>();
ViewBag.ProductId = new SelectList(productRepo.Queryable(), "Id", "Name");
return View(order);
}
// Método para obtener el PartialView de creación de detalle de orden
public ActionResult CreateOrderDetailPartial()
{
// Devolver una vista parcial vacía o con datos iniciales
// Asegúrate de que este método devuelva el ViewResult correcto para tu Partial View
var model = new OrderDetail { Id = 0, ProductId = 0, Quantity = 1, UnitPrice = 0 };
// Cargar listas desplegables si son necesarias para el detalle
var productRepository = _unitOfWork.Repository<Product>();
ViewBag.ProductId = new SelectList(productRepository.Queryable(), "Id", "Name");
return PartialView("_OrderDetailForm", model);
}
// Método para obtener el PartialView de edición de detalle de orden
public ActionResult EditOrderDetailPartial(int id)
{
var detailRepository = _unitOfWork.Repository<OrderDetail>();
var orderDetail = detailRepository.Find(id);
if (orderDetail == null)
{
return HttpNotFound();
}
// Cargar listas desplegables si son necesarias para el detalle
var productRepository = _unitOfWork.Repository<Product>();
ViewBag.ProductId = new SelectList(productRepository.Queryable(), "Id", "Name", orderDetail.ProductId);
return PartialView("_OrderDetailForm", orderDetail);
}
// --- Métodos para manejar la eliminación ---
// La eliminación de registros hijos es un desafío.
// Una estrategia común es marcar los registros para eliminación en lugar de eliminarlos físicamente de inmediato.
// O manejar la eliminación directamente en el POST del formulario principal.
// Aquí se asume que el cliente enviará los detalles y su estado (Added, Modified, Deleted).
// El método InsertOrUpdateGraph debería manejar la lógica de eliminación basada en el ObjectState.
}
// Definición de enumeración para el estado del objeto (ejemplo)
public enum ObjectState
{
Unchanged,
Added,
Modified,
Deleted,
Detached // Para entidades que no deben ser rastreadas por el contexto
}
// Clase base de entidad (ejemplo)
public abstract class Entity
{
public int Id { get; set; }
public ObjectState ObjectState { get; set; } = ObjectState.Unchanged;
}
// Entidades de ejemplo
public class Order : Entity
{
public string Customer { get; set; }
public string ShippingAddress { get; set; }
public DateTime OrderDate { get; set; }
public virtual ICollection<OrderDetail> OrderDetails { get; set; } = new List<OrderDetail>();
}
public class OrderDetail : Entity
{
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public virtual Order Order { get; set; }
public virtual Product Product { get; set; }
}
public class Product : Entity
{
public string Name { get; set; }
// Otras propiedades...
}
// Interfaz y Clase de ejemplo para UnitOfWork y Repository
public interface IRepository<T> where T : class
{
IQueryable<T> Queryable();
T Find(int id);
void Add(T entity);
void Update(T entity);
void Delete(T entity);
// ... otros métodos
}
public interface IUnitOfWork : System.IDisposable
{
IRepository<T> Repository<T>() where T : class;
int SaveChanges();
}
// Implementación de servicio de ejemplo (necesitaría una implementación real)
public interface IOrderService
{
void InsertOrUpdateGraph(Order order);
}
// Ejemplo de implementación de InsertOrUpdateGraph (simplificado)
public class OrderService : IOrderService
{
private readonly IUnitOfWork _unitOfWork;
public OrderService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public void InsertOrUpdateGraph(Order order)
{
var orderRepo = _unitOfWork.Repository<Order>();
var detailRepo = _unitOfWork.Repository<OrderDetail>();
if (order.ObjectState == ObjectState.Added)
{
orderRepo.Add(order);
}
else if (order.ObjectState == ObjectState.Modified)
{
orderRepo.Update(order); // Asumiendo que Update maneja la actualización de propiedades
}
// Si la orden principal se marca como eliminada, se debería manejar aquí o en SaveChanges
if (order.OrderDetails != null)
{
foreach (var detail in order.OrderDetails)
{
switch (detail.ObjectState)
{
case ObjectState.Added:
detailRepo.Add(detail);
break;
case ObjectState.Modified:
detailRepo.Update(detail);
break;
case ObjectState.Deleted:
detailRepo.Delete(detail);
break;
}
}
}
}
}
Desafío de la Eliminación: La eliminación de detalles de la orden en estado de edición presenta una dificultad particular. Una solución podría ser marcar los detalles para su eliminación (ObjectState.Deleted) en lugar de eliminarlos enmediatamente del DOM. El backend luego procesaría estas marcas. Sin embargo, esto requiere lógica adicional para filtrar o mostrar correctamente las filas marcadas.
Se está buscando activamente una solución más elegante para la gestión de la eliminación y la serialización del estado del objeto que minimice el código repetitivo en el controlador.
Edición de Detalles en Modal
El botón "Añadir nuevo detalle" (#neworderdetailbutton) y la lógica asociada en el controlador (CreateOrderDetailPartial) permiten abrir una ventana modal para ingresar nuevos detalles. De manera similar, la edición de detalles existentes se maneja a través de una acción del controlador que devuelve un PartialView específico para la edición.
La implementación del manejo de la eliminación, especialmente al editar registros existentes, sigue siendo un área de mejora activa, buscando una solución que sea eficiente y fácil de mantener.