Blazor & Maui: Juan Carlos Aga 2023, Semestre 1
Blazor & Maui: Juan Carlos Aga 2023, Semestre 1
2
PARTE II - App Móvil 21
Páginas 22
ACA VAMOS 22
Controles de presentación 22
Controles que inician comandos 22
Controles para establecer valores 22
Controles de edición de texto 23
Controles para indicar actividad 23
Controles para desplegar colecciones 23
DataBinding 23
El patrón MVVM 24
El uso de comandos 25
Implementando el INotifyPropertyChanged automáticamente 25
Estilos en .NET MAUI 25
CollectionView 25
Consumir APIs 29
SQLite 30
Definiendo un repositorio genérico 31
HASTA ACÁ HE PREPARADO 31
3
PARTE I - WEB & API
4
Matriz de funcionalidad
Cambiar contraseña X X X X
Confirmar el pedido. X X X X
5
Diagrama Entidad Relación
Vamos a crear un sencillo sistema de ventas que va a utilizar el siguiente modelo de datos:
SQL Server
on Azure
6
Vamos a crear esta estructura en Visual Studio (asegurese de poner todos los proyectos rn :
Crear la BD con EF
Recomiendo buscar y leer documentación sobre Code First y Database First. En este curso trabajaremos con EF Code
First, si están interesados en conocer más sobre EF Database First acá les dejo un enlace:
https://docs.microsoft.com/en-us/ef/core/get-started/aspnetcore/existing-db
1. Empecemos creando la carpeta Entites y dentro de esta la entidad Country en el proyecto Shared:
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class Country
{
public int Id { get; set; }
[Display(Name = "País")]
[MaxLength(100, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
7
}
}
using Microsoft.EntityFrameworkCore;
using Sales.Shared.Entities;
namespace Sales.API.Data
{
public class DataContext : DbContext
{
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public DbSet<Country> Countries { get; set; }
{
"ConnectionStrings": {
"DockerConnection": "Data Source=.;Initial Catalog=SalesPrep;User ID=sa;Password=Roger1974.;Connect
Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False",
"LocalConnection": "Server=(localdb)\\
MSSQLLocalDB;Database=Sales2023;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Nota: dejo los 3 string de conexión para que use el que más le convenga en el vídeo de clase explico mejor cual
utilizar en cada caso.
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<DataContext>(x => x.UseSqlServer("name=DockerConnection"));
8
var app = builder.Build();
add-migration InitialDb
update-database
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Route("/api/countries")]
public class CountriesController : ControllerBase
{
private readonly DataContext _context;
[HttpGet]
public async Task<ActionResult> Get()
{
return Ok(await _context.Countries.ToListAsync());
}
[HttpPost]
public async Task<ActionResult> Post(Country country)
{
_context.Add(country);
await _context.SaveChangesAsync();
return Ok(country);
}
}
}
2. Agregamos estas líneas al Program del proyecto API para habilitar su consumo:
app.MapControllers();
app.UseCors(x => x
9
.AllowAnyMethod()
.AllowAnyHeader()
.SetIsOriginAllowed(origin => true)
.AllowCredentials());
app.Run();
7. En el proyecto WEB creamos a carpeta Repositories y dentro de esta creamos la clase HttpResponseWrapper
con el siguiente código:
using System.Net;
namespace Web.Repositories
{
public class HttpResponseWrapper<T>
{
public HttpResponseWrapper(T? response, bool error, HttpResponseMessage httpResponseMessage)
{
Error = error;
Response = response;
HttpResponseMessage = httpResponseMessage;
}
namespace Web.Repositories
{
public interface IRepository
{
Task<HttpResponseWrapper<T>> Get<T>(string url);
using System.Text;
using System.Text.Json;
namespace Sales.WEB.Repositories
{
public class Repository : IRepository
{
private readonly HttpClient _httpClient;
await builder.Build().RunAsync();
@typeparam Titem
@if(MyList is null)
{
12
@if(Loading is null)
{
<div class="align-items-center">
<img src="https://upload.wikimedia.org/wikipedia/commons/b/b1/Loading_icon.gif?20151024034921" />
</div>
}
else
{
@Loading
}
}
else if(MyList.Count == 0)
{
@if(NoRecords is null)
{
<p>No hay registros para mostrar...</p>
}
else
{
@NoRecords
}
}
else
{
@Body
}
@code {
[Parameter]
public RenderFragment? Loading { get; set; }
[Parameter]
public RenderFragment? NoRecords { get; set; }
[Parameter]
[EditorRequired]
public RenderFragment Body { get; set; } = null!;
[Parameter]
[EditorRequired]
public List<Titem> MyList { get; set; } = null!;
}
12. En el proyecto WEB Dentro de Pages creamos la carpeta Countries y dentro de esta carpeta creamos la página
CountriesIndex:
@page "/countries"
@inject IRepository repository
<h3>Paises</h3>
<div class="mb-3">
<a class="btn btn-primary" href="/countries/create">Nuevo País</a>
13
</div>
<GenericList MyList="Countries">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>País</th>
</tr>
</thead>
<tbody>
@foreach (var country in Countries!)
{
<tr>
<td>
<a class="btn btn-warning">Editar</a>
<button class="btn btn-danger">Borrar</button>
</td>
<td>
@country.Name
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
@code {
public List<Country>? Countries { get; set; }
13. Agregamos los problemas de los using y luego movemos esos using al _Imports.razor:
@using Sales.WEB.Shared
@using Sales.Shared.Entities
@using Sales.WEB.Repositories
15. Configuramos nuestro proyecto para que inicie al mismo tiempo el proyecto API y el proyecto WEB:
[HttpGet("{id:int}")]
public async Task<ActionResult> Get(int id)
{
var country = await _context.Countries.FirstOrDefaultAsync(x => x.Id == id);
if (country is null)
{
return NotFound();
}
return Ok(country);
}
[HttpPut]
public async Task<ActionResult> Put(Country country)
{
_context.Update(country);
await _context.SaveChangesAsync();
return Ok(country);
}
[HttpDelete("{id:int}")]
public async Task<ActionResult> Delete(int id)
{
var afectedRows = await _context.Countries
.Where(x => x.Id == id)
.ExecuteDeleteAsync();
if (afectedRows == 0)
{
15
return NotFound();
}
return NoContent();
}
21. Vamos agregarle al proyecto WEB el paquete CurrieTechnologies.Razor.SweetAlert2, que nos va a servir
para mostrar modeles de alertas muy bonitos.
22. Vamos a la página de Sweet Alert 2 (Basaingeal/Razor.SweetAlert2: A Razor class library for interacting with
SweetAlert2 (github.com) y copiamos el script que debemos de agregar al index.html que está en el wwwroot
de nuestro proyecto WEB.
<script src="_framework/blazor.webassembly.js"></script>
16
<script src="_content/CurrieTechnologies.Razor.SweetAlert2/sweetAlert2.min.js"></script>
</body>
builder.Services.AddScoped<IRepository, Repository>();
builder.Services.AddSweetAlert2();
@code {
[EditorRequired]
[Parameter]
public Country Country { get; set; } = null!;
[EditorRequired]
[Parameter]
public EventCallback OnValidSubmit { get; set; }
[EditorRequired]
[Parameter]
public EventCallback ReturnAction { get; set; }
}
@page "/countries/create"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Crear País</h3>
@code {
private Country country = new();
navigationManager.NavigateTo("/countries");
}
<h3>Países</h3>
<GenericList MyList="Countries">
28. Mejorermos el formulario previniendo que el usuario salga y deje el formulario incompleto, modificamos nuestro
componente CountryForm:
<NavigationLock OnBeforeInternalNavigation="OnBeforeInternalNavigation"></NavigationLock>
@code {
private EditContext editContext = null!;
[EditorRequired]
[Parameter]
public Country Country { get; set; } = null!;
[EditorRequired]
[Parameter]
public EventCallback OnValidSubmit { get; set; }
[EditorRequired]
[Parameter]
public EventCallback ReturnAction { get; set; }
if (!formWasEdited)
{
return;
}
if (FormPostedSuccessfully)
{
return;
}
if (confirm)
{
return;
}
context.PreventNavigation();
}
}
@page "/countries/create"
@inject NavigationManager navigationManager
@inject IRepository repository
19
@inject SweetAlertService sweetAlertService
<h3>Crear País</h3>
@code {
private Country country = new();
private CountryForm? countryForm;
if (httpResponse.Error)
{
var mensajeError = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", mensajeError, SweetAlertIcon.Error);
}
else
{
countryForm!.FormPostedSuccessfully = true;
navigationManager.NavigateTo("countries");
}
}
30. Probamos la creación de países por interfaz y luego hacemos nuestro commit. Asegurate de presionar Ctrl +
F5, para que te tome los cambios.
@page "/countries/edit/{Id:int}"
@inject NavigationManager navigationManager
@inject IRepository repository
@inject SweetAlertService sweetAlertService
<h3>Editar País</h3>
@code {
20
private Country? country;
private CountryForm? countryForm;
[Parameter]
public int Id { get; set; }
if (responseHTTP.Error)
{
if (responseHTTP.HttpResponseMessage.StatusCode == System.Net.HttpStatusCode.NotFound)
{
navigationManager.NavigateTo("countries");
}
else
{
var messageError = await responseHTTP.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", messageError, SweetAlertIcon.Error);
}
}
else
{
country = responseHTTP.Response;
}
}
if (responseHTTP.Error)
{
var mensajeError = await responseHTTP.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", mensajeError, SweetAlertIcon.Error);
}
else
{
countryForm!.FormPostedSuccessfully = true;
navigationManager.NavigateTo("countries");
}
}
@page "/countries"
@inject IRepository repository
21
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Paises</h3>
<div class="mb-3">
<a class="btn btn-primary" href="/countries/create">Nuevo País</a>
</div>
<GenericList MyList="Countries">
<RecordsComplete>
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>País</th>
</tr>
</thead>
<tbody>
@foreach (var country in Countries!)
{
<tr>
<td>
<a href="/countries/edit/@country.Id" class="btn btn-warning">Editar</a>
<button class="btn btn-danger" @onclick=@(() => Delete(country))>Borrar</button>
</td>
<td>
@country.Name
</td>
</tr>
}
</tbody>
</table>
</RecordsComplete>
</GenericList>
@code {
public List<Country>? Countries { get; set; }
if (confirm)
{
return;
}
if (responseHTTP.Error)
{
if (responseHTTP.HttpResponseMessage.StatusCode == System.Net.HttpStatusCode.NotFound)
{
navigationManager.NavigateTo("/");
}
else
{
var mensajeError = await responseHTTP.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", mensajeError, SweetAlertIcon.Error);
}
}
else
{
await Load();
}
}
}
33. Y probamos la edición y eliminación de países por interfaz. No olvides hacer el commit.
[HttpPost]
public async Task<ActionResult> Post(Country country)
{
_context.Add(country);
try
{
await _context.SaveChangesAsync();
return Ok(country);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
23
{
return BadRequest("Ya existe un país con el mismo nombre.");
}
else
{
return BadRequest(dbUpdateException.InnerException.Message);
}
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut]
public async Task<ActionResult> Put(Country country)
{
_context.Update(country);
try
{
await _context.SaveChangesAsync();
return Ok(country);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un registro con el mismo nombre.");
}
else
{
return BadRequest(dbUpdateException.InnerException.Message);
}
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
2. Probamos. Ahora vamos a adicionar un alimentador de la base de datos. Para esto primero creamos en el
proyecto API dentro de la carpeta Data la clase SeedDb:
using Sales.Shared.Entities;
namespace Sales.API.Data
{
public class SeedDb
{
private readonly DataContext _context;
await _context.SaveChangesAsync();
}
}
}
3. Luego modificamos el Program del proyecto API para llamar el alimentador de la BD:
Actividad #1
Con el conocimiento adquirido hasta el momento hacer lo mismo para las categorías. El modelo
categoría es muy sencillo, solo tiene el Id y el Name (igual a país). Cree todo lo necesario para que
haya un CRUD de categorías, y modifique el alimentador de base de datos para que adicione algunas
categorías por defecto.
namespace Sales.Shared.Entities
{
public class State
{
public int Id { get; set; }
[Display(Name = "Departamento/Estado")]
[MaxLength(100, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[Display(Name = "Estados/Departamentos")]
public int StatesNumber => States == null ? 0 : States.Count;
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class City
{
public int Id { get; set; }
[Display(Name = "Ciudad")]
[MaxLength(100, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[Display(Name = "Ciudades")]
public int CitiesNumber => Cities == null ? 0 : Cities.Count;
26
5. Modificamos el DataContext:
6. Antes de continuar vamos a modifcar la entidad State y City para agregar el CountryId y StateId y esto nos va a
facilitar la vida para cuando estemos implementando el CRUD multi-nivel:
8. Para evitar la redundancia ciclica en la respuesta de los JSON vamos a agregar la siguiente configuración,
modificamos el Program del API:
builder.Services.AddControllers()
.AddJsonOptions(x => x.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
9. Modificamos el Seeder:
await _context.SaveChangesAsync();
28
}
[HttpGet]
public async Task<ActionResult> Get()
{
return Ok(await _context.Countries
.Include(x => x.States)
.ToListAsync());
}
[HttpGet("full")]
public async Task<ActionResult> GetFull()
{
return Ok(await _context.Countries
.Include(x => x.States!)
.ThenInclude(x => x.Cities)
.ToListAsync());
}
11. Borramos la base de datos con el comando drop-database para que el Seeder vueva a ser ejecutado.
12. Adicionamos la nueva migración de la base de datos con el comando: add-migration AddStatesAndCities y
aunque el Seeder corre automáticamente el Update Database, prefiero correrlo manualmente para asegurarme
que no genere ningun error: update-databse.
13. Cambiemos el Index de países para ver el número de departamentos/estados de cada país y adicionar el botón
de detalles:
<GenericList MyList="Countries">
<RecordsComplete>
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>País</th>
<th>Departamentos/Estados</th>
</tr>
</thead>
<tbody>
@foreach (var country in Countries!)
{
<tr>
<td>
<a href="/countries/details/@country.Id" class="btn btn-info">Detalles</a>
<a href="/countries/edit/@country.Id" class="btn btn-warning">Editar</a>
<button class="btn btn-danger" @onclick=@(() => Delete(country))>Borrar</button>
</td>
<td>
@country.Name
</td>
<td>
@country.StatesNumber
29
</td>
</tr>
}
</tbody>
</table>
</RecordsComplete>
</GenericList>
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Route("/api/states")]
public class StatesController : ControllerBase
{
private readonly DataContext _context;
[HttpGet]
public async Task<IActionResult> GetAsync()
{
return Ok(await _context.States
.Include(x => x.Cities)
.ToListAsync());
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetAsync(int id)
{
var state = await _context.States
.Include(x => x.Cities)
.FirstOrDefaultAsync(x => x.Id == id);
if (state == null)
{
return NotFound();
}
return Ok(state);
}
30
[HttpPost]
public async Task<ActionResult> PostAsync(State state)
{
try
{
_context.Add(state);
await _context.SaveChangesAsync();
return Ok(state);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un estado/departamento con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut]
public async Task<ActionResult> PutAsync(State state)
{
try
{
_context.Update(state);
await _context.SaveChangesAsync();
return Ok(state);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un estado/departamento con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var state = await _context.States.FirstOrDefaultAsync(x => x.Id == id);
if (state == null)
31
{
return NotFound();
}
_context.Remove(state);
await _context.SaveChangesAsync();
return NoContent();
}
}
}
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Route("/api/cities")]
public class CitiesController : ControllerBase
{
private readonly DataContext _context;
[HttpGet]
public async Task<IActionResult> GetAsync()
{
return Ok(await _context.Cities.ToListAsync());
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetAsync(int id)
{
var city = await _context.Cities.FirstOrDefaultAsync(x => x.Id == id);
if (city == null)
{
return NotFound();
}
return Ok(city);
}
[HttpPost]
public async Task<ActionResult> PostAsync(City city)
{
try
{
32
_context.Add(city);
await _context.SaveChangesAsync();
return Ok(city);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe una ciudad con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut]
public async Task<ActionResult> PutAsync(City city)
{
try
{
_context.Update(city);
await _context.SaveChangesAsync();
return Ok(city);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe una ciudad con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var city = await _context.Cities.FirstOrDefaultAsync(x => x.Id == id);
if (city == null)
{
return NotFound();
}
_context.Remove(city);
await _context.SaveChangesAsync();
33
return NoContent();
}
}
}
@page "/countries/details/{Id:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@if(country is null)
{
<p>Cargando...</p>
} else
{
<h3>@country.Name</h3>
<div class="mb-2">
<a class="btn btn-primary" href="/states/create/@country.Id">Nuevo Estado/Departamento</a>
<a class="btn btn-success" href="/countries">Regresar</a>
</div>
<GenericList MyList="states">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>Estado / Departamento</th>
<th>Ciudades</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var state in states!)
{
<tr>
<td>
@state.Name
</td>
<td>
@state.CitiesNumber
</td>
<td>
<a class="btn btn-info" href="/states/details/@state.Id">Detalles</a>
<a class="btn btn-warning" href="/states/edit/@state.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(state.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
34
}
@code {
private Country? country;
private List<State>? states;
[Parameter]
public int Id { get; set; }
country = responseHttp.Response;
states = country!.States!.ToList();
}
await LoadAsync();
}
}
19. Ahora vamos a implementar la creación de estados. En el proyecto WEB en la carpeta Pages la carpeta States
y dentro de esta creamos el componente StateForm:
@code {
private EditContext editContext = null!;
[Parameter]
[EditorRequired]
public State State { get; set; } = null!;
[Parameter]
[EditorRequired]
public EventCallback OnValidSubmit { get; set; }
[Parameter]
[EditorRequired]
public EventCallback ReturnAction { get; set; }
context.PreventNavigation();
}
}
20. En el proyecto WEB en la carpeta Pages la carpeta States y dentro de esta creamos el componente
StateCreate:
@page "/states/create/{CountryId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Crear Estado/Departamento</h3>
@code {
private State state = new();
private StateForm? stateForm;
[Parameter]
public int CountryId { get; set; }
state.CountryId = CountryId;
37
var httpResponse = await repository.Post("/api/states", state);
if (httpResponse.Error)
{
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
Return();
}
21. En el proyecto WEB en la carpeta Pages la carpeta States y dentro de esta creamos el componente EditState:
@page "/states/edit/{StateId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
<h3>Editar Estado/Departamento</h3>
@code {
private State? state;
private StateForm? stateForm;
[Parameter]
public int StateId { get; set; }
state = responseHttp.Response;
}
Return();
}
22. En el proyecto WEB en la carpeta Pages la carpeta States y dentro de esta creamos el componente
StateDetails:
@page "/states/details/{StateId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<GenericList MyList="cities">
<Body>
<table class="table table-striped">
<thead>
<tr>
39
<th>Ciudad</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var city in cities!)
{
<tr>
<td>
@city.Name
</td>
<td>
<a class="btn btn-warning" href="/cities/edit/@city.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(city.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
}
@code {
private State? state;
private List<City>? cities;
[Parameter]
public int StateId { get; set; }
state = responseHttp.Response;
cities = state!.Cities!.ToList();
}
40
private async Task DeleteAsync(int cityId)
{
var result = await sweetAlertService.FireAsync(new SweetAlertOptions
{
Title = "Confirmación",
Text = "¿Realmente deseas eliminar el registro?",
Icon = SweetAlertIcon.Question,
ShowCancelButton = true,
CancelButtonText = "No",
ConfirmButtonText = "Si"
});
await LoadAsync();
}
}
23. En el proyecto WEB en la carpeta Pages creamos la carpeta Cities y dentro de esta creamos el componente
CityForm:
41
@code {
private EditContext editContext = null!;
[Parameter]
[EditorRequired]
public City City { get; set; } = null!;
[Parameter]
[EditorRequired]
public EventCallback OnValidSubmit { get; set; }
[Parameter]
[EditorRequired]
public EventCallback ReturnAction { get; set; }
context.PreventNavigation();
}
}
24. En el proyecto WEB en la carpeta Pages en la carpeta Cities y dentro de esta creamos el componente
CityCreate:
@page "/cities/create/{StateId:int}"
42
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Crear Ciudad</h3>
@code {
private City city = new();
private CityForm? cityForm;
[Parameter]
public int StateId { get; set; }
Return();
}
25. En el proyecto WEB en la carpeta Pages en la carpeta Cities y dentro de esta creamos el componente CityEdit:
@page "/cities/edit/{CityId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
<h3>Editar Ciudad</h3>
[Parameter]
public int CityId { get; set; }
city = responseHttp.Response;
}
Return();
}
28. Al proyecto API agrega al appstettings.json los siguientes parámetros. No olvides cambiar el valor del
TokenValue por la obtenida por usted en el paso anterior:
{
"ConnectionStrings": {
"DockerConnection": "Data Source=.;Initial Catalog=SalesPrep;User ID=sa;Password=Roger1974.;Connect
Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
},
"CoutriesAPI": {
"urlBase": "https://api.countrystatecity.in",
"tokenName": "X-CSCAPI-KEY",
"tokenValue": "NUZicm9hR0FUb0oxUU5mck14NEY3cEFkcU9GR3VqdEhVOGZlODlIRQ=="
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
29. Al proyecto Shared dentro de la carpeta Responses las clases que vamos a obtener de la API. Empecemos
primero con la clase genérica para todas las respuestas Response:
namespace Sales.Shared.Responses
{
public class Response
{
public bool IsSuccess { get; set; }
using Newtonsoft.Json;
namespace Sales.Shared.Responses
{
public class CountryResponse
{
[JsonProperty("id")]
public long Id { get; set; }
[JsonProperty("name")]
public string? Name { get; set; }
45
[JsonProperty("iso2")]
public string? Iso2 { get; set; }
}
}
using Newtonsoft.Json;
namespace Sales.Shared.Responses
{
public class StateResponse
{
[JsonProperty("id")]
public long Id { get; set; }
[JsonProperty("name")]
public string? Name { get; set; }
[JsonProperty("iso2")]
public string? Iso2 { get; set; }
}
}
using Newtonsoft.Json;
namespace Sales.Shared.Responses
{
public class CityResponse
{
[JsonProperty("id")]
public long Id { get; set; }
[JsonProperty("name")]
public string? Name { get; set; }
}
}
33. En el proyecto API creamos la carpeta Services y dentro de esta, la interfaz IApiService:
using Sales.Shared.Responses;
namespace Sales.API.Services
{
public interface IApiService
{
Task<Response> GetListAsync<T>(string servicePrefix, string controller);
}
}
46
using Newtonsoft.Json;
using Sales.Shared.Responses;
namespace Sales.API.Services
{
public class ApiService : IApiService
{
private readonly IConfiguration _configuration;
private readonly string _urlBase;
private readonly string _tokenName;
private readonly string _tokenValue;
client.DefaultRequestHeaders.Add(_tokenName, _tokenValue);
string url = $"{servicePrefix}{controller}";
HttpResponseMessage response = await client.GetAsync(url);
string result = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
return new Response
{
IsSuccess = false,
Message = result,
};
}
builder.Services.AddTransient<SeedDb>();
builder.Services.AddScoped<IApiService, ApiService>();
using Microsoft.EntityFrameworkCore;
using Sales.API.Services;
using Sales.Shared.Entities;
using Sales.Shared.Responses;
namespace Sales.API.Data
{
public class SeedDb
{
private readonly DataContext _context;
private readonly IApiService _apiService;
38. Se puede demorar varias horas para llenar la mayoría de países con sus estados y ciudades. Digo la mayorìa
porque la lógica deshecha algunos paises o estados que no tienen ciudades devueltas por la API.
49
39. Probamos y hacemos el commit.
Agregando paginación
1. En el projecto Shared creamos la carpeta DTOs y dentro de esta creamos la clase PaginationDTO:
namespace Sales.Shared.DTOs
{
public class PaginationDTO
{
public int Id { get; set; }
using Sales.Shared.DTOs;
namespace Sales.API.Helpers
{
public static class QueryableExtensions
{
public static IQueryable<T> Paginate<T>(this IQueryable<T> queryable,
PaginationDTO pagination)
{
return queryable
.Skip((pagination.Page - 1) * pagination.RecordsNumber)
.Take(pagination.RecordsNumber);
}
}
}
[HttpGet]
public async Task<IActionResult> GetAsync([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Countries
.Include(x => x.States)
.AsQueryable();
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
50
{
var queryable = _context.Countries.AsQueryable();
double count = await queryable.CountAsync();
double totalPages = Math.Ceiling(count / pagination.RecordsNumber);
return Ok(totalPages);
}
<nav>
<ul class="pagination">
@code {
[Parameter] public int CurrentPage { get; set; } = 1;
[Parameter] public int TotalPages { get; set; }
[Parameter] public int Radio { get; set; } = 5;
[Parameter] public EventCallback<int> SelectedPage { get; set; }
List<PageModel> Links = new();
class PageModel
{
public string Text { get; set; } = null!;
public int Page { get; set; }
public bool Enable { get; set; } = true;
public bool Active { get; set; } = false;
}
}
@page "/countries"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Países</h3>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="Countries">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>País</th>
<th style="width:220px">Estados / Departamentos</th>
<th style="width:260px"></th>
</tr>
</thead>
<tbody>
@foreach (var country in Countries!)
{
<tr>
<td>
@country.Name
</td>
<td>
@country.StatesNumber
</td>
<td>
<a class="btn btn-info" href="/countries/details/@country.Id">Detalles</a>
<a class="btn btn-warning" href="/countries/edit/@country.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(country.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
53
</table>
</Body>
</GenericList>
@code {
public List<Country>? Countries { get; set; }
private int currentPage = 1;
private int totalPages;
await LoadAsync();
}
}
8. Probamos.
9. Ahora vamos hacer lo mismo para estados. Empezamos modificando el GET del StatesController y de paso
creamos el método para obtener el número de página:
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.States
.Include(x => x.Cities)
.Where(x => x.Country!.Id == pagination.Id)
.AsQueryable();
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.States
.Where(x => x.Country!.Id == pagination.Id)
.AsQueryable();
@page "/countries/details/{Id:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@if(country is null)
{
<p>Cargando...</p>
} else
55
{
<h3>@country.Name</h3>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="sates!">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>Estado / Departamento</th>
<th style="width:140px">Ciudades</th>
<th style="width:260px"></th>
</tr>
</thead>
<tbody>
@foreach (var state in states!)
{
<tr>
<td>
@state.Name
</td>
<td>
@state.CitiesNumber
</td>
<td>
<a class="btn btn-info" href="/states/details/@state.Id">Detalles</a>
<a class="btn btn-warning" href="/states/edit/@state.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(state.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
}
@code {
private Country? country;
private List<State>? states;
private int currentPage = 1;
private int totalPages;
[Parameter]
public int Id { get; set; }
56
private async Task SelectedPage(int page)
{
currentPage = page;
await LoadAsync(page);
}
await LoadAsync();
}
}
12. Probamos.
57
13. Ahora vamos hacer lo mismo para ciudades. Empezamos modificando el GET del CitiesController y de paso
creamos el método para obtener el número de página:
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Cities
.Where(x => x.State!.Id == pagination.Id)
.AsQueryable();
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Cities
.Where(x => x.State!.Id == pagination.Id)
.AsQueryable();
@page "/states/details/{StateId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="cities!">
<Body>
<table class="table table-striped">
<thead>
58
<tr>
<th>Ciudad</th>
<th style="width:180px"></th>
</tr>
</thead>
<tbody>
@foreach (var city in cities!)
{
<tr>
<td>
@city.Name
</td>
<td>
<a class="btn btn-warning" href="/cities/edit/@city.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(city.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
}
@code {
private State? state;
private List<City>? cities;
private int currentPage = 1;
private int totalPages;
[Parameter]
public int StateId { get; set; }
await LoadAsync();
}
Agregando filtros
1. En el projecto Shared modificamos la clase PaginationDTO:
2. En el projecto WEB modificamos los métodos Get y GetPages del controlador CountriesController:
[HttpGet]
60
public async Task<IActionResult> GetAsync([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Countries
.Include(x => x.States)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Countries.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
@page "/countries"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Países</h3>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="Countries">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>País</th>
<th style="width:220px">Estados / Departamentos</th>
<th style="width:260px"></th>
</tr>
</thead>
<tbody>
@foreach (var country in Countries!)
{
<tr>
<td>
@country.Name
</td>
<td>
@country.StatesNumber
</td>
<td>
<a class="btn btn-info" href="/countries/details/@country.Id">Detalles</a>
<a class="btn btn-warning" href="/countries/edit/@country.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(country.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
@code {
public List<Country>? Countries { get; set; }
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/countries?page={page}";
url2 = $"api/countries/totalPages";
}
else
{
url1 = $"api/countries?page={page}&filter={Filter}";
url2 = $"api/countries/totalPages?filter={Filter}";
}
await LoadAsync();
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.States
.Include(x => x.Cities)
.Where(x => x.Country!.Id == pagination.Id)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.States
64
.Where(x => x.Country!.Id == pagination.Id)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Cities
.Where(x => x.State!.Id == pagination.Id)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Cities
.Where(x => x.State!.Id == pagination.Id)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
7. Modificamos el CountryDetails.
@page "/countries/details/{Id:int}"
@inject IRepository repository
65
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@if(country is null)
{
<p>Cargando...</p>
} else
{
<h3>@country.Name</h3>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="states!">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>Estado / Departamento</th>
<th style="width:140px">Ciudades</th>
<th style="width:260px"></th>
</tr>
</thead>
<tbody>
@foreach (var state in states!)
{
<tr>
<td>
@state.Name
</td>
<td>
@state.CitiesNumber
</td>
<td>
<a class="btn btn-info" href="/states/details/@state.Id">Detalles</a>
<a class="btn btn-warning" href="/states/edit/@state.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(state.Id))>Borrar</button>
</td>
66
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
}
@code {
private Country? country;
private List<State>? states;
private int currentPage = 1;
private int totalPages;
[Parameter]
public int Id { get; set; }
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/states?id={Id}&page={page}";
url2 = $"api/states/totalPages?id={Id}";
}
else
{
url1 = $"api/states?id={Id}&page={page}&filter={Filter}";
67
url2 = $"api/states/totalPages?id={Id}&filter={Filter}";
}
await LoadAsync();
}
8. Modificamos el StateDetails.
@page "/states/details/{StateId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
<GenericList MyList="cities!">
<Body>
<table class="table table-striped">
<thead>
<tr>
<th>Ciudad</th>
<th style="width:180px"></th>
</tr>
</thead>
<tbody>
@foreach (var city in cities!)
{
<tr>
<td>
@city.Name
</td>
<td>
69
<a class="btn btn-warning" href="/cities/edit/@city.Id">Editar</a>
<button class="btn btn-danger" @onclick=@(() => DeleteAsync(city.Id))>Borrar</button>
</td>
</tr>
}
</tbody>
</table>
</Body>
</GenericList>
}
@code {
private State? state;
private List<City>? cities;
private int currentPage = 1;
private int totalPages;
[Parameter]
public int StateId { get; set; }
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/cities?id={StateId}&page={page}";
url2 = $"api/cities/totalPages?id={StateId}";
}
70
else
{
url1 = $"api/cities?id={StateId}&page={page}&filter={Filter}";
url2 = $"api/cities/totalPages?id={StateId}&filter={Filter}";
}
await LoadAsync();
}
Actividad #2
Con el conocimiento adquirido hasta el momento hacer lo mismo para las categorías, es decir,
paginación y filtros. Adicionalmente, requiero que modifiquen la funcionalidad del componente
genérico Paginantion para que funcione de la siguiente manera:
10. Si son 10 o menos pagínas muestre, el número de páginas que son, por ejemplo si son 4
páginas deberia mostrar:
Note que el 2 está de fondo azul porque se supone que es la página activa. Si la página activa
fuera la 1, el botón anterior debe estar des-habilitado:
11. Si son más de 10 pagínas, solo muestre las 10 primeras páginas y el botón siguiente va
cambiando los número de página. Por ejemplo si son 20 páginas debe mostrar:
Y cuando el usuario llegue a la página 10 y presione siguiente, debe cambiar los números de las
páginas:
Y así sucesivamente hasta que llegue al la última página, para nestro ejemplo la 20 en la cual ya
se debe deshabilitar el botón siguiente, puesto que ya no hay más paginas:
Y si presiona el botón anterior, debe colocar los números de pagina correctos hasta llegar al 1 y
habilitar el boton siguiente, porque ya hay una página siguiente luego de la página 19.
namespace Sales.Shared.Enums
72
{
public enum UserType
{
Admin,
User
}
}
using Microsoft.AspNetCore.Identity;
using Sales.Shared.Enums;
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class User : IdentityUser
{
[Display(Name = "Documento")]
[MaxLength(20, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Document { get; set; } = null!;
[Display(Name = "Nombres")]
[MaxLength(50, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string FirstName { get; set; } = null!;
[Display(Name = "Apellidos")]
[MaxLength(50, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string LastName { get; set; } = null!;
[Display(Name = "Dirección")]
[MaxLength(200, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Address { get; set; } = null!;
[Display(Name = "Foto")]
public string? Photo { get; set; }
[Display(Name = "Ciudad")]
[Range(1, int.MaxValue, ErrorMessage = "Debes seleccionar una {0}.")]
public int CityId { get; set; }
[Display(Name = "Usuario")]
public string FullName => $"{FirstName} {LastName}";
73
}
}
15. Modificamos la entidad City para definir la relación a ambos lados de esta:
using Microsoft.AspNetCore.Identity;
using Sales.Shared.Entities;
namespace Sales.API.Helpers
{
public interface IUserHelper
{
Task<User> GetUserAsync(string email);
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.Entities;
namespace Sales.API.Helpers
{
public class UserHelper : IUserHelper
{
private readonly DataContext _context;
private readonly UserManager<User> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
74
}
builder.Services.AddScoped<IApiService, ApiService>();
builder.Services.AddScoped<IUserHelper, UserHelper>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
private async Task<User> CheckUserAsync(string document, string firstName, string lastName, string email, string
phone, string address, UserType userType)
76
{
var user = await _userHelper.GetUserAsync(email);
if (user == null)
{
user = new User
{
FirstName = firstName,
LastName = lastName,
Email = email,
UserName = email,
PhoneNumber = phone,
Address = address,
Document = document,
City = _context.Cities.FirstOrDefault(),
UserType = userType,
};
return user;
}
PM> drop-database
PM> add-migration Users
PM> update-database
@using Microsoft.AspNetCore.Components.Authorization
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
namespace Sales.WEB.Auth
{
public class AuthenticationProviderTest : AuthenticationStateProvider
77
{
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var anonimous = new ClaimsIdentity();
return await Task.FromResult(new AuthenticationState(new ClaimsPrincipal(anonimous)));
}
}
}
5. Modificamos el App.razor:
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<CascadingAuthenticationState>
<PageTitle>No encontrado</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Lo sentimos no hay nada en esta ruta.</p>
</LayoutView>
</CascadingAuthenticationState>
</NotFound>
</Router>
6. Probamos y vemos que aparentemente no pasa nada, ahora a nuestro AuthenticationProviderTest le vamos a
colocar un tiempo de espera:
@page "/"
<PageTitle>Index</PageTitle>
<AuthorizeView>
<p>Estas autenticado</p>
</AuthorizeView>
<h1>Hello, world!</h1>
<AuthorizeView>
<Authorized>
<p>Estas autenticado</p>
</Authorized>
<NotAuthorized>
<p>No estas autorizado</p>
</NotAuthorized>
</AuthorizeView>
13. Y jugamos con el AuthenticationProviderTest para ver que pasa con el usuario anonimous y con el usuario
zuluUser.
<AuthorizeView>
<Authorized>
<p>Estas autenticado, @context.User.Identity?.Name</p>
</Authorized>
<NotAuthorized>
<p>No estas autorizado</p>
</NotAuthorized>
</AuthorizeView>
<AuthorizeView Roles="Admin">
<Authorized>
<p>Estas autenticado y autorizado, @context.User.Identity?.Name</p>
</Authorized>
<NotAuthorized>
<p>No estas autorizado</p>
</NotAuthorized>
</AuthorizeView>
18. Ahora cambiamos nuestro NavMenu para mostrar la opción de países solo a los administradores, y jugamos con
nuestro AuthenticationProviderTest para cambiarle el rol al usuario:
19. Pero nótese que solo estamos ocultando la opción, si el usuario por la URL introduce la dirección de países,
pues podrá acceder a nuestras páginas, lo cual es algo que no queremos.
20. Para evitar esto le colocamos este atributo a todos los componentes a los que navegamos y queremos proteger:
22. Antes de continuar aprendamos a identificar si el usuario esta autenticado por código C#, hagamos la prueba en
el componente Counter y modificamos el AuthenticationProviderTest para poder hacer la prueba:
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
@code {
private int currentCount = 0;
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; } = null!;
26. Creamos el parámetro jwtKey en el appsettings del proyecto API (cualquier cosa, entre mas larga mejor):
"AllowedHosts": "*",
"jwtKey": "sagdsadgfeSDF674545REFG$%FEfgdslkjfglkjhfgdkljhdR5454545_4TGRGtyo!!kjytkljty"
}
builder.Services.AddScoped<IUserHelper, UserHelper>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(x => x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["jwtKey"]!)),
ClockSkew = TimeSpan.Zero
});
using Sales.Shared.Entities;
using System.ComponentModel.DataAnnotations;
using System.Xml.Linq;
82
namespace Sales.Shared.DTOs
{
public class UserDTO : User
{
[DataType(DataType.Password)]
[Display(Name = "Contraseña")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
[StringLength(20, MinimumLength = 6, ErrorMessage = "El campo {0} debe tener entre {2} y {1} carácteres.")]
public string Password { get; set; } = null!;
using Sales.Shared.Entities;
namespace Sales.Shared.DTOs
{
public class TokenDTO
{
public string Token { get; set; } = null!;
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.DTOs
{
public class LoginDTO
{
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
[EmailAddress(ErrorMessage = "Debes ingresar un correo válido.")]
public string Email { get; set; } = null!;
[Display(Name = "Contraseña")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
[MinLength(6, ErrorMessage = "El campo {0} debe tener al menos {1} carácteres.")]
public string Password { get; set; } = null!;
}
}
Task LogoutAsync();
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Sales.API.Helpers;
using Sales.Shared.DTOs;
using Sales.Shared.Entities;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace Sales.API.Controllers
{
[ApiController]
[Route("/api/accounts")]
public class AccountsController : ControllerBase
{
private readonly IUserHelper _userHelper;
private readonly IConfiguration _configuration;
[HttpPost("CreateUser")]
public async Task<ActionResult> CreateUser([FromBody] UserDTO model)
{
User user = model;
var result = await _userHelper.AddUserAsync(user, model.Password);
if (result.Succeeded)
{
await _userHelper.AddUserToRoleAsync(user, user.UserType.ToString());
return Ok(BuildToken(user));
}
return BadRequest(result.Errors.FirstOrDefault());
}
[HttpPost("Login")]
public async Task<ActionResult> Login([FromBody] LoginDTO model)
{
var result = await _userHelper.LoginAsync(model);
if (result.Succeeded)
{
var user = await _userHelper.GetUserAsync(model.Email);
return Ok(BuildToken(user));
}
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
try
{
var responseHppt = await repository.Get<List<Country>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Countries = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
36. Podemos probar por POSTMAN como está funcionando nuestro token, y con https://jwt.io/ probamos como está
quedando nuestro token.
37. Probamos en la interfaz web, y nos debe salir un error porque aun no le mandamos ningún token a nuestra API.
Hacemos el commit.
using Microsoft.JSInterop;
namespace Sales.WEB.Helpers
{
public static class IJSRuntimeExtensionMethods
{
public static ValueTask<object> SetLocalStorage(this IJSRuntime js, string key, string content)
{
return js.InvokeAsync<object>("localStorage.setItem", key, content);
}
namespace Sales.WEB.Auth
{
public interface ILoginService
{
Task LoginAsync(string token);
Task LogoutAsync();
}
}
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.JSInterop;
using Sales.WEB.Helpers;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
namespace Sales.WEB.Auth
{
public class AuthenticationProviderJWT : AuthenticationStateProvider, ILoginService
{
private readonly IJSRuntime _jSRuntime;
private readonly HttpClient _httpClient;
private readonly String _tokenKey;
private readonly AuthenticationState _anonimous;
return BuildAuthenticationState(token.ToString()!);
}
5. Modificamos el Program del WEB para usar nuestro nuevo proveedor de autenticación:
builder.Services.AddScoped<AuthenticationProviderJWT>();
builder.Services.AddScoped<AuthenticationStateProvider, AuthenticationProviderJWT>(x =>
x.GetRequiredService<AuthenticationProviderJWT>());
builder.Services.AddScoped<ILoginService, AuthenticationProviderJWT>(x =>
x.GetRequiredService<AuthenticationProviderJWT>());
<AuthorizeView>
<Authorized>
<span>Hola, @context.User.Identity!.Name</span>
88
<a href="Logout" class="nav-link btn btn-link">Cerrar Sesión</a>
</Authorized>
<NotAuthorized>
<a href="Register" class="nav-link btn btn-link">Registro</a>
<a href="Login" class="nav-link btn btn-link">Iniciar Sesión</a>
</NotAuthorized>
</AuthorizeView>
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<AuthLinks/>
<a href="https://docs.microsoft.com/aspnet/" target="_blank">Acerca de</a>
</div>
@page "/Register"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@inject ILoginService loginService
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombres:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.FirstName" />
<ValidationMessage For="@(() => userDTO.FirstName)" />
</div>
</div>
<div class="mb-3">
89
<label>Apellidos:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.LastName" />
<ValidationMessage For="@(() => userDTO.LastName)" />
</div>
</div>
<div class="mb-3">
<label>Documento:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Document" />
<ValidationMessage For="@(() => userDTO.Document)" />
</div>
</div>
<div class="mb-3">
<label>Teléfono:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.PhoneNumber" />
<ValidationMessage For="@(() => userDTO.PhoneNumber)" />
</div>
</div>
<div class="mb-3">
<label>Dirección:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Address" />
<ValidationMessage For="@(() => userDTO.Address)" />
</div>
</div>
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Email" />
<ValidationMessage For="@(() => userDTO.Email)" />
</div>
</div>
</div>
<div class="col-6">
<div class="mb-3">
<label>Ciudad:</label>
<div>
<InputNumber class="form-control" @bind-Value="@userDTO.CityId" />
<ValidationMessage For="@(() => userDTO.CityId)" />
</div>
</div>
<div class="mb-3">
<label>Foto:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Photo" />
<ValidationMessage For="@(() => userDTO.Photo)" />
</div>
</div>
<div class="mb-3">
<label>Contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@userDTO.Password" />
90
<ValidationMessage For="@(() => userDTO.Password)" />
</div>
</div>
<div class="mb-3">
<label>Confirmación de contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@userDTO.PasswordConfirm" />
<ValidationMessage For="@(() => userDTO.PasswordConfirm)" />
</div>
</div>
</div>
</div>
<button class="btn btn-primary" type="submit">Registrar</button>
</EditForm>
@code {
private UserDTO userDTO = new();
await loginService.LoginAsync(responseHttp.Response!.Token);
navigationManager.NavigateTo("/");
}
}
@page "/Login"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@inject ILoginService loginService
<h3>Iniciar Sesión</h3>
<div class="row">
<div class="col-4">
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@loginDTO.Email" />
91
<ValidationMessage For="@(() => loginDTO.Email)" />
</div>
</div>
<div class="mb-3">
<label>Contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@loginDTO.Password" />
<ValidationMessage For="@(() => loginDTO.Password)" />
</div>
</div>
<button class="btn btn-primary" type="submit">Iniciar Sesión</button>
</div>
</div>
</EditForm>
@code {
private LoginDTO loginDTO = new();
await loginService.LoginAsync(responseHttp.Response!.Token);
navigationManager.NavigateTo("/");
}
}
@page "/logout"
@inject ILoginService loginService
@inject NavigationManager navigationManager
<p>Cerrando sesión...</p>
@code {
protected override async Task OnInitializedAsync()
{
await loginService.LogoutAsync();
navigationManager.NavigateTo("/");
}
}
92
Habilitando tokens en swagger
Este titulo se lo debemos a José Rendon que me explico como se hacia, gracias Jose!
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sales API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = @"JWT Authorization header using the Bearer scheme. <br /> <br />
Enter 'Bearer' [space] and then your token in the text input below.<br /> <br />
Example: 'Bearer 12345abcdef'<br /> <br />",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
}
});
});
[AllowAnonymous]
[HttpGet("combo")]
public async Task<ActionResult> GetCombo()
{
return Ok(await _context.Countries.ToListAsync());
}
93
2. Creamos el método GetCombo en el StatesController:
[AllowAnonymous]
[HttpGet("combo/{countryId:int}")]
public async Task<ActionResult> GetCombo(int countryId)
{
return Ok(await _context.States
.Where(x => x.CountryId == countryId)
.ToListAsync());
}
[AllowAnonymous]
[HttpGet("combo/{stateId:int}")]
public async Task<ActionResult> GetCombo(int stateId)
{
return Ok(await _context.Cities
.Where(x => x.StateId == stateId)
.ToListAsync());
}
4. Modificamos el Register.razor:
…
<div class="col-6">
<div class="mb-3">
<label>País:</label>
<div>
<select class="form-select" @onchange="CountryChangedAsync">
<option value="0">-- Seleccione un país --</option>
@if (countries is not null)
{
@foreach (var country in countries)
{
<option value="@country.Id">@country.Name</option>
}
}
</select>
</div>
</div>
<div class="mb-3">
<label>Estado/Departamento:</label>
<div>
<select class="form-select" @onchange="StateChangedAsync">
<option value="0">-- Seleccione un estado/departamento --</option>
@if (states is not null)
{
@foreach (var state in states)
{
<option value="@state.Id">@state.Name</option>
}
}
</select>
94
</div>
</div>
<div class="mb-3">
<label>Ciudad:</label>
<div>
<select class="form-select" @bind="userDTO.CityId">
<option value="0">-- Seleccione una ciudad --</option>
@if (cities is not null)
{
@foreach (var city in cities)
{
<option value="@city.Id">@city.Name</option>
}
}
</select>
<ValidationMessage For="@(() => userDTO.CityId)" />
</div>
</div>
<div class="mb-3">
<label>Foto:</label>
…
@code {
private UserDTO userDTO = new();
private List<Country>? countries;
private List<State>? states;
private List<City>? cities;
95
countries = responseHttp.Response;
}
states = responseHttp.Response;
}
cities = responseHttp.Response;
}
.spinner {
border: 16px solid silver;
border-top: 16px solid #337AB7;
border-radius: 50%;
width: 80px;
height: 80px;
animation: spin 700ms linear infinite;
top: 40%;
left: 55%;
position: absolute;
}
@keyframes spin {
96
0% {
transform: rotate(0deg)
}
100% {
transform: rotate(360deg)
}
}
…
@if (Countries is null)
{
<div class="spinner"/>
}
else
{
<GenericList MyList="Countries">
<RecordsComplete>
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-globe"></i> Países
<a class="btn btn-sm btn-primary float-end" href="/countries/create"><i class="oi oi-plus"></i> Adicionar
País</a>
</span>
</div>
<div class="card-body">
<div class="mb-2" style="display: flex; flex-wrap:wrap; align-items: center;">
<div>
<input style="width: 400px;" type="text" class="form-control" id="titulo" placeholder="Buscar país..."
@bind-value="Filter" />
</div>
<div class="mx-1">
<button type="button" class="btn btn-outline-primary" @onclick="ApplyFilterAsync"><i class="oi oi-
layers" /> Filtrar</button>
<button type="button" class="btn btn-outline-danger" @onclick="CleanFilterAsync"><i class="oi oi-
ban" /> Limpiar</button>
</div>
</div>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
8. Replica el cambio para el resto de la solución. Si quieres una lista de íconos que puedes usar te dejo este link:
https://kordamp.org/ikonli/cheat-sheet-openiconic.html
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-person" /> Registrar Nuevo Usuario
<button class="btn btn-sm btn-primary float-end" type="submit"><i class="oi oi-check" /> Registrar</button>
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombres:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.FirstName" />
<ValidationMessage For="@(() => userDTO.FirstName)" />
</div>
</div>
98
<div class="mb-3">
<label>Apellidos:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.LastName" />
<ValidationMessage For="@(() => userDTO.LastName)" />
</div>
</div>
<div class="mb-3">
<label>Documento:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Document" />
<ValidationMessage For="@(() => userDTO.Document)" />
</div>
</div>
<div class="mb-3">
<label>Teléfono:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.PhoneNumber" />
<ValidationMessage For="@(() => userDTO.PhoneNumber)" />
</div>
</div>
<div class="mb-3">
<label>Dirección:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Address" />
<ValidationMessage For="@(() => userDTO.Address)" />
</div>
</div>
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Email" />
<ValidationMessage For="@(() => userDTO.Email)" />
</div>
</div>
</div>
<div class="col-6">
<div class="mb-3">
<label>País:</label>
<div>
<select class="form-select" @onchange="CountryChangedAsync">
<option value="0">-- Seleccione un país --</option>
@if (countries is not null)
{
@foreach (var country in countries)
{
<option value="@country.Id">@country.Name</option>
}
}
</select>
</div>
</div>
<div class="mb-3">
<label>Estado/Departamento:</label>
99
<div>
<select class="form-select" @onchange="StateChangedAsync">
<option value="0">-- Seleccione un estado/departamento --</option>
@if (states is not null)
{
@foreach (var state in states)
{
<option value="@state.Id">@state.Name</option>
}
}
</select>
</div>
</div>
<div class="mb-3">
<label>Ciudad:</label>
<div>
<select class="form-select" @bind="userDTO.CityId">
<option value="0">-- Seleccione una ciudad --</option>
@if (cities is not null)
{
@foreach (var city in cities)
{
<option value="@city.Id">@city.Name</option>
}
}
</select>
<ValidationMessage For="@(() => userDTO.CityId)" />
</div>
</div>
<div class="mb-3">
<label>Foto:</label>
<div>
<InputText class="form-control" @bind-Value="@userDTO.Photo" />
<ValidationMessage For="@(() => userDTO.Photo)" />
</div>
</div>
<div class="mb-3">
<label>Contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@userDTO.Password" />
<ValidationMessage For="@(() => userDTO.Password)" />
</div>
</div>
<div class="mb-3">
<label>Confirmación de contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@userDTO.PasswordConfirm" />
<ValidationMessage For="@(() => userDTO.PasswordConfirm)" />
</div>
</div>
</div>
</div>
</div>
</div>
100
</EditForm>
@page "/Login"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@inject ILoginService loginService
<div class="row">
<div class="col-md-4 offset-md-4">
<EditForm Model="loginDTO" OnValidSubmit="LoginAsync">
<DataAnnotationsValidator />
11. También cambiemos todos los <p>Cargando…</p> por <div class="spinner" />.
<div>
<label>@Label</label>
101
<div>
<InputFile OnChange="OnChange" accept=".jpg,.jpeg,.png" />
</div>
</div>
<div>
@if (imageBase64 is not null)
{
<div>
<div style="margin: 10px">
<img src="data:image/jpeg;base64, @imageBase64" style="width:400px" />
</div>
</div>
}
@code {
[Parameter] public string Label { get; set; } = "Imagen";
[Parameter] public string? ImageURL { get; set; }
[Parameter] public EventCallback<string> ImageSelected { get; set; }
private string? imageBase64;
…
<div class="mb-3">
<label>Confirmación de contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@userDTO.PasswordConfirm" />
102
<ValidationMessage For="@(() => userDTO.PasswordConfirm)" />
</div>
</div>
<div class="mb-3">
<InputImg Label="Foto" ImageSelected="ImageSelected" ImageURL="@imageUrl" />
</div>
</div>
</div>
</div>
</div>
</EditForm>
@code {
private UserDTO userDTO = new();
private List<Country>? countries;
private List<State>? states;
private List<City>? cities;
private bool loading;
private string? imageUrl;
if (!string.IsNullOrEmpty(userDTO.Photo))
{
imageUrl = userDTO.Photo;
userDTO.Photo = null;
}
}
103
5. Y luego creamos los contenedores para users y products:
6. Luego que termine copiamos el connection string que necesitamos para acceder a nuestro blob storage, para mi
ejemplo es:
DefaultEndpointsProtocol=https;AccountName=sales2023;AccountKey=qC+EUq97TPPgIh8Syt18jgnl4swmJNiaS4fZEW
VUwHlzr21H0wVstqJ8t+8t8VHdL3ZvarbAOBiq+AStRAgUtA==;EndpointSuffix=core.windows.net
"ConnectionStrings": {
"DockerConnection": "Data Source=.;Initial Catalog=Sales;User ID=sa;Password=Roger1974.;Connect
Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False",
104
"LocalConnection": "Server=(localdb)\\
MSSQLLocalDB;Database=Sales2023;Trusted_Connection=True;MultipleActiveResultSets=true",
"AzureStorage":
"DefaultEndpointsProtocol=https;AccountName=sales2023;AccountKey=qC+EUq97TPPgIh8Syt18jgnl4swmJNiaS4fZEW
VUwHlzr21H0wVstqJ8t+8t8VHdL3ZvarbAOBiq+AStRAgUtA==;EndpointSuffix=core.windows.net"
},
namespace Sales.API.Helpers
{
public interface IFileStorage
{
Task<string> SaveFileAsync(byte[] content, string extention, string containerName);
async Task<string> EditFileAsync(byte[] content, string extention, string containerName, string path)
{
if (path is not null)
{
await RemoveFileAsync(path, containerName);
}
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Sales.API.Helpers
{
public class FileStorage : IFileStorage
{
private readonly string connectionString;
public FileStorage(IConfiguration configuration)
{
connectionString = configuration.GetConnectionString("AzureStorage")!;
}
return blob.Uri.ToString();
}
}
}
builder.Services.AddScoped<IFileStorage, FileStorage>();
[ApiController]
[Route("/api/accounts")]
public class AccountsController : ControllerBase
{
private readonly IUserHelper _userHelper;
private readonly IConfiguration _configuration;
private readonly IFileStorage _fileStorage;
private readonly string _container;
[HttpPost("CreateUser")]
public async Task<ActionResult> CreateUser([FromBody] UserDTO model)
{
User user = model;
if(!string.IsNullOrEmpty(model.Photo))
{
var photoUser = Convert.FromBase64String(model.Photo);
model.Photo = await _fileStorage.SaveFileAsync(photoUser, ".jpg", _container);
}
return BadRequest(result.Errors.FirstOrDefault());
}
<AuthorizeView>
<Authorized>
<span>Hola, @context.User.Identity!.Name</span>
@if (!string.IsNullOrEmpty(photoUser))
{
<div class="mx-2">
<img src="@photoUser" width="50" height="50" style="border-radius:50%" />
</div>
}
<a href="Logout" class="nav-link btn btn-link">Cerrar Sesión</a>
</Authorized>
<NotAuthorized>
<a href="Register" class="nav-link btn btn-link">Registro</a>
<a href="Login" class="nav-link btn btn-link">Iniciar Sesión</a>
</NotAuthorized>
</AuthorizeView>
@code {
private string? photoUser;
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; } = null!;
Editando el usuario
14. A la interfaz IUserHelper le adicionamos los siguientes métodos:
[HttpPut]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> Put(User user)
{
try
{
if (!string.IsNullOrEmpty(user.Photo))
{
var photoUser = Convert.FromBase64String(user.Photo);
user.Photo = await _fileStorage.SaveFileAsync(photoUser, ".jpg", _container);
}
currentUser.Document = user.Document;
108
currentUser.FirstName = user.FirstName;
currentUser.LastName = user.LastName;
currentUser.Address = user.Address;
currentUser.PhoneNumber = user.PhoneNumber;
currentUser.Photo = !string.IsNullOrEmpty(user.Photo) && user.Photo != currentUser.Photo ? user.Photo :
currentUser.Photo;
currentUser.CityId = user.CityId;
return BadRequest(result.Errors.FirstOrDefault());
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> Get()
{
return Ok(await _userHelper.GetUserAsync(User.Identity!.Name!));
}
<Authorized>
Hola, <a href="EditUser" class="nav-link btn btn-link">@context.User.Identity!.Name</a>
@if (!string.IsNullOrEmpty(photoUser))
{
<div class="mx-2">
<img src="@photoUser" width="50" height="50" style="border-radius:50%" />
</div>
}
<a href="Logout" class="nav-link btn btn-link">Cerrar Sesión</a>
</Authorized>
@page "/EditUser"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@inject ILoginService loginService
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-person" /> Editar Usuario
<a class="btn btn-sm btn-secondary float-end" href="/changePassword"><i class="oi oi-key" /> Cambiar
Contraseña</a>
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-check" /> Guardar
Cambios</button>
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombres:</label>
<div>
<InputText class="form-control" @bind-Value="@user.FirstName" />
<ValidationMessage For="@(() => user.FirstName)" />
</div>
</div>
<div class="mb-3">
<label>Apellidos:</label>
<div>
<InputText class="form-control" @bind-Value="@user.LastName" />
<ValidationMessage For="@(() => user.LastName)" />
</div>
</div>
<div class="mb-3">
<label>Documento:</label>
<div>
<InputText class="form-control" @bind-Value="@user.Document" />
<ValidationMessage For="@(() => user.Document)" />
</div>
</div>
<div class="mb-3">
<label>Teléfono:</label>
<div>
<InputText class="form-control" @bind-Value="@user.PhoneNumber" />
<ValidationMessage For="@(() => user.PhoneNumber)" />
</div>
</div>
<div class="mb-3">
<label>Dirección:</label>
<div>
<InputText class="form-control" @bind-Value="@user.Address" />
<ValidationMessage For="@(() => user.Address)" />
</div>
</div>
</div>
110
<div class="col-6">
<div class="mb-3">
<label>País:</label>
<div>
<select class="form-select" @onchange="CountryChangedAsync">
<option value="0">-- Seleccione un país --</option>
@if (countries is not null)
{
@foreach (var country in countries)
{
<option value="@country.Id" selected="@(country.Id ==
user.City!.State!.Country!.Id)">@country.Name</option>
}
}
</select>
</div>
</div>
<div class="mb-3">
<label>Estado/Departamento:</label>
<div>
<select class="form-select" @onchange="StateChangedAsync">
<option value="0">-- Seleccione un estado/departamento --</option>
@if (states is not null)
{
@foreach (var state in states)
{
<option value="@state.Id" selected="@(state.Id ==
user.City!.State!.Id)">@state.Name</option>
}
}
</select>
</div>
</div>
<div class="mb-3">
<label>Ciudad:</label>
<div>
<select class="form-select" @bind="user.CityId">
<option value="0">-- Seleccione una ciudad --</option>
@if (cities is not null)
{
@foreach (var city in cities)
{
<option value="@city.Id" selected="@(city.Id == user.City!.Id)">@city.Name</option>
}
}
</select>
<ValidationMessage For="@(() => user.CityId)" />
</div>
</div>
<div class="mb-3">
<InputImg Label="Foto" ImageSelected="ImageSelected" ImageURL="@imageUrl" />
</div>
</div>
</div>
111
</div>
</div>
</EditForm>
}
@code {
private User? user;
private List<Country>? countries;
private List<State>? states;
private List<City>? cities;
private string? imageUrl;
if (!string.IsNullOrEmpty(user!.Photo))
{
imageUrl = user.Photo;
user.Photo = null;
}
countries = responseHttp.Response;
}
states = responseHttp.Response;
}
cities = responseHttp.Response;
}
navigationManager.NavigateTo("/");
}
}
19. Probamos.
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.DTOs
{
public class ChangePasswordDTO
{
[DataType(DataType.Password)]
[Display(Name = "Contraseña actual")]
[StringLength(20, MinimumLength = 6, ErrorMessage = "El campo {0} debe tener entre {2} y {1} carácteres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string CurrentPassword { get; set; } = null!;
[DataType(DataType.Password)]
[Display(Name = "Nueva contraseña")]
[StringLength(20, MinimumLength = 6, ErrorMessage = "El campo {0} debe tener entre {2} y {1} carácteres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string NewPassword { get; set; } = null!;
[HttpPost("changePassword")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> ChangePasswordAsync(ChangePasswordDTO model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
return NoContent();
}
@page "/changePassword"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@if (loading)
{
<div class="spinner" />
}
<div class="row">
<div class="col-6">
<EditForm Model="changePasswordDTO" OnValidSubmit="ChangePasswordAsync">
<DataAnnotationsValidator />
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-key" /> Cambiar Contraseña
<a class="btn btn-sm btn-success float-end" href="/editUser"><i class="oi oi-arrow-thick-left" />
Regresar</a>
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-check" /> Guardar
Cambios</button>
</span>
</div>
<div class="card-body">
<div class="mb-3">
<label>Contraseña actual:</label>
<div>
<InputText type="password" class="form-control" @bind-
Value="@changePasswordDTO.CurrentPassword" />
<ValidationMessage For="@(() => changePasswordDTO.CurrentPassword)" />
</div>
</div>
<div class="mb-3">
<label>Nueva contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-
Value="@changePasswordDTO.NewPassword" />
<ValidationMessage For="@(() => changePasswordDTO.CurrentPassword)" />
</div>
</div>
115
<div class="mb-3">
<label>Confirmación de nueva contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@changePasswordDTO.Confirm" />
<ValidationMessage For="@(() => changePasswordDTO.Confirm)" />
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
@code {
private ChangePasswordDTO changePasswordDTO = new();
private bool loading;
loading = false;
navigationManager.NavigateTo("/editUser");
var toast = sweetAlertService.Mixin(new SweetAlertOptions
{
Toast = true,
Position = SweetAlertPosition.TopEnd,
ShowConfirmButton = true,
Timer = 5000
});
await toast.FireAsync(icon: SweetAlertIcon.Success, message: "Contraseña cambiada con éxito.");
}
}
2. Verificamos que la cuenta de Gmail con la que vamos a mandar los correos tenga lo siguiente:
"Mail": {
"From": "[email protected]",
"Name": "Soporte Sales",
"Smtp": "smtp.gmail.com",
"Port": 587,
"Password": "nniufszzppfuzhxe"
},
"UrlWEB": "localhost:7175"
Nota: reemplazar el 7175 por el puerto donde sale tu App WEB, y reemplazar el password por el generado de tu
cuenta.
using MailKit.Net.Smtp;
117
using MimeKit;
using Sales.Shared.Responses;
namespace Sales.API.Helpers
{
public class MailHelper : IMailHelper
{
private readonly IConfiguration _configuration;
public Response SendMail(string toName, string toEmail, string subject, string body)
{
try
{
var from = _configuration["Mail:From"];
var name = _configuration["Mail:Name"];
var smtp = _configuration["Mail:Smtp"];
var port = _configuration["Mail:Port"];
var password = _configuration["Mail:Password"];
}
catch (Exception ex)
{
return new Response
{
IsSuccess = false,
Message = ex.Message,
Result = ex
};
}
118
}
}
}
builder.Services.AddScoped<IMailHelper, MailHelper>();
Y la implementación:
[HttpPost("CreateUser")]
public async Task<ActionResult> CreateUser([FromBody] UserDTO model)
{
User user = model;
if (!string.IsNullOrEmpty(model.Photo))
{
var photoUser = Convert.FromBase64String(model.Photo);
model.Photo = await _fileStorage.SaveFileAsync(photoUser, ".jpg", _container);
}
if (response.IsSuccess)
{
return NoContent();
}
return BadRequest(response.Message);
}
return BadRequest(result.Errors.FirstOrDefault());
}
[HttpGet("ConfirmEmail")]
public async Task<ActionResult> ConfirmEmailAsync(string userId, string token)
{
token = token.Replace(" ", "+");
var user = await _userHelper.GetUserAsync(new Guid(userId));
if (user == null)
{
return NotFound();
}
return NoContent();
}
[HttpPost("Login")]
public async Task<ActionResult> Login([FromBody] LoginDTO model)
{
var result = await _userHelper.LoginAsync(model);
if (result.Succeeded)
{
var user = await _userHelper.GetUserAsync(model.Email);
return Ok(BuildToken(user));
}
if (result.IsLockedOut)
{
return BadRequest("Ha superado el máximo número de intentos, su cuenta está bloqueada, intente de nuevo en 5
minutos.");
}
if (result.IsNotAllowed)
120
{
return BadRequest("El usuario no ha sido habilitado, debes de seguir las instrucciones del correo enviado para
poder habilitar el usuario.");
}
@page "/api/accounts/ConfirmEmail"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
<h3>Confirmación de email</h3>
@code {
private string? message;
[Parameter]
[SupplyParameterFromQuery]
public string UserId { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Token { get; set; } = "";
private async Task<User> CheckUserAsync(string document, string firstName, string lastName, string email, string
phone, string address, UserType userType)
{
var user = await _userHelper.GetUserAsync(email);
if (user == null)
{
var city = await _context.Cities.FirstOrDefaultAsync(x => x.Name == "Medellín");
if (city == null)
{
city = await _context.Cities.FirstOrDefaultAsync();
}
return user;
}
loading = false;
await sweetAlertService.FireAsync("Confirmación", "Su cuenta ha sido creada con éxito. Se te ha enviado un correo
electrónico con las instrucciones para activar tu usuario.", SweetAlertIcon.Info);
navigationManager.NavigateTo("/");
}
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.DTOs
{
public class EmailDTO
{
[Display(Name = "Email")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
[EmailAddress(ErrorMessage = "Debes ingresar un correo válido.")]
public string Email { get; set; } = null!;
}
}
[HttpPost("ResedToken")]
public async Task<ActionResult> ResedToken([FromBody] EmailDTO model)
{
User user = await _userHelper.GetUserAsync(model.Email);
if (user == null)
{
return NotFound();
}
if (response.IsSuccess)
{
return NoContent();
}
return BadRequest(response.Message);
}
<div class="row">
<div class="col-md-4 offset-md-4">
<EditForm Model="loginDTO" OnValidSubmit="LoginAsync">
<DataAnnotationsValidator />
@page "/ResendToken"
124
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@if (loading)
{
<div class="spinner" />
}
<div class="row">
<div class="col-6">
<EditForm Model="emailDTO" OnValidSubmit="ResendConfirmationEmailTokenAsync">
<DataAnnotationsValidator />
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-key" /> Reenviar correo de confirmación de contraseña
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-loop-square" />
Reenviar</button>
</span>
</div>
<div class="card-body">
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@emailDTO.Email" />
<ValidationMessage For="@(() => emailDTO.Email)" />
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
@code {
private EmailDTO emailDTO = new();
private bool loading;
loading = false;
await sweetAlertService.FireAsync("Confirmación", "Se te ha enviado un correo electrónico con las instrucciones
para activar tu usuario.", SweetAlertIcon.Info);
navigationManager.NavigateTo("/");
125
}
}
3. Y su implementación en el Repository:
4. Modificamos el EditUser:
await loginService.LoginAsync(responseHttp.Response!.Token);
navigationManager.NavigateTo("/");
}
126
Recuperación de contraseña
1. Modificamos el Login.razor:
<div class="card-footer">
<p><a class="bbtn btn-link" href="/ResendToken">Reenviar correro de activación de cuenta</a></p>
<p><a class="bbtn btn-link" href="/RecoverPassword">¿Has olvidado tu contraseña?</a></p>
</div>
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.DTOs
{
public class ResetPasswordDTO
{
[Display(Name = "Email")]
[EmailAddress(ErrorMessage = "Debes ingresar un correo válido.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Email { get; set; } = null!;
[DataType(DataType.Password)]
[Display(Name = "Contraseña")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
[StringLength(20, MinimumLength = 6, ErrorMessage = "El campo {0} debe tener entre {2} y {1} carácteres.")]
public string Password { get; set; } = null!;
Y la implementación:
[HttpPost("RecoverPassword")]
public async Task<ActionResult> RecoverPassword([FromBody] EmailDTO model)
{
User user = await _userHelper.GetUserAsync(model.Email);
if (user == null)
{
return NotFound();
}
if (response.IsSuccess)
{
return NoContent();
}
return BadRequest(response.Message);
}
[HttpPost("ResetPassword")]
public async Task<ActionResult> ResetPassword([FromBody] ResetPasswordDTO model)
{
User user = await _userHelper.GetUserAsync(model.Email);
if (user == null)
{
return NotFound();
}
return BadRequest(result.Errors.FirstOrDefault()!.Description);
}
@if (loading)
{
<div class="spinner" />
}
<div class="row">
<div class="col-6">
<EditForm Model="emailDTO" OnValidSubmit="SendRecoverPasswordEmailTokenAsync">
<DataAnnotationsValidator />
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-key" /> Enviar email para recuperación de contraseña
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-loop-square" />
Enviar</button>
</span>
</div>
<div class="card-body">
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@emailDTO.Email" />
<ValidationMessage For="@(() => emailDTO.Email)" />
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
@code {
private EmailDTO emailDTO = new();
private bool loading;
loading = false;
129
await sweetAlertService.FireAsync("Confirmación", "Se te ha enviado un correo electrónico con las instrucciones
para recuperar su contraseña.", SweetAlertIcon.Info);
navigationManager.NavigateTo("/");
}
}
@page "/api/accounts/ResetPassword"
@inject IRepository repository
@inject SweetAlertService sweetAlertService
@inject NavigationManager navigationManager
@if (loading)
{
<div class="spinner" />
}
<div class="row">
<div class="col-6">
<EditForm Model="resetPasswordDTO" OnValidSubmit="ChangePasswordAsync">
<DataAnnotationsValidator />
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-key" /> Cambiar Contraseña
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-check" /> Cambiar
Contrasña</button>
</span>
</div>
<div class="card-body">
<div class="mb-3">
<label>Email:</label>
<div>
<InputText class="form-control" @bind-Value="@resetPasswordDTO.Email" />
<ValidationMessage For="@(() => resetPasswordDTO.Email)" />
</div>
</div>
<div class="mb-3">
<label>Nueva contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-Value="@resetPasswordDTO.Password" />
<ValidationMessage For="@(() => resetPasswordDTO.Password)" />
</div>
</div>
<div class="mb-3">
<label>Confirmar contraseña:</label>
<div>
<InputText type="password" class="form-control" @bind-
Value="@resetPasswordDTO.ConfirmPassword" />
<ValidationMessage For="@(() => resetPasswordDTO.ConfirmPassword)" />
</div>
</div>
</div>
</div>
130
</EditForm>
</div>
</div>
@code {
private ResetPasswordDTO resetPasswordDTO = new();
private bool loading;
[Parameter]
[SupplyParameterFromQuery]
public string Token { get; set; } = "";
loading = false;
await sweetAlertService.FireAsync("Confirmación", "Contraseña cambiada con éxito, ahora puede ingresar con su
nueva contraseña.", SweetAlertIcon.Info);
navigationManager.NavigateTo("/Login");
}
}
<nav>
<ul class="pagination">
@foreach (var link in Links)
{
<li @onclick=@(() => InternalSelectedPage(link)) style="cursor: pointer" class="page-item @(link.Enable ? null :
"disabled") @(link.Enable ? "active" : null)">
<a class="page-link">@link.Text</a>
</li>
}
</ul>
</nav>
@code {
[Parameter] public int CurrentPage { get; set; } = 1;
[Parameter] public int TotalPages { get; set; }
[Parameter] public int Radio { get; set; } = 10;
131
[Parameter] public EventCallback<int> SelectedPage { get; set; }
List<PageModel> Links = new();
await SelectedPage.InvokeAsync(pageModel.Page);
}
Links.Add(new PageModel
{
Text = "Anterior",
Page = previousLinkPage,
Enable = previousLinkEnable
});
if (TotalPages > Radio && i <= Radio && CurrentPage <= Radio)
{
Links.Add(new PageModel
{
Page = i,
Enable = CurrentPage == i,
Text = $"{i}"
});
}
if (CurrentPage > Radio && i > CurrentPage - Radio && i <= CurrentPage)
{
Links.Add(new PageModel
{
Page = i,
Enable = CurrentPage == i,
132
Text = $"{i}"
});
}
}
class PageModel
{
public string Text { get; set; } = null!;
public int Page { get; set; }
public bool Enable { get; set; } = true;
public bool Active { get; set; } = false;
}
}
CRUD de Categorías
1. En Sales.Shared.Entities adicionamos la entidad Category:
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class Category
{
public int Id { get; set; }
[Display(Name = "Categoría")]
[MaxLength(100, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
}
}
3. Modificamos el DataContext:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.API.Helpers;
using Sales.Shared.DTOs;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Route("/api/categories")]
public class CategoiresController : ControllerBase
{
private readonly DataContext _context;
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Categories
.AsQueryable();
134
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Categories
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("{id:int}")]
public async Task<ActionResult> Get(int id)
{
var category = await _context.Categories
.FirstOrDefaultAsync(x => x.Id == id);
if (category is null)
{
return NotFound();
}
return Ok(category);
}
[HttpPost]
public async Task<ActionResult> Post(Category category)
{
_context.Add(category);
try
{
await _context.SaveChangesAsync();
return Ok(category);
}
catch (DbUpdateException dbUpdateException)
{
135
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un registro con el mismo nombre.");
}
else
{
return BadRequest(dbUpdateException.InnerException.Message);
}
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut]
public async Task<ActionResult> Put(Category category)
{
_context.Update(category);
try
{
await _context.SaveChangesAsync();
return Ok(category);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un registro con el mismo nombre.");
}
else
{
return BadRequest(dbUpdateException.InnerException.Message);
}
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var category = await _context.Categories.FirstOrDefaultAsync(x => x.Id == id);
if (category == null)
{
return NotFound();
}
_context.Remove(category);
await _context.SaveChangesAsync();
return NoContent();
}
136
}
}
6. Modificamos el SeedDb:
@page "/categories"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPageAsync" />
@code {
public List<Category>? categories { get; set; }
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/categories?page={page}";
url2 = $"api/categories/totalPages";
}
else
{
url1 = $"api/categories?page={page}&filter={Filter}";
url2 = $"api/categories/totalPages?filter={Filter}";
}
try
{
var responseHppt = await repository.Get<List<Category>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
categories = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
139
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
if (confirm)
{
return;
}
if (responseHTTP.Error)
{
if (responseHTTP.HttpResponseMessage.StatusCode == System.Net.HttpStatusCode.NotFound)
{
navigationManager.NavigateTo("/");
}
else
{
var mensajeError = await responseHTTP.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", mensajeError, SweetAlertIcon.Error);
}
}
else
{
await LoadAsync();
}
}
8. Modificamos el NavMenu.razor:
<AuthorizeView Roles="Admin">
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="categories">
<span class="oi oi-list" aria-hidden="true"></span> Categorías
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="countries">
<span class="oi oi-globe" aria-hidden="true"></span> Países
</NavLink>
</div>
</Authorized>
</AuthorizeView>
@code {
private EditContext editContext = null!;
[Parameter]
[EditorRequired]
public Category Category { get; set; } = null!;
[Parameter]
[EditorRequired]
public EventCallback OnValidSubmit { get; set; }
[Parameter]
[EditorRequired]
141
public EventCallback ReturnAction { get; set; }
context.PreventNavigation();
}
}
@page "/categories/create"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Crear categoría</h3>
@code {
private Category category = new();
private CategoryForm? categoryForm;
[Parameter]
public int StateId { get; set; }
142
private async Task CreateAsync()
{
var httpResponse = await repository.Post("/api/categories", category);
if (httpResponse.Error)
{
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
Return();
}
@page "/categories/edit/{CategoryId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<h3>Editar categoría</h3>
@code {
private Category? category;
private CategoryForm? categoryForm;
[Parameter]
public int CategoryId { get; set; }
category = responseHttp.Response;
}
Return();
}
builder.Services.AddBlazoredModal();
3. Modificamos el _Imports.razor:
@using Blazored.Modal
@using Blazored.Modal.Services
4. Modificamos el App.razor:
…
<a class="btn btn-sm btn-primary float-end" @onclick=@(() => ShowModal())><i class="oi oi-plus"></i> Adicionar
Categoría</a>
…
<a @onclick=@(() => ShowModal(category.Id, true)) class="btn btn-warning"><i class="oi oi-pencil" /> Editar</a>
…
[CascadingParameter]
IModalService Modal { get; set; } = default!;
…
private async Task ShowModal(int id = 0, bool isEdit = false)
{
IModalReference modalReference;
if (isEdit)
{
modalReference = Modal.Show<CategoryEdit>(string.Empty, new ModalParameters().Add("CategoryId", id));
}
else
{
modalReference = Modal.Show<CategoryCreate>();
}
145
6. Modificamos el CategoriesEdit:
…
[CascadingParameter]
BlazoredModalInstance BlazoredModal { get; set; } = default!;
…
private async Task EditAsync()
{
var responseHttp = await repository.Put("/api/categories", category);
if (responseHttp.Error)
{
var message = await responseHttp.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
await BlazoredModal.CloseAsync(ModalResult.Ok());
Return();
}
…
7. Modificamos el CategoriesCreate:
…
[CascadingParameter]
BlazoredModalInstance BlazoredModal { get; set; } = default!;
…
private async Task CreateAsync()
{
var httpResponse = await repository.Post("/api/categories", category);
if (httpResponse.Error)
{
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
await BlazoredModal.CloseAsync(ModalResult.Ok());
Return();
}
…
Actividad #4
Deben estar al día en la aplicación, aplicar ventanas modales al resto de la aplicación, es decir:
Crear/Editar de países, estados y ciudades y aplicar ventana modal al cambio de contraseña.
using Microsoft.EntityFrameworkCore.Metadata.Internal;
146
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Sales.Shared.Entities
{
public class Product
{
public int Id { get; set; }
[Display(Name = "Nombre")]
[MaxLength(50, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[DataType(DataType.MultilineText)]
[Display(Name = "Descripción")]
[MaxLength(500, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
public string Description { get; set; } = null!;
[Column(TypeName = "decimal(18,2)")]
[DisplayFormat(DataFormatString = "{0:C2}")]
[Display(Name = "Precio")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public decimal Price { get; set; }
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Inventario")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public float Stock { get; set; }
}
}
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class ProductImage
{
public int Id { get; set; }
[Display(Name = "Imagen")]
public string Image { get; set; } = null!;
}
}
namespace Sales.Shared.Entities
147
{
public class ProductCategory
{
public int Id { get; set; }
[Display(Name = "Categoría")]
[MaxLength(100, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[Display(Name = "Productos")]
public int ProductCategoriesNumber => ProductCategories == null ? 0 : ProductCategories.Count;
}
[Display(Name = "Nombre")]
[MaxLength(50, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[DataType(DataType.MultilineText)]
[Display(Name = "Descripción")]
[MaxLength(500, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
public string Description { get; set; } = null!;
[Column(TypeName = "decimal(18,2)")]
[DisplayFormat(DataFormatString = "{0:C2}")]
[Display(Name = "Precio")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public decimal Price { get; set; }
148
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Inventario")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public float Stock { get; set; }
[Display(Name = "Categorías")]
public int ProductCategoriesNumber => ProductCategories == null ? 0 : ProductCategories.Count;
[Display(Name = "Imágenes")]
public int ProductImagesNumber => ProductImages == null ? 0 : ProductImages.Count;
[Display(Name = "Imagén")]
public string MainImage => ProductImages == null ? string.Empty : ProductImages.FirstOrDefault()!.Image;
}
6. Modificamos el DataContext.
8. Dentro del proyecto API copiamos el folder Images el cual puedes obtener de mi repositorio.
10. Modificamos el SeedDb para agregar registros a las nuevas tablas y de paso aprovechamos y creamos los
usuarios con foto:
150
await AddProductAsync("AirPods", 1300000M, 12F, new List<string>() { "Tecnología", "Apple" }, new List<string>() {
"airpos.png", "airpos2.png" });
await AddProductAsync("Audifonos Bose", 870000M, 12F, new List<string>() { "Tecnología" }, new List<string>()
{ "audifonos_bose.png" });
await AddProductAsync("Bicicleta Ribble", 12000000M, 6F, new List<string>() { "Deportes" }, new List<string>()
{ "bicicleta_ribble.png" });
await AddProductAsync("Camisa Cuadros", 56000M, 24F, new List<string>() { "Ropa" }, new List<string>()
{ "camisa_cuadros.png" });
await AddProductAsync("Casco Bicicleta", 820000M, 12F, new List<string>() { "Deportes" }, new List<string>()
{ "casco_bicicleta.png", "casco.png" });
await AddProductAsync("iPad", 2300000M, 6F, new List<string>() { "Tecnología", "Apple" }, new List<string>()
{ "ipad.png" });
await AddProductAsync("iPhone 13", 5200000M, 6F, new List<string>() { "Tecnología", "Apple" }, new List<string>()
{ "iphone13.png", "iphone13b.png", "iphone13c.png", "iphone13d.png" });
await AddProductAsync("Mac Book Pro", 12100000M, 6F, new List<string>() { "Tecnología", "Apple" }, new
List<string>() { "mac_book_pro.png" });
await AddProductAsync("Mancuernas", 370000M, 12F, new List<string>() { "Deportes" }, new List<string>()
{ "mancuernas.png" });
await AddProductAsync("Mascarilla Cara", 26000M, 100F, new List<string>() { "Belleza" }, new List<string>()
{ "mascarilla_cara.png" });
await AddProductAsync("New Balance 530", 180000M, 12F, new List<string>() { "Calzado", "Deportes" }, new
List<string>() { "newbalance530.png" });
await AddProductAsync("New Balance 565", 179000M, 12F, new List<string>() { "Calzado", "Deportes" }, new
List<string>() { "newbalance565.png" });
await AddProductAsync("Nike Air", 233000M, 12F, new List<string>() { "Calzado", "Deportes" }, new List<string>()
{ "nike_air.png" });
await AddProductAsync("Nike Zoom", 249900M, 12F, new List<string>() { "Calzado", "Deportes" }, new
List<string>() { "nike_zoom.png" });
await AddProductAsync("Buso Adidas Mujer", 134000M, 12F, new List<string>() { "Ropa", "Deportes" }, new
List<string>() { "buso_adidas.png" });
await AddProductAsync("Suplemento Boots Original", 15600M, 12F, new List<string>() { "Nutrición" }, new
List<string>() { "Boost_Original.png" });
await AddProductAsync("Whey Protein", 252000M, 12F, new List<string>() { "Nutrición" }, new List<string>()
{ "whey_protein.png" });
await AddProductAsync("Arnes Mascota", 25000M, 12F, new List<string>() { "Mascotas" }, new List<string>()
{ "arnes_mascota.png" });
await AddProductAsync("Cama Mascota", 99000M, 12F, new List<string>() { "Mascotas" }, new List<string>()
{ "cama_mascota.png" });
await AddProductAsync("Teclado Gamer", 67000M, 12F, new List<string>() { "Gamer", "Tecnología" }, new
List<string>() { "teclado_gamer.png" });
await AddProductAsync("Silla Gamer", 980000M, 12F, new List<string>() { "Gamer", "Tecnología" }, new
List<string>() { "silla_gamer.png" });
await AddProductAsync("Mouse Gamer", 132000M, 12F, new List<string>() { "Gamer", "Tecnología" }, new
List<string>() { "mouse_gamer.png" });
await _context.SaveChangesAsync();
}
}
private async Task AddProductAsync(string name, decimal price, float stock, List<string> categories, List<string>
images)
{
Product prodcut = new()
{
Description = name,
151
Name = name,
Price = price,
Stock = stock,
ProductCategories = new List<ProductCategory>(),
ProductImages = new List<ProductImage>()
};
_context.Products.Add(prodcut);
}
152
private async Task<User> CheckUserAsync(string document, string firstName, string lastName, string email, string
phone, string address, string image, UserType userType)
{
var user = await _userHelper.GetUserAsync(email);
if (user == null)
{
var city = await _context.Cities.FirstOrDefaultAsync(x => x.Name == "Medellín");
if (city == null)
{
city = await _context.Cities.FirstOrDefaultAsync();
}
return user;
}
…
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Sales.Shared.DTOs
{
public class ProductDTO
{
153
public int Id { get; set; }
[Display(Name = "Nombre")]
[MaxLength(50, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public string Name { get; set; } = null!;
[DataType(DataType.MultilineText)]
[Display(Name = "Descripción")]
[MaxLength(500, ErrorMessage = "El campo {0} debe tener máximo {1} caractéres.")]
public string Description { get; set; } = null!;
[Column(TypeName = "decimal(18,2)")]
[DisplayFormat(DataFormatString = "{0:C2}")]
[Display(Name = "Precio")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public decimal Price { get; set; }
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Inventario")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public float Stock { get; set; }
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.API.Helpers;
using Sales.Shared.DTOs;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Route("/api/products")]
public class ProductsController : ControllerBase
{
private readonly DataContext _context;
private readonly IFileStorage _fileStorage;
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Products
.Include(x => x.ProductImages)
.Include(x => x.ProductCategories)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Products
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.Name.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetAsync(int id)
{
var product = await _context.Products
.Include(x => x.ProductImages)
.Include(x => x.ProductCategories!)
.ThenInclude(x => x.Category)
.FirstOrDefaultAsync(x => x.Id == id);
if (product == null)
{
return NotFound();
}
return Ok(product);
}
155
[HttpPost]
public async Task<ActionResult> PostAsync(ProductDTO productDTO)
{
try
{
Product newProduct = new()
{
Name = productDTO.Name,
Description = productDTO.Description,
Price = productDTO.Price,
Stock = productDTO.Stock,
ProductCategories = new List<ProductCategory>(),
ProductImages = new List<ProductImage>()
};
_context.Add(newProduct);
await _context.SaveChangesAsync();
return Ok(productDTO);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe una ciudad con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut]
public async Task<ActionResult> PutAsync(Product product)
{
try
{
_context.Update(product);
await _context.SaveChangesAsync();
156
return Ok(product);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe un producto con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var product = await _context.Products.FirstOrDefaultAsync(x => x.Id == id);
if (product == null)
{
return NotFound();
}
_context.Remove(product);
await _context.SaveChangesAsync();
return NoContent();
}
}
}
14. Dentro de Pages creamos la carpeta Products y dentro de esta creamos el ProductsIndex:
@page "/products"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
157
<a class="btn btn-sm btn-primary float-end" href="/products/create"><i class="oi oi-plus"/> Nuevo
Producto</a>
</span>
</div>
<div class="card-body">
<div class="mb-2" style="display: flex; flex-wrap:wrap; align-items: center;">
<div>
<input style="width: 400px;" type="text" class="form-control" id="titulo" placeholder="Buscar
producto..." @bind-value="Filter" />
</div>
<div class="mx-1">
<button type="button" class="btn btn-outline-primary" @onclick="ApplyFilterAsync"><i class="oi oi-
layers" /> Filtrar</button>
<button type="button" class="btn btn-outline-danger" @onclick="CleanFilterAsync"><i class="oi oi-
ban" /> Limpiar</button>
</div>
</div>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPageAsync" />
@code {
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
159
string url1 = string.Empty;
string url2 = string.Empty;
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/products?page={page}";
url2 = $"api/products/totalPages";
}
else
{
url1 = $"api/products?page={page}&filter={Filter}";
url2 = $"api/products/totalPages?filter={Filter}";
}
try
{
var responseHppt = await repository.Get<List<Product>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Products = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
if (confirm)
{
return;
}
if (responseHTTP.Error)
{
if (responseHTTP.HttpResponseMessage.StatusCode == System.Net.HttpStatusCode.NotFound)
{
navigationManager.NavigateTo("/");
return;
}
160
var mensajeError = await responseHTTP.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", mensajeError, SweetAlertIcon.Error);
return;
}
await LoadAsync(1);
}
<AuthorizeView Roles="Admin">
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="categories">
<span class="oi oi-list" aria-hidden="true"></span> Categorías
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="countries">
<span class="oi oi-globe" aria-hidden="true"></span> Países
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="products">
<span class="oi oi-star" aria-hidden="true"></span> Productos
</NavLink>
</div>
</Authorized>
</AuthorizeView>
namespace Sales.WEB.Helpers
{
public class MultipleSelectorModel
161
{
public MultipleSelectorModel(string key, string value)
{
Key = key;
Value = value;
}
.multiple-selector {
display: flex;
}
.selectable-ul {
height: 200px;
overflow-y: auto;
list-style-type: none;
width: 170px;
padding: 0;
border-radius: 3px;
border: 1px solid #ccc;
}
.selectable-ul li {
cursor: pointer;
border-bottom: 1px #eee solid;
padding: 2px 10px;
font-size: 14px;
}
.selectable-ul li:hover {
background-color: #08c
}
.multiple-selector-botones {
display: flex;
flex-direction: column;
justify-content: center;
padding: 5px
}
.multiple-selector-botones button {
margin: 5px;
}
<div class="multiple-selector">
162
<ul class="selectable-ul">
@foreach (var item in NonSelected)
{
<li @onclick=@(() => Select(item))>@item.Value</li>
}
</ul>
<div class="selector-multiple-botones">
<div class="mx-2 my-2">
<p><button type="button" @onclick="SelectAll">@addAllText</button></p>
</div>
<div class="mx-2 my-2">
<p><button type="button" @onclick="UnselectAll">@removeAllText</button></p>
</div>
</div>
<ul class="selectable-ul">
@foreach (var item in Selected)
{
<li @onclick=@(() => Unselect(item))>@item.Value</li>
}
</ul>
</div>
@code {
private string addAllText = ">>";
private string removeAllText = "<<";
[Parameter]
public List<MultipleSelectorModel> NonSelected { get; set; } = new();
[Parameter]
public List<MultipleSelectorModel> Selected { get; set; } = new();
<NavigationLock OnBeforeInternalNavigation="OnBeforeInternalNavigation"></NavigationLock>
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-star" /> Crear Nuevo Producto
<a class="btn btn-sm btn-success float-end" href="/products"><i class="oi oi-arrow-thick-left" /> Regresar</a>
<button class="btn btn-sm btn-primary float-end mx-2" type="submit"><i class="oi oi-check" /> Guardar
Cambios</button>
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombre:</label>
<div>
<InputText class="form-control" @bind-Value="@ProductDTO.Name" />
<ValidationMessage For="@(() => ProductDTO.Name)" />
</div>
</div>
<div class="mb-3">
<label>Descripción:</label>
<div>
<InputText class="form-control" @bind-Value="@ProductDTO.Description" />
<ValidationMessage For="@(() => ProductDTO.Description)" />
</div>
</div>
<div class="mb-3">
<label>Precio:</label>
<div>
<InputNumber class="form-control" @bind-Value="@ProductDTO.Price" />
<ValidationMessage For="@(() => ProductDTO.Price)" />
</div>
</div>
<div class="mb-3">
<label>Inventario:</label>
<div>
<InputNumber class="form-control" @bind-Value="@ProductDTO.Stock" />
<ValidationMessage For="@(() => ProductDTO.Stock)" />
</div>
</div>
</div>
<div class="col-6">
164
<div class="mb-3">
<label>Categorías:</label>
<div>
<MultipleSelector NonSelected="nonSelected" Selected="selected" />
</div>
</div>
<div class="mb-3">
<InputImg Label="Foto" ImageSelected="ImageSelected" ImageURL="@imageUrl" />
</div>
@if (IsEdit)
{
<div class="mb-3">
<button type="button" class="btn btn-outline-primary" @onclick="AddImageAction"><i class="oi oi-plus"
/> Agregar Imagenes</button>
<button type="button" class="btn btn-outline-danger" @onclick="RemoveImageAction"><i class="oi oi-
trash" /> Eliminar Última Imagén</button>
</div>
}
</div>
</div>
</div>
</div>
</EditForm>
@code {
private EditContext editContext = null!;
private List<MultipleSelectorModel> selected { get; set; } = new();
private List<MultipleSelectorModel> nonSelected { get; set; } = new();
private string? imageUrl;
[Parameter]
public bool IsEdit { get; set; } = false;
[EditorRequired]
[Parameter]
public ProductDTO ProductDTO { get; set; } = null!;
[EditorRequired]
[Parameter]
public EventCallback OnValidSubmit { get; set; }
[EditorRequired]
[Parameter]
public EventCallback ReturnAction { get; set; }
[Parameter]
public EventCallback AddImageAction { get; set; }
[Parameter]
165
public EventCallback RemoveImageAction { get; set; }
[Parameter]
public List<Category> SelectedCategories { get; set; } = new();
[Parameter]
[EditorRequired]
public List<Category> NonSelectedCategories { get; set; } = new();
ProductDTO.ProductImages!.Add(imagenBase64);
imageUrl = null;
}
if (!formWasEdited)
{
return;
}
if (FormPostedSuccessfully)
{
return;
}
if (confirm)
{
return;
}
context.PreventNavigation();
}
}
@page "/products/create"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
@if (loading)
{
<div class="spinner" />
}
else
{
<ProductForm @ref="productForm" ProductDTO="productDTO" NonSelectedCategories="nonSelectedCategories"
OnValidSubmit="CreateAsync" ReturnAction="Return" />
}
@code {
private ProductDTO productDTO = new ProductDTO
{
ProductCategoryIds = new List<int>(),
ProductImages = new List<string>()
};
if (httpResponse.Error)
{
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
167
return;
}
nonSelectedCategories = httpResponse.Response!;
}
Return();
}
[HttpPost]
public async Task<ActionResult> PostAsync(ProductDTO productDTO)
{
try
{
Product newProduct = new()
{
Name = productDTO.Name,
Description = productDTO.Description,
Price = productDTO.Price,
Stock = productDTO.Stock,
ProductCategories = new List<ProductCategory>(),
ProductImages = new List<ProductImage>()
};
_context.Add(newProduct);
await _context.SaveChangesAsync();
return Ok(productDTO);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe una ciudad con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
24. Probamos y hacemos el commit de lo que hemos logrado hasta el momento, corra la App con Ctrl + F5, para
que tome los cambios en el CSS.
@using MudBlazor
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Sales.WEB</title>
<base href="/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="Sales.WEB.styles.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
</head>
169
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/CurrieTechnologies.Razor.SweetAlert2/sweetAlert2.min.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
builder.Services.AddMudServices();
<div class="my-2">
<MudCarousel Class="mud-width-full" Style="height:200px;" ShowArrows="@arrows" ShowBullets="@bullets"
EnableSwipeGesture="@enableSwipeGesture" AutoCycle="@autocycle" TData="object">
@foreach (var image in Images)
{
<MudCarouselItem Transition="transition" Color="@Color.Primary">
<div class="d-flex" style="height:100%; justify-content:center">
<img src="@image" />
</div>
</MudCarouselItem>
}
</MudCarousel>
</div>
@code {
private bool arrows = true;
private bool bullets = true;
private bool enableSwipeGesture = true;
private bool autocycle = true;
private Transition transition = Transition.Slide;
[EditorRequired]
[Parameter]
public List<string> Images { get; set; } = null!;
}
170
7. Modificamos el ProductForm:
…
</EditForm>
8. Creamos el ProductEdit:
@page "/products/edit/{ProductId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
@if (loading)
{
<div class="spinner" />
}
else
{
<ProductForm @ref="productForm" ProductDTO="productDTO" SelectedCategories="selectedCategories"
NonSelectedCategories="nonSelectedCategories" OnValidSubmit="SaveChangesAsync" ReturnAction="Return"
IsEdit=true AddImageAction="AddImageAsync" RemoveImageAction="RemoveImageAsyc"/>
}
@code {
private ProductDTO productDTO = new ProductDTO
{
ProductCategoryIds = new List<int>(),
ProductImages = new List<string>()
};
[Parameter]
public int ProductId { get; set; }
if (httpResponse.Error)
{
loading = false;
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
product = httpResponse.Response!;
productDTO = ToProductDTO(product);
loading = false;
}
if (httpResponse.Error)
{
loading = false;
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
Return();
}
[HttpPut]
public async Task<ActionResult> PutAsync(ProductDTO productDTO)
{
try
{
var product = await _context.Products
.Include(x => x.ProductCategories)
.FirstOrDefaultAsync(x => x.Id == productDTO.Id);
if (product == null)
{
return NotFound();
}
product.Name = productDTO.Name;
product.Description = productDTO.Description;
product.Price = productDTO.Price;
product.Stock = productDTO.Stock;
product.ProductCategories = productDTO.ProductCategoryIds!.Select(x => new ProductCategory { CategoryId =
x }).ToList();
173
_context.Update(product);
await _context.SaveChangesAsync();
return Ok(productDTO);
}
catch (DbUpdateException dbUpdateException)
{
if (dbUpdateException.InnerException!.Message.Contains("duplicate"))
{
return BadRequest("Ya existe una ciudad con el mismo nombre.");
}
return BadRequest(dbUpdateException.Message);
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
10. Probamos y hacemos el commit de lo que hemos logrado hasta el momento, corra la App con Ctrl + F5, para
que tome los cambios en el CSS.
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.DTOs
{
public class ImageDTO
{
[Required]
public int ProductId { get; set; }
[Required]
public List<string> Images { get; set; } = null!;
}
}
[HttpPost("addImages")]
public async Task<ActionResult> PostAddImagesAsync(ImageDTO imageDTO)
{
var product = await _context.Products
.Include(x => x.ProductImages)
.FirstOrDefaultAsync(x => x.Id == imageDTO.ProductId);
if (product == null)
{
return NotFound();
}
174
if (product.ProductImages is null)
{
product.ProductImages = new List<ProductImage>();
}
_context.Update(product);
await _context.SaveChangesAsync();
return Ok(imageDTO);
}
[HttpPost("removeLastImage")]
public async Task<ActionResult> PostRemoveLastImageAsync(ImageDTO imageDTO)
{
var product = await _context.Products
.Include(x => x.ProductImages)
.FirstOrDefaultAsync(x => x.Id == imageDTO.ProductId);
if (product == null)
{
return NotFound();
}
<div class="my-2">
<MudCarousel Class="mud-width-full" Style="height:200px;" ShowArrows="@arrows" ShowBullets="@bullets"
EnableSwipeGesture="@enableSwipeGesture" AutoCycle="@autocycle" TData="object">
@foreach (var image in Images)
{
@if (image.StartsWith("https://sales2023.blob.core.windows.net/products/"))
175
{
<MudCarouselItem Transition="transition" Color="@Color.Primary">
<div class="d-flex" style="height:100%; justify-content:center">
<img src="@image" />
</div>
</MudCarouselItem>
}
}
</MudCarousel>
</div>
productDTO.ProductImages = httpResponse.Response!.Images;
var toast = sweetAlertService.Mixin(new SweetAlertOptions
{
Toast = true,
Position = SweetAlertPosition.TopEnd,
ShowConfirmButton = false,
Timer = 5000
});
await toast.FireAsync(icon: SweetAlertIcon.Success, message: "Imagenes agregadas con éxito.");
}
productDTO.ProductImages = httpResponse.Response!.Images;
var toast = sweetAlertService.Mixin(new SweetAlertOptions
{
Toast = true,
Position = SweetAlertPosition.TopEnd,
ShowConfirmButton = false,
Timer = 5000
});
await toast.FireAsync(icon: SweetAlertIcon.Success, message: "Imagén eliminada con éxito.");
}
15. Probamos y hacemos el commit de lo que hemos logrado hasta el momento, corra la App con Ctrl + F5, para
que tome los cambios en el CSS.
2. Modificamos el Index.razor.
@page "/"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<style type="text/css">
.card {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid lightgray;
box-shadow: 2px 2px 8px 4px #d3d3d3d1;
border-radius: 15px;
font-family: sans-serif;
margin: 5px;
}
</style>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPageAsync" />
@code {
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
178
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/products?page={page}&RecordsNumber=8";
url2 = $"api/products/totalPages/?RecordsNumber=8";
}
else
{
url1 = $"api/products?page={page}&filter={Filter}&RecordsNumber=8";
url2 = $"api/products/totalPages?filter={Filter}&RecordsNumber=8";
}
try
{
var responseHppt = await repository.Get<List<Product>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Products = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
}
}
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class TemporalSale
{
public int Id { get; set; }
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Cantidad")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public float Quantity { get; set; }
[DataType(DataType.MultilineText)]
[Display(Name = "Comentarios")]
public string? Remarks { get; set; }
180
3. Modificmos la entidad User agregando esta propiedad:
4. La adicionamos en el DataContext:
namespace Sales.Shared.DTOs
{
public class TemporalSaleDTO
{
public int ProductId { get; set; }
7. Creamos el TemporalSalesController:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.DTOs;
using Sales.Shared.Entities;
namespace Sales.API.Controllers
{
[ApiController]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Route("/api/temporalSales")]
public class TemporalSalesController : ControllerBase
{
private readonly DataContext _context;
[HttpPost]
public async Task<ActionResult> Post(TemporalSaleDTO temporalSaleDTO)
{
var product = await _context.Products.FirstOrDefaultAsync(x => x.Id == temporalSaleDTO.ProductId);
if (product == null)
{
181
return NotFound();
}
try
{
_context.Add(temporalSale);
await _context.SaveChangesAsync();
return Ok(temporalSaleDTO);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet]
public async Task<ActionResult> Get()
{
return Ok(await _context.TemporalSales
.Include(ts => ts.User!)
.Include(ts => ts.Product!)
.ThenInclude(p => p.ProductCategories!)
.ThenInclude(pc => pc.Category)
.Include(ts => ts.Product!)
.ThenInclude(p => p.ProductImages)
.Where(x => x.User!.Email == User.Identity!.Name)
.ToListAsync());
}
[HttpGet("count")]
public async Task<ActionResult> GetCount()
{
return Ok(await _context.TemporalSales
.Where(x => x.User!.Email == User.Identity!.Name)
.SumAsync(x => x.Quantity));
}
}
}
8. Modificamos el Index.razor.
182
@page "/"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
<style type="text/css">
.card {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid lightgray;
box-shadow: 2px 2px 8px 4px #d3d3d3d1;
border-radius: 15px;
font-family: sans-serif;
margin: 5px;
}
</style>
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPageAsync" />
@code {
private int currentPage = 1;
private int totalPages;
private int counter = 0;
private bool isAuthenticated;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; } = null!;
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/products?page={page}&RecordsNumber=8";
url2 = $"api/products/totalPages/?RecordsNumber=8";
}
else
{
url1 = $"api/products?page={page}&filter={Filter}&RecordsNumber=8";
url2 = $"api/products/totalPages?filter={Filter}&RecordsNumber=8";
}
try
{
var responseHppt = await repository.Get<List<Product>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Products = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
185
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
await LoadCounterAsync();
4. Dentro de Pages creamos la carpeta Orders y dentro de esta creamos el ShowCart.razor temporal.
@page "/Orders/ShowCart"
<h3>ShowCart</h3>
@code {
6. Ahora vamos a mostrar los detalles del producto y dar la oportunidad de agregar al carro de compras ingresando
una cantidad y un comentario. Primero creamos el ProductDetails.razor.
@page "/orders/details/{ProductId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@if (loading)
{
<div class="spinner" />
}
else
{
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-star" /> @product!.Name
<a class="btn btn-sm btn-success float-end" href="/"><i class="oi oi-arrow-thick-left" /> Regresar</a>
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombre:</label>
<div>
<b>@product.Name</b>
</div>
</div>
<div class="mb-3">
<label>Descripción:</label>
<div>
<b>@product.Description</b>
187
</div>
</div>
<div class="mb-3">
<label>Precio:</label>
<div>
<b>@($"{product.Price:C2}")</b>
</div>
</div>
<div class="mb-3">
<label>Inventario:</label>
<div>
<b>@($"{product.Stock:N2}")</b>
</div>
</div>
<div class="mb-3">
<label>Categorías:</label>
<div>
@foreach (var category in categories!)
{
<div class="mx-2">
<b>@category</b>
</div>
}
</div>
</div>
</div>
<div class="col-6">
<EditForm Model="TemporalSaleDTO" OnValidSubmit="AddToCartAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label>Cantidad:</label>
<div>
<InputNumber class="form-control" @bind-Value="@TemporalSaleDTO.Quantity" />
<ValidationMessage For="@(() => TemporalSaleDTO.Quantity)" />
</div>
<label>Comentarios:</label>
<div>
<InputText class="form-control" @bind-Value="@TemporalSaleDTO.Remarks" />
<ValidationMessage For="@(() => TemporalSaleDTO.Remarks)" />
</div>
</div>
<button class="btn btn-primary" type="submit"><i class="oi oi-plus" /> Agregar Al Carro de
Compras</button>
</EditForm>
</div>
</div>
<CarouselView Images="images" />
</div>
</div>
}
@code {
private List<string>? categories;
188
private List<string>? images;
private bool loading = true;
private Product? product;
private bool isAuthenticated;
[Parameter]
public int ProductId { get; set; }
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; } = null!;
if (httpResponse.Error)
{
loading = false;
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
product = httpResponse.Response!;
categories = product.ProductCategories!.Select(x => x.Category.Name).ToList();
images = product.ProductImages!.Select(x => x.Image).ToList();
loading = false;
}
TemporalSaleDTO.ProductId = ProductId;
namespace Sales.Shared.Enums
{
public enum OrderStatus
{
Nuevo,
Despachado,
Enviado,
Confirmado,
Cancelado
}
190
}
3. Agregamos el SaleDTO:
using Sales.Shared.Enums;
namespace Sales.Shared.DTOs
{
public class SaleDTO
{
public int Id { get; set; }
[HttpGet("{id:int}")]
public async Task<ActionResult> Get(int id)
{
return Ok(await _context.TemporalSales
.Include(ts => ts.User!)
.Include(ts => ts.Product!)
.ThenInclude(p => p.ProductCategories!)
.ThenInclude(pc => pc.Category)
.Include(ts => ts.Product!)
.ThenInclude(p => p.ProductImages)
.FirstOrDefaultAsync(x => x.Id == id));
}
[HttpPut]
public async Task<ActionResult> Put(TemporalSaleDTO temporalSaleDTO)
{
var currentTemporalSale = await _context.TemporalSales.FirstOrDefaultAsync(x => x.Id == temporalSaleDTO.Id);
if (currentTemporalSale == null)
{
return NotFound();
}
currentTemporalSale!.Remarks = temporalSaleDTO.Remarks;
currentTemporalSale.Quantity = temporalSaleDTO.Quantity;
_context.Update(currentTemporalSale);
await _context.SaveChangesAsync();
return Ok(temporalSaleDTO);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync(int id)
{
var temporalSale = await _context.TemporalSales.FirstOrDefaultAsync(x => x.Id == id);
191
if (temporalSale == null)
{
return NotFound();
}
_context.Remove(temporalSale);
await _context.SaveChangesAsync();
return NoContent();
}
@page "/Orders/ShowCart"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin, User")]
@code {
public List<TemporalSale>? temporalSales { get; set; }
193
private float sumQuantity;
private decimal sumValue;
if (confirm)
{
return;
}
if (responseHTTP.Error)
{
if (responseHTTP.HttpResponseMessage.StatusCode == System.Net.HttpStatusCode.NotFound)
{
navigationManager.NavigateTo("/");
194
return;
}
await LoadAsync();
var toast = sweetAlertService.Mixin(new SweetAlertOptions
{
Toast = true,
Position = SweetAlertPosition.TopEnd,
ShowConfirmButton = false,
Timer = 5000
});
await toast.FireAsync(icon: SweetAlertIcon.Success, message: "Producto eliminado del carro de compras.");
}
}
@page "/Orders/ModifyTemporalSale/{TemporalSaleId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@if (loading)
{
<div class="spinner" />
}
else
{
<div class="card">
<div class="card-header">
<span>
<i class="oi oi-star" /> @product!.Name
<a class="btn btn-sm btn-success float-end" href="/"><i class="oi oi-arrow-thick-left" /> Regresar</a>
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="mb-3">
<label>Nombre:</label>
<div>
<b>@product.Name</b>
</div>
</div>
<div class="mb-3">
<label>Descripción:</label>
<div>
195
<b>@product.Description</b>
</div>
</div>
<div class="mb-3">
<label>Precio:</label>
<div>
<b>@($"{product.Price:C2}")</b>
</div>
</div>
<div class="mb-3">
<label>Inventario:</label>
<div>
<b>@($"{product.Stock:N2}")</b>
</div>
</div>
<div class="mb-3">
<label>Categorías:</label>
<div>
@foreach (var category in categories!)
{
<div class="mx-2">
<b>@category</b>
</div>
}
</div>
</div>
</div>
<div class="col-6">
<EditForm Model="temporalSaleDTO" OnValidSubmit="UpdateCartAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label>Cantidad:</label>
<div>
<InputNumber class="form-control" @bind-Value="@temporalSaleDTO!.Quantity" />
<ValidationMessage For="@(() => temporalSaleDTO.Quantity)" />
</div>
<label>Comentarios:</label>
<div>
<InputText class="form-control" @bind-Value="@temporalSaleDTO.Remarks" />
<ValidationMessage For="@(() => temporalSaleDTO.Remarks)" />
</div>
</div>
<button class="btn btn-primary" type="submit"><i class="oi oi-check" /> Actualizar Carro de
Compras</button>
</EditForm>
</div>
</div>
<CarouselView Images="images" />
</div>
</div>
}
@code {
private List<string>? categories;
196
private List<string>? images;
private bool loading = true;
private Product? product;
private bool isAuthenticated;
private TemporalSaleDTO? temporalSaleDTO;
[Parameter]
public int TemporalSaleId { get; set; }
if (httpResponse.Error)
{
loading = false;
var message = await httpResponse.GetErrorMessageAsync();
await sweetAlertService.FireAsync("Error", message, SweetAlertIcon.Error);
return;
}
Procesando el pedido
1. Agregamos la entidad Sale:
using Sales.Shared.Enums;
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class Sale
{
public int Id { get; set; }
[DataType(DataType.MultilineText)]
[Display(Name = "Comentarios")]
public string? Remarks { get; set; }
[DisplayFormat(DataFormatString = "{0:N0}")]
[Display(Name = "Líneas")]
public int Lines => SaleDetails == null ? 0 : SaleDetails.Count;
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Cantidad")]
public float Quantity => SaleDetails == null ? 0 : SaleDetails.Sum(sd => sd.Quantity);
[DisplayFormat(DataFormatString = "{0:C2}")]
[Display(Name = "Valor")]
public decimal Value => SaleDetails == null ? 0 : SaleDetails.Sum(sd => sd.Value);
}
}
198
2. Agregamos la entidad SaleDetail:
using System.ComponentModel.DataAnnotations;
namespace Sales.Shared.Entities
{
public class SaleDetail
{
public int Id { get; set; }
[DataType(DataType.MultilineText)]
[Display(Name = "Comentarios")]
public string? Remarks { get; set; }
[DisplayFormat(DataFormatString = "{0:N2}")]
[Display(Name = "Cantidad")]
[Required(ErrorMessage = "El campo {0} es obligatorio.")]
public float Quantity { get; set; }
[DisplayFormat(DataFormatString = "{0:C2}")]
[Display(Name = "Valor")]
public decimal Value => Product == null ? 0 : (decimal)Quantity * Product.Price;
}
}
using Sales.Shared.Responses;
199
namespace Sales.API.Helpers
{
public interface IOrdersHelper
{
Task<Response> ProcessOrderAsync(string email, string remarks);
}
}
using Microsoft.EntityFrameworkCore;
using Sales.API.Data;
using Sales.Shared.Entities;
using Sales.Shared.Enums;
using Sales.Shared.Responses;
namespace Sales.API.Helpers
{
public class OrdersHelper : IOrdersHelper
{
private readonly DataContext _context;
_context.TemporalSales.Remove(temporalSale);
}
_context.Sales.Add(sale);
await _context.SaveChangesAsync();
return response;
}
201
9. Lo inyectamos en el Program del API:
builder.Services.AddScoped<IOrdersHelper, OrdersHelper>();
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Sales.API.Helpers;
using Sales.Shared.DTOs;
namespace Sales.API.Controllers
{
[ApiController]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Route("/api/sales")]
public class SalesController : ControllerBase
{
private readonly IOrdersHelper _ordersHelper;
[HttpPost]
public async Task<ActionResult> Post(SaleDTO saleDTO)
{
var response = await _ordersHelper.ProcessOrderAsync(User.Identity!.Name!, saleDTO.Remarks);
if (response.IsSuccess)
{
return NoContent();
}
return BadRequest(response.Message);
}
}
}
@page "/Orders/SaleConfirmed"
<center>
<h3>Pedido Confirmado</h3>
<img src="images/Shopping.png" width="300" />
<p>Su peidido ha sido confirmado. En pronto recibirá sus productos, muchas gracias</p>
<a href="/" class="btn btn-primary">Volver al inicio</a>
</center>
navigationManager.NavigateTo("/Orders/SaleConfirmed");
}
Administrar pedidos
1. Agregamos estos métodos al SalesController, primero inyectamos el DataContext y el IUserHelper:
[HttpGet]
public async Task<ActionResult> Get([FromQuery] PaginationDTO pagination)
{
var user = await _context.Users.FirstOrDefaultAsync(x => x.Email == User.Identity!.Name);
if (user == null)
{
return BadRequest("User not valid.");
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var user = await _context.Users.FirstOrDefaultAsync(x => x.Email == User.Identity!.Name);
if (user == null)
{
return BadRequest("User not valid.");
}
@page "/sales"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
205
@code {
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
try
{
var responseHppt = await repository.Get<List<Sale>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Sales = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
}
3. Modificamos el NavMenu.razor:
[HttpGet("{id:int}")]
public async Task<ActionResult> Get(int id)
{
var sale = await _context.Sales
.Include(s => s.User!)
.ThenInclude(u => u.City!)
.ThenInclude(c => c.State!)
.ThenInclude(s => s.Country)
.Include(s => s.SaleDetails!)
.ThenInclude(sd => sd.Product)
.ThenInclude(p => p.ProductImages)
.FirstOrDefaultAsync(s => s.Id == id);
if (sale== null)
{
return NotFound();
}
return Ok(sale);
}
6. Creamos el SaleDetails:
@page "/orders/saleDetails/{SaleId:int}"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
208
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>Producto</th>
<th>Imagen</th>
<th>Comentarios</th>
<th>Cantidad</th>
<th>Precio</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
@foreach (var saleDetail in sale.SaleDetails!)
{
<tr>
<td>@saleDetail.Product!.Name</td>
<td><img src="@saleDetail.Product!.MainImage" style="width:100px;" /></td>
<td>@saleDetail.Remarks</td>
<td>@($"{saleDetail.Quantity:N2}")</td>
<td>@($"{saleDetail.Product!.Price:C2}")</td>
<td>@($"{saleDetail.Value:C2}")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</Body>
</GenericList>
}
@code {
private Sale? sale;
[Parameter]
public int SaleId { get; set; }
}
}
[HttpPut]
public async Task<ActionResult> Put(SaleDTO saleDTO)
{
var user = await _userHelper.GetUserAsync(User.Identity!.Name!);
if (user == null)
{
return NotFound();
}
if (saleDTO.OrderStatus == OrderStatus.Cancelado)
{
210
await ReturnStockAsync(sale);
}
sale.OrderStatus = saleDTO.OrderStatus;
_context.Update(sale);
await _context.SaveChangesAsync();
return Ok(sale);
}
211
var confirm = string.IsNullOrEmpty(result.Value);
if (confirm)
{
return;
}
navigationManager.NavigateTo("/sales");
}
</AuthorizeView>
<AuthorizeView Roles="User">
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="sales">
<span class="oi oi-dollar" aria-hidden="true"></span> Ver Mis Pedidos
</NavLink>
</div>
</Authorized>
</AuthorizeView>
</nav>
</div>
212
13. Modificamos el SaleDetails:
…
@attribute [Authorize(Roles = "Admin, User")]
…
<div class="card-header">
<span>
<i class="oi oi-dollar"></i> @sale.User!.FullName
@if (sale.OrderStatus == OrderStatus.Nuevo)
{
<button class="btn btn-sm btn-danger float-end mx-2" @onclick=@(() => CancelSaleAsync())><i class="oi oi-
trash" /> Cancelar</button>
<AuthorizeView Roles="Admin">
<Authorized>
<button class="btn btn-sm btn-primary float-end mx-2" @onclick=@(() => DispatchSaleAsync())><i class="oi
oi-external-link" /> Despachar</button>
</Authorized>
</AuthorizeView>
}
<AuthorizeView Roles="Admin">
<Authorized>
@if (sale.OrderStatus == OrderStatus.Despachado)
{
<button class="btn btn-sm btn-warning float-end mx-2" @onclick=@(() => SendSaleAsync())><i class="oi oi-
location" /> Enviar</button>
}
@if (sale.OrderStatus == OrderStatus.Enviado)
{
<button class="btn btn-sm btn-dark float-end mx-2" @onclick=@(() => ConfirmSaleAsync())><i class="oi oi-
thumb-up" /> Confirmar</button>
}
</Authorized>
</AuthorizeView>
<a class="btn btn-sm btn-success float-end" href="/sales"><i class="oi oi-arrow-thick-left" /> Regresar</a>
</span>
</div>
[HttpGet(“all”)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> GetAll([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Users
.Include(u => u.City)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.FirstName.ToLower().Contains(pagination.Filter.ToLower()) ||
213
x.LastName.ToLower().Contains(pagination.Filter.ToLower()));
}
[HttpGet("totalPages")]
public async Task<ActionResult> GetPages([FromQuery] PaginationDTO pagination)
{
var queryable = _context.Users.AsQueryable();
if (!string.IsNullOrWhiteSpace(pagination.Filter))
{
queryable = queryable.Where(x => x.FirstName.ToLower().Contains(pagination.Filter.ToLower()) ||
x.LastName.ToLower().Contains(pagination.Filter.ToLower()));
}
@page "/users"
@inject IRepository repository
@inject NavigationManager navigationManager
@inject SweetAlertService sweetAlertService
@attribute [Authorize(Roles = "Admin")]
<Pagination CurrentPage="currentPage"
TotalPages="totalPages"
SelectedPage="SelectedPage" />
@code {
public List<User>? Users { get; set; }
private int currentPage = 1;
private int totalPages;
[Parameter]
[SupplyParameterFromQuery]
public string Page { get; set; } = "";
[Parameter]
[SupplyParameterFromQuery]
public string Filter { get; set; } = "";
if (string.IsNullOrEmpty(Filter))
{
url1 = $"api/accounts/all?page={page}";
url2 = $"api/accounts/totalPages";
}
else
{
url1 = $"api/accounts/all?page={page}&filter={Filter}";
url2 = $"api/accounts/totalPages?filter={Filter}";
}
try
216
{
var responseHppt = await repository.Get<List<User>>(url1);
var responseHppt2 = await repository.Get<int>(url2);
Users = responseHppt.Response!;
totalPages = responseHppt2.Response!;
}
catch (Exception ex)
{
await sweetAlertService.FireAsync("Error", ex.Message, SweetAlertIcon.Error);
}
}
…
[Parameter]
[SupplyParameterFromQuery]
public bool IsAdmin { get; set; }
…
private async Task CreteUserAsync()
{
userDTO.UserName = userDTO.Email;
if (IsAdmin)
{
userDTO.UserType = UserType.Admin;
}
else
{
userDTO.UserType = UserType.User;
}
await sweetAlertService.FireAsync("Confirmación", "Su cuenta ha sido creada con éxito. Se te ha enviado un correo
electrónico con las instrucciones para activar tu usuario.", SweetAlertIcon.Info);
217
navigationManager.NavigateTo("/");
}
…
foreach (string? image in images)
{
string filePath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
filePath = $"{Environment.CurrentDirectory}\\Images\\products\\{image}";
}
else
{
filePath = $"{Environment.CurrentDirectory}/Images/products/{image}";
}
string filePath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
filePath = $"{Environment.CurrentDirectory}\\Images\\users\\{image}";
}
else
{
filePath = $"{Environment.CurrentDirectory}/Images/users/{image}";
}
218
PARTE II - App Móvil
219
Páginas
Vamos hacer unas pruebas para irnos familiarizando con MAUI.
namespace Sales.Mobile
{
public partial class App : Application
{
public App()
{
InitializeComponent();
3. Probamos.
4. Cambiamos el método OnCounterClicked de la MainPage para que hagamos nuestra primer navegación:
5. Volvemos a cambiar que nuestra página de inicio sea MainPage, la cual devemos llamar dentro de un
NavigationPage:
public App()
{
InitializeComponent();
220
6. Probamos.
using Sales.Mobile.PagesDemo;
namespace Sales.Mobile
{
public partial class App : Application
{
public App()
{
InitializeComponent();
8. Probamos.
11. Probamos.
namespace Sales.Mobile.PagesDemo;
public App()
{
InitializeComponent();
15. Probamos.
17. Probamos.
18. Ahora agregamos a la carpeta PagesDemo la TabbedPageDemo.xaml:
namespace Sales.Mobile.PagesDemo;
public App()
223
{
InitializeComponent();
224
Controles de presentación
23. Creamos una nueva carpeta en el proyecto Mobile llamada ControlsDemo y dentro de esta creamos la página
PresentationControlsDemo:
25. Probamos.
<Label
Text="Este es un label"
TextColor="Black"
FontAttributes="Bold"
FontSize="Large"
HorizontalTextAlignment="Center"/>
27. Probamos.
<Ellipse
Fill="DarkRed"
Stroke="DarkGreen"
StrokeThickness="10"
HeightRequest="200"
HorizontalOptions="Center"
WidthRequest="200"/>
29. Probamos.
<Line
Stroke="Purple"
X1="0"
Y1="0"
X2="100"
Y2="50"
225
StrokeThickness="10"/>
31. Probamos.
<Rectangle
Fill="Aqua"
Stroke="Black"
StrokeThickness="5"
HeightRequest="50"
HorizontalOptions="End"
WidthRequest="150"
RadiusX="10"
RadiusY="10"/>
33. Probamos.
<Polygon
Fill="LightBlue"
Points="40,10 70,80 10,50"
Stroke="DarkBlue"
StrokeThickness="5"/>
<Polygon
Fill="Yellow"
Points="40,10 70,80 10,50"
Stroke="Green"
StrokeDashArray="1,1"
StrokeDashOffset="6"
StrokeThickness="5"/>
35. Probamos.
<Polyline
Points="0,0 10,30 15,0 18,60 23,30 35,30 40,0 43,60 48,30 100,30"
Stroke="Red"/>
<Polyline
Points="0 48, 0 144, 96 150, 100 0, 192 0, 192 96, 50 96, 48 192, 150 200 144 48"
Fill="Blue"
Stroke="Red"
StrokeThickness="3" />
37. Probamos.
<Path
Aspect="Uniform"
Data="M 10,100 L 100,100 100,50Z"
HorizontalOptions="Center"
226
Stroke="Black"/>
39. Probamos.
<Border
Stroke="#C49B33"
StrokeThickness="4"
Background="#2B0B98"
Padding="16,8"
HorizontalOptions="Center">
<Border.StrokeShape>
<RoundRectangle CornerRadius="40,0,0,40" />
</Border.StrokeShape>
<Label
Text="Welcome to .NET MAUI!"
VerticalOptions="Center"
HorizontalOptions="Center"
TextColor="White"/>
</Border>
41. Probamos.
<Frame
Margin="5"
BackgroundColor="Azure"
Padding="10">
<Image Source="dotnet_bot.svg"/>
</Frame>
43. Probamos.
<WebView
HeightRequest="500"
Source="https://www.google.com/"/>
<ImageButton
Source="dotnet_bot.svg"
Clicked="btnTest_Clicked"/>
4. Probamos.
<RadioButton
CheckedChanged="RadioButton_CheckedChanged"
Content="Option 1"
GroupName="group1"/>
<RadioButton
CheckedChanged="RadioButton_CheckedChanged"
Content="Option 2"
GroupName="group1"/>
<RadioButton
CheckedChanged="RadioButton_CheckedChanged"
Content="Option 3"
GroupName="group2"/>
<RadioButton
CheckedChanged="RadioButton_CheckedChanged"
Content="Option 4"
GroupName="group2"/>
7. Probamos.
<SwipeView>
<SwipeView.LeftItems>
228
<SwipeItems>
<SwipeItem
BackgroundColor="LightGreen"
IconImageSource="dotnet_bot.svg"
Invoked="SwipeItem_Invoked"
Text="Favorite"/>
<SwipeItem
BackgroundColor="LightPink"
IconImageSource="dotnet_bot.svg"
Invoked="SwipeItem_Invoked"
Text="Delete"/>
</SwipeItems>
</SwipeView.LeftItems>
<Grid
BackgroundColor="LightGray"
HeightRequest="60"
WidthRequest="300">
<Label
HorizontalOptions="Center"
Text="Swipe Right"
VerticalOptions="Center"/>
</Grid>
</SwipeView>
3. Probamos.
229
4. Modificamos InputControlsDemo:
<Slider
x:Name="slider"
Minimum="0"
Maximum="10"
MinimumTrackColor="Yellow"
MaximumTrackColor="Green"
ThumbColor="DarkRed"
ValueChanged="slider_ValueChanged"/>
<Label
x:Name="lblSlider"/>
</VerticalStackLayout>
</ContentPage>
5. Modificamos InputControlsDemo.xaml.cs:
6. Probamos.
7. Modificamos InputControlsDemo:
<Stepper
x:Name="stepper"
ValueChanged="stepper_ValueChanged"
Maximum="10"
Minimum="2"
Increment="2"/>
8. Modificamos InputControlsDemo.xaml.cs:
9. Probamos.
230
10. Modificamos InputControlsDemo:
<Switch
IsToggled="True"/>
11. Probamos.
<DatePicker />
<TimePicker/>
<Editor
AutoSize="TextChanges"/>
</VerticalStackLayout>
</ContentPage>
2. Modificamos el TextControlsDemo.xaml.cs:
using System.Diagnostics;
namespace Sales.Mobile.ControlsDemo;
231
void Entry_Completed(object sender, EventArgs e)
{
Debug.WriteLine(txtName.Text);
}
}
232
<x:String>monodevelop</x:String>
<x:String>monotone</x:String>
<x:String>monopoly</x:String>
<x:String>monomodal</x:String>
<x:String>mononucleosis</x:String>
</x:Array>
</CarouselView.ItemsSource>
<CarouselView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Frame
Margin="20"
BorderColor="DarkGray"
CornerRadius="5"
HasShadow="True"
HeightRequest="100"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand">
<Label
Text="{Binding .}"/>
</Frame>
</StackLayout>
</DataTemplate>
</CarouselView.ItemTemplate>
</CarouselView>
<IndicatorView
x:Name="indicatorView"
HorizontalOptions="Center"
IndicatorColor="LightGray"
SelectedIndicatorColor="DarkGray"/>
</VerticalStackLayout>
</ContentPage>
<ListView
HasUnevenRows="True">
<ListView.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>mono</x:String>
<x:String>monodroid</x:String>
<x:String>monotouch</x:String>
<x:String>monorail</x:String>
<x:String>monodevelop</x:String>
<x:String>monotone</x:String>
<x:String>monopoly</x:String>
<x:String>monomodal</x:String>
<x:String>mononucleosis</x:String>
233
</x:Array>
</ListView.ItemsSource>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<StackLayout>
<Frame
Margin="20"
BorderColor="DarkGray"
CornerRadius="5"
HasShadow="True"
HeightRequest="100"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand">
<Label Text="{Binding .}" />
</Frame>
</StackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
4. Probamos.
<CollectionView SelectionMode="Multiple">
<CollectionView.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>mono</x:String>
<x:String>monodroid</x:String>
<x:String>monotouch</x:String>
<x:String>monorail</x:String>
<x:String>monodevelop</x:String>
<x:String>monotone</x:String>
<x:String>monopoly</x:String>
<x:String>monomodal</x:String>
<x:String>mononucleosis</x:String>
</x:Array>
</CollectionView.ItemsSource>
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Frame
Margin="20"
BorderColor="DarkGray"
CornerRadius="5"
HasShadow="True"
HeightRequest="100"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand">
<Label Text="{Binding .}" />
</Frame>
</StackLayout>
234
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
6. Probamos.
<StackLayout>
<Picker VerticalOptions="Center">
<Picker.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>mono</x:String>
<x:String>monodroid</x:String>
<x:String>monotouch</x:String>
<x:String>monorail</x:String>
<x:String>monodevelop</x:String>
<x:String>monotone</x:String>
<x:String>monopoly</x:String>
<x:String>monomodal</x:String>
<x:String>mononucleosis</x:String>
</x:Array>
</Picker.ItemsSource>
</Picker>
</StackLayout>
8. Probamos.
<TableView Intent="Settings">
<TableRoot>
<TableSection Title="First Section">
<TextCell Detail="TextCell Detail" Text="TextCell" />
<EntryCell Label="Entry Label" Text="EntryCell Text" />
<SwitchCell Text="SwitchCell Text" />
<ImageCell
Detail="ImageCell Detail"
ImageSource="dotnet_bot.svg"
Text="ImageCell Text" />
</TableSection>
<TableSection Title="Second Section">
<TextCell Detail="TextCell Detail" Text="TextCell" />
<EntryCell Label="Entry Label" Text="EntryCell Text" />
<SwitchCell Text="SwitchCell Text" />
<ImageCell
Detail="ImageCell Detail"
ImageSource="dotnet_bot.svg"
Text="ImageCell Text" />
</TableSection>
</TableRoot>
235
</TableView>
ACA VAMOS
DataBinding
1. En el proyecto Mobile vamos a crear una carpeta llamada BindingDemo y dentro de esta creamos la clase
Person:
using System;
namespace Sales.Mobile.BindingDemo
{
public class Person
{
public string Name { get; set; }
<Button
x:Name="btnOk"
Text="Bind"
Clicked="btnOk_Clicked"/>
</VerticalStackLayout>
</ContentPage>
3. Modificamos el BindigPage.xaml.cs:
namespace Sales.Mobile.BindingDemo;
lblName.SetBinding(Label.TextProperty, personBinding);
}
}
5. Probamos.
6. Ahora vamos a probar el binding desde el XAML, para eso modificamos el BindingDemo:
<ContentPage.Resources>
<Models:Person
x:Key="person"
Name="Juan Zuluaga"
Address="Calle Luna Calle Sol"
Phone="+57 322 311 4620"/>
</ContentPage.Resources>
<VerticalStackLayout
Padding="10"
Spacing="25"
VerticalOptions="Center">
<Label
x:Name="lblName"
FontSize="50"
HorizontalOptions="Center"
237
Text="{Binding Name, Source={StaticResource person}}"
VerticalOptions="Center"/>
<Button
x:Name="btnOk"
Text="Bind"
Clicked="btnOk_Clicked"/>
</VerticalStackLayout>
</ContentPage>
8. Probamos
.
9. Modificamos nuevamente el BindigPage:
<!--<Label
x:Name="lblName"
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Name, Source={StaticResource person}}"
VerticalOptions="Center"/>-->
<Label
x:Name="lblName"
FontSize="50"
HorizontalOptions="Center"
VerticalOptions="Center"/>
11. Probamos.
BindingContext = person;
//lblName.BindingContext = person;
//lblName.SetBinding(Label.TextProperty, "Name");
<Label
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Name}"
VerticalOptions="Center"/>
<Label
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Phone}"
VerticalOptions="Center"/>
<Label
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Address}"
VerticalOptions="Center"/>
239
14. Probamos.
15. Ahora vamos a probar el binding entre controles, creamos una nueva página llamada SliderPage:
17. Probamos.
240
MainPage = new BindingModes();
20. Probamos y jugamos con los valores del Mode para ver las diferencias.
<Entry
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Name}"
VerticalOptions="Center"/>
<Entry
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Phone}"
VerticalOptions="Center"/>
<Entry
FontSize="50"
HorizontalOptions="Center"
Text="{Binding Address}"
VerticalOptions="Center"/>
23. Probamos.
namespace Sales.Mobile.BindingDemo;
public BindigPage()
{
InitializeComponent();
BindingContext = _person;
}
25. Probamos y vemos que las cosas no funcionan como lo esperabamos, para que funcione tenemos que
implementar el INotifyPropertyChanged en la clase Person:
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Sales.Mobile.BindingDemo
{
public class Person : INotifyPropertyChanged
{
private string _name;
private string _phone;
private string _address;
El patrón MVVM
1. Creamos dentro del proyecto Mobile la carpeta MVVM y dentro de esta las carpetas Models, Views y
ViewModels.
using System;
namespace Sales.Mobile.MVVM.Models
{
public class Person
{
public string Name { get; set; }
using Sales.Mobile.MVVM.Models;
namespace Sales.Mobile.MVVM.Views;
243
{
InitializeComponent();
var person = new Person
{
Name = "Juan",
Age = 48
};
BindingContext = person;
}
}
6. Probamos.
7. Pero no debemos permitir que la vista acceda directamente el modelo, por eso creamos en la carpeta de los
ViewModels nuestro PesonViewModel:
8. Probamos.
using System;
using Sales.Mobile.MVVM.Models;
namespace Sales.Mobile.MVVM.ViewModels
{
public class PersonViewModel
{
public PersonViewModel()
{
Person = new Person
{
Name = "Juan",
Age = 48
};
}
244
public Person Person { get; set; }
}
}
9. Modificamos el PersonView.xaml.cs:
using Sales.Mobile.MVVM.ViewModels;
namespace Sales.Mobile.MVVM.Views;
11. Probamos.
using System;
namespace Sales.Mobile.MVVM.Models
{
public class Person
{
public string Name { get; set; }
15. Probamos.
246
16. Dentro de MVVM/Views creamos una nueva página de contenido llamada PeopleView:
18. Probamos
19. Pero ahora vamos a meterle una buena arquitectura a esto. Creamos la PeopleViewModel:
using System;
namespace Sales.Mobile.MVVM.ViewModels
{
public class PeopleViewModel
{
247
public PeopleViewModel()
{
People = new List<string>()
{
"Juan",
"Ledys",
"Valery",
"Ronal",
"Geralin",
"Benedict",
"Isis",
"Gaia",
"Toño"
};
}
namespace Sales.Mobile.MVVM.Views;
using Sales.Mobile.MVVM.ViewModels;
22. Probamos.
using System;
using Sales.Mobile.MVVM.Models;
namespace Sales.Mobile.MVVM.ViewModels
{
public class PeopleViewModel
{
public PeopleViewModel()
{
People = new List<Person>()
{
new Person { Name = "Juan", Age = 48, BirthDate = new DateTime(1974, 9, 23), LunchTime = new
TimeSpan(12, 0,0), Married = true, Weight = 89 },
new Person { Name = "Ledys", Age = 42, BirthDate = new DateTime(1981, 1, 11), LunchTime = new
TimeSpan(13, 0,0), Married = true, Weight = 56 },
new Person { Name = "Valery", Age = 12, BirthDate = new DateTime(2010, 2, 27), LunchTime = new
TimeSpan(12, 30,0), Married = false, Weight = 38 },
new Person { Name = "Ronal", Age = 23, BirthDate = new DateTime(2000, 1, 20), LunchTime = new
TimeSpan(14, 0,0), Married = false, Weight = 47 },
};
}
25. Probamos.
26. Dentro de MVVM creamos la carpeta Converters y dentro de esta la clase BoolConverter:
using System;
using System.Globalization;
namespace Sales.Mobile.MVVM.Converters
{
public class BoolConverter : IValueConverter
{
public BoolConverter()
{
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var boolValue = (bool)value;
if (boolValue)
{
return "Sí";
}
return "No";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
var stringValue = value.ToString();
if (stringValue == "Sí")
{
return true;
250
}
return false;
}
}
}
<CollectionView
ItemsSource="{Binding People}"
SelectionMode="Multiple">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Frame
Margin="20"
BorderColor="DarkGray"
CornerRadius="5"
HasShadow="True"
HeightRequest="110"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand">
<VerticalStackLayout>
<Label
Text="{Binding Name}"
FontAttributes="Bold"
FontSize="Large" />
<Label
Text="{Binding BirthDate, StringFormat='{0:yyy/MM/dd}'}" />
<Label
Text="{Binding Married, Converter={StaticResource boolConverter}, StringFormat='Casado: {0}'}" />
</VerticalStackLayout>
</Frame>
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage>
El uso de comandos
1. Creamos el CommandsViewModel:
251
using System;
using System.Windows.Input;
namespace Sales.Mobile.MVVM.ViewModels
{
public class CommandsViewModel
{
public CommandsViewModel()
{
}
2. Creamos el CommandsView:
3. Modificamos el CommandsView.xaml.cs:
namespace Sales.Mobile.MVVM.Views;
using Sales.Mobile.MVVM.ViewModels;
5. Modificamos el CommandsView:
252
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Sales.Mobile.MVVM.Views.CommandsView"
Title="CommandsView">
<VerticalStackLayout
VerticalOptions="Center"
HorizontalOptions="Center" >
<Button
Text="Click Me!"
Command="{Binding ClickCommand}"/>
<SearchBar
Text="{Binding SearchTerm}"
SearchCommand="{Binding SearchCommand}"/>
</VerticalStackLayout>
</ContentPage>
6. Modificamos el CommandsViewModel:
using System;
using System.Windows.Input;
namespace Sales.Mobile.MVVM.ViewModels
{
public class CommandsViewModel
{
public CommandsViewModel()
{
}
using System;
using System.Windows.Input;
namespace Sales.Mobile.MVVM.ViewModels
{
public class DemoAutoPropertyChangedViewModel
{
public int Number1 { get; set; }
253
public int Number2 { get; set; }
public ICommand AddCommand => new Command(() => Result = Number1 + Number2);
}
}
2. Creamos el DemoAutoPropertyChangedView:
3. Modificamos el DemoAutoPropertyChangedView.xaml.cs:
namespace Sales.Mobile.MVVM.Views;
using Sales.Mobile.MVVM.ViewModels;
using System;
using System.Windows.Input;
using PropertyChanged;
namespace Sales.Mobile.MVVM.ViewModels
{
[AddINotifyPropertyChangedInterface]
public class DemoAutoPropertyChangedViewModel
{
public int Number1 { get; set; }
public ICommand AddCommand => new Command(() => Result = Number1 + Number2);
}
}
3. Probamos.
5. Probamos.
6. Vamos cortar los colores y estilos que pusimos en el StyleDemoView.xaml y los vamos a pasar al App.xaml:
<ContentPage.Resources>
</ContentPage.Resources>
<VerticalStackLayout>
<Button
Text="Login"
Style="{StaticResource primaryButton}"/>
<Button
Text="Visit WebSite"
Style="{StaticResource secondaryButton}"/>
</VerticalStackLayout>
</ContentPage>
7. Probamos.
9. Probamos.
<Style TargetType="VerticalStackLayout">
<Setter Property="VerticalOptions" Value="Center"/>
<Setter Property="Spacing" Value="5"/>
<Setter Property="Padding" Value="10"/>
</Style>
<Style
TargetType="Button"
x:Key="primaryButton"
BasedOn="{StaticResource baseButton}">
<Setter Property="BackgroundColor" Value="{StaticResource bgColor}"/>
</Style>
<Style
TargetType="Button"
x:Key="secondaryButton"
BasedOn="{StaticResource baseButton}">
<Setter Property="BackgroundColor" Value="{StaticResource bg2Color}"/>
<Setter Property="TextColor" Value="Black"/>
</Style>
</ResourceDictionary>
<Color x:Key="bgColor">#323031</Color>
<Color x:Key="bg2Color">#45f03a</Color>
<Color x:Key="textColor">#ffc857</Color>
</ResourceDictionary>
13. Probamos.
14. Para no perdernos entre tantos colores podemos usar la página: https://color.adobe.com/es/create/color-wheel
CollectionView
259
260
261
1. Vamos a crear un nuevo proyecto llamado CollectionViewDemo.
2. Creamos la carpeta MVVM y dentro de esta creamos la carpeta Views y dentro de escra creamos el DataView:
262
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
3. Dentro la carpeta MVVM y dentro de esta creamos la carpeta ViewModels y dentro de escra creamos el
DataViewModel:
using System;
namespace CollectionViewDemo.MVVM.ViewModels
{
public class DataViewModel
{
}
}
4. Modificamos el DataView.xaml.cs:
using CollectionViewDemo.MVVM.ViewModels;
namespace CollectionViewDemo.MVVM.Views;
6. Probamos.
using System;
namespace CollectionViewDemo.MVVM.Models
{
public class Product
{
public string Name { get; set; }
263
public decimal OfferPrice { get; set; }
}
}
8. Modificamos el DataViewModel:
using System;
using System.Collections.ObjectModel;
using CollectionViewDemo.MVVM.Models;
namespace CollectionViewDemo.MVVM.ViewModels
{
public class DataViewModel
{
public DataViewModel()
{
Products = new()
{
new Product
{
Name = "Yogurt",
Price = 60.0m,
Image = "yogurt.png",
HasOffer = false,
Stock = 28
},
new Product
{
Name = "Watermelon",
Price = 30.0m,
Image = "watermelon.png",
HasOffer = false,
Stock = 87
},
new Product
{
Name = "Water Bottle",
Price = 80.0m,
Image = "water_bottle.png",
HasOffer = true,
OfferPrice = 69.99m,
Stock = 33
},
new Product
{
Name = "Tomato",
Price = 120.0m,
Image = "tomato.png",
HasOffer = false,
Stock = 0
},
new Product
{
Name = "Tea",
264
Price = 65.0m,
Image = "tea_bag.png",
HasOffer = false,
Stock = 82
},
new Product
{
Name = "Sparkling Drink",
Price = 35.0m,
Image = "sparkling_drink.png",
HasOffer = false,
Stock = 728
},
new Product
{
Name = "Spaguetti",
Price = 15.0m,
Image = "spaguetti.png",
HasOffer = false,
Stock = 0
},
new Product
{
Name = "Cream",
Price = 48.0m,
Image = "cream.png",
HasOffer = false,
Stock = 22
},
new Product
{
Name = "Snack",
Price = 25.0m,
Image = "009_snack.png",
HasOffer = false,
Stock = 2
},
new Product
{
Name = "Shrimp",
Price = 300.0m,
Image = "shrimp.png",
HasOffer = true,
OfferPrice = 250.0m,
Stock = 58
},
new Product
{
Name = "Seasoning",
Price = 185.0m,
Image = "seasoning.png",
HasOffer = false,
Stock = 99
},
265
new Product
{
Name = "Sauce",
Price = 220.0m,
Image = "sauce.png",
HasOffer = false,
Stock = 72
},
new Product
{
Name = "Rice",
Price = 48.0m,
Image = "rice.png",
HasOffer = false,
Stock = 143
},
new Product
{
Name = "Peas",
Price = 114.0m,
Image = "peas.png",
HasOffer = false,
Stock = 0
},
new Product
{
Name = "Ham",
Price = 215.0m,
Image = "ham_1.png",
HasOffer = true,
OfferPrice = 189.0m,
Stock = 732
},
new Product
{
Name = "Chicken Leg",
Price = 142.0m,
Image = "chicken_leg.png",
HasOffer = true,
OfferPrice = 125.0m,
Stock = 20
},
new Product
{
Name = "Pizza",
Price = 321.0m,
Image = "pizza.png",
HasOffer = false,
Stock = 559
},
new Product
{
Name = "Pineapple",
Price = 49.0m,
266
Image = "pineapple.png",
HasOffer = false,
Stock = 41
},
new Product
{
Name = "Pepper",
Price = 60.0m,
Image = "pepper.png",
HasOffer = true,
OfferPrice = 30.0m,
Stock = 64
},
new Product
{
Name = "Pasta",
Price = 52.0m,
Image = "pasta.png",
HasOffer = false,
Stock = 0
},
new Product
{
Name = "Oil Bottle",
Price = 152.0m,
Image = "oil_bottle",
HasOffer = false,
Stock = 87
},
new Product
{
Name = "Mushroom",
Price = 28.0m,
Image = "mushroom.png",
HasOffer = false,
Stock = 17
},
new Product
{
Name = "Milk Bottle",
Price = 85.0m,
Image = "milk_bottle.png",
HasOffer = false,
Stock = 39
},
new Product
{
Name = "Meat",
Price = 450.0m,
Image = "meat.png",
HasOffer = false,
Stock = 28
},
new Product
267
{
Name = "Lemon",
Price = 20.0m,
Image = "lemon.png",
HasOffer = false,
Stock = 87
},
new Product
{
Name = "Tomato Sauce",
Price = 15.0m,
Image = "tomato_sauce.png",
HasOffer = false,
Stock = 26
},
new Product
{
Name = "Juice",
Price = 60.0m,
Image = "juice.png",
HasOffer = false,
Stock = 31
},
new Product
{
Name = "Ice Cream",
Price = 251.0m,
Image = "ice_cream.png",
HasOffer = true,
OfferPrice = 200.0m,
Stock = 88
},
new Product
{
Name = "Ham",
Price = 290.0m,
Image = "ham.png",
HasOffer = false,
Stock = 0
},
new Product
{
Name = "Ice",
Price = 125.0m,
Image = "ice.png",
HasOffer = false,
Stock = 22
},
new Product
{
Name = "Flour",
Price = 86.0m,
Image = "flour.png",
HasOffer = false,
268
Stock = 28
},
new Product
{
Name = "Fish",
Price = 440.0m,
Image = "fish_1.png",
HasOffer = false,
Stock = 80
},
new Product
{
Name = "Fish 2",
Price = 425.0m,
Image = "fish.png",
HasOffer = false,
Stock = 24
},
new Product
{
Name = "Eggs",
Price = 150.0m,
Image = "eggs.png",
HasOffer = false,
Stock = 47
},
new Product
{
Name = "Cucumber",
Price = 35.0m,
Image = "cucumber.png",
HasOffer = false,
Stock = 74
},
new Product
{
Name = "Croissant",
Price = 68.0m,
Image = "croissant.png",
HasOffer = true,
OfferPrice = 50.0m,
Stock = 27
},
new Product
{
Name = "Cookies",
Price = 95.0m,
Image = "cookie.png",
HasOffer = false,
Stock = 56
},
new Product
{
Name = "Coffee",
269
Price = 154.0m,
Image = "toffee.png",
HasOffer = false,
Stock = 83
},
new Product
{
Name = "Chocolate Bar",
Price = 32.0m,
Image = "chocolate_bar.png",
HasOffer = false,
Stock = 21
},
new Product
{
Name = "Cheese",
Price = 36.0m,
Image = "cheese.png",
HasOffer = true,
OfferPrice = 25.0m,
Stock =73
},
new Product
{
Name = "Carrot",
Price = 15.0m,
Image = "carrot.png",
HasOffer = false,
Stock = 28
},
new Product
{
Name = "Canned Food",
Price = 89.0m,
Image = "canned_food.png",
HasOffer = false,
Stock = 773
},
new Product
{
Name = "Soda",
Price = 45.0m,
Image = "can.png",
HasOffer = false,
Stock = 843
},
new Product
{
Name = "Candies",
Price = 55.0m,
Image = "candy.png",
HasOffer = false,
Stock = 71
},
270
new Product
{
Name = "Cake",
Price = 250.0m,
Image = "cake.png",
HasOffer = true,
OfferPrice = 200.0m,
Stock = 0
},
new Product
{
Name = "Bread",
Price = 100.0m,
Image = "bread_1.png",
HasOffer = false,
Stock =134
},
new Product
{
Name = "Bread",
Price = 85.0m,
Image = "bread.png",
HasOffer = false,
Stock = 8
},
new Product
{
Name = "Banana",
Price = 15.0m,
Image = "banana.png",
HasOffer = true,
OfferPrice = 10.0m,
Stock = 72
},
new Product
{
Name = "Apple",
Price = 40.0m,
Image = "apple.png",
HasOffer = false,
Stock = 737
},
new Product
{
Name = "Alcohol",
Price = 370.0m,
Image = "alcohol.png",
HasOffer = false,
Stock = 9
},
};
}
9. Cambiemos el DataView:
10. Probamos.
12. Probamos.
<Label
Grid.Column="2"
FontSize="Large"
Text="{Binding Name}"
VerticalOptions="Center"/>
<Label
Grid.Column="2"
Grid.Row="1"
FontSize="Large"
Text="{Binding Price, StringFormat='{0:C}'}"
VerticalOptions="Center"/>
15. Probamos.
274
19. Probamos.
20. Ahora vamos a mostrar un diseño diferente para los productos que se encuentran en oferta. Para eso vamos
utilizar un Data Template Selector.
using System;
using CollectionViewDemo.MVVM.Models;
namespace CollectionViewDemo.Selectors
{
public class ProductDataTemplateSelector : DataTemplateSelector
{
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
{
var product = item as Product;
if (!product.HasOffer)
{
Application.Current.Resources.TryGetValue("ProductStyle", out var productStyle);
return productStyle as DataTemplate;
}
return new DataTemplate();
}
}
}
23. Probamos.
24. Ahora adicionemos el estilo para los productos en oferta, modificando el CollectionViewDictionary:
…
</DataTemplate>
<DataTemplate x:Key="OfferStyle">
<Grid
Margin="15,10,15,0"
275
HeightRequest="200"
ColumnDefinitions=".3*,.7*"
RowDefinitions="*,*">
<Frame
Grid.RowSpan="2"
Grid.ColumnSpan="2"
BorderColor="White">
<Frame.Background>
<LinearGradientBrush EndPoint="1,0">
<GradientStop Offset="0" Color="Yellow"/>
<GradientStop Offset="1" Color="#eeb54c"/>
</LinearGradientBrush>
</Frame.Background>
</Frame>
<Image
Grid.RowSpan="2"
HeightRequest="100"
Source="{Binding Image}"/>
<Label
Grid.Column="2"
FontSize="Title"
FontAttributes="Bold"
TextColor="White"
Text="{Binding Name, StringFormat='OFFER: {0}'}"
VerticalOptions="Center"/>
<Label
Grid.Column="2"
Grid.Row="1"
FontSize="Title"
FontAttributes="Bold"
TextColor="White"
Text="{Binding Price, StringFormat='{0:C}'}"
VerticalOptions="Center">
<Label.FormattedText>
<FormattedString>
<Span
Text="{Binding Price, StringFormat='{0:C}'}"
TextDecorations="Strikethrough"
TextColor="DarkRed"/>
<Span
Text="{Binding OfferPrice, StringFormat=' => {0:C}'}"/>
</FormattedString>
</Label.FormattedText>
</Label>
</Grid>
</DataTemplate>
</ResourceDictionary>
using System;
using CollectionViewDemo.MVVM.Models;
namespace CollectionViewDemo.Selectors
{
276
public class ProductDataTemplateSelector : DataTemplateSelector
{
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
{
var product = item as Product;
if (!product.HasOffer)
{
Application.Current.Resources.TryGetValue("ProductStyle", out var productStyle);
return productStyle as DataTemplate;
}
26. Probamos.
<ContentPage.Resources>
<DataTemplates:ProductDataTemplateSelector x:Key="ProductTemplates"/>
</ContentPage.Resources>
<RefreshView
Command="{Binding RefreshCommand}"
IsRefreshing="{Binding IsRefreshing}">
<CollectionView
ItemsSource="{Binding Products}"
ItemTemplate="{StaticResource ProductTemplates}">
</CollectionView>
</RefreshView>
</ContentPage>
using System;
using System.Collections.ObjectModel;
using System.Windows.Input;
using CollectionViewDemo.MVVM.Models;
namespace CollectionViewDemo.MVVM.ViewModels
277
{
[AddINotifyPropertyChangedInterface]
public class DataViewModel
{
public DataViewModel()
{
RefreshItems();
}
31. Probamos.
using System;
using System.Collections.ObjectModel;
using System.Windows.Input;
using CollectionViewDemo.MVVM.Models;
using PropertyChanged;
namespace CollectionViewDemo.MVVM.ViewModels
{
[AddINotifyPropertyChangedInterface]
public class DataViewModel
{
public DataViewModel()
{
RefreshItems();
}
<RefreshView
Command="{Binding RefreshCommand}"
IsRefreshing="{Binding IsRefreshing}">
<CollectionView
ItemsSource="{Binding Products}"
ItemTemplate="{StaticResource ProductTemplates}"
RemainingItemsThreshold="1"
RemainingItemsThresholdReachedCommand="{Binding ThresholdReachedCommand}">
</CollectionView>
</RefreshView>
294
35. Probamos.
…
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:ViewModels="clr-namespace:CollectionViewDemo.MVVM.ViewModels">
<DataTemplate x:Key="ProductStyle">
<SwipeView>
<SwipeView.LeftItems>
<SwipeItems>
<SwipeItem
BackgroundColor="DarkRed"
Command="{Binding Source={RelativeSource AncestorType={x:Type ViewModels:DataViewModel}},
Path=DeleteCommand}"
CommandParameter="{Binding}"
IconImageSource="trash.png"/>
</SwipeItems>
</SwipeView.LeftItems>
<Grid
…
39. Ahora vamos a ver diferentes tipos de vistas que podemos usar con nuestro CollectionView.
using CollectionViewDemo.MVVM.ViewModels;
namespace CollectionViewDemo.MVVM.Views;
43. Probamos.
<CollectionView
ItemsSource="{Binding Products}"
ItemsLayout="HorizontalList">
<CollectionView.ItemTemplate>
45. Probamos.
<CollectionView
ItemsSource="{Binding Products}">
<CollectionView.ItemsLayout>
<LinearItemsLayout
ItemSpacing="50"
Orientation="Horizontal"/>
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
47. Probamos.
296
49. Cambiamos por:
<CollectionView
ItemsSource="{Binding Products}">
<CollectionView.ItemsLayout>
<GridItemsLayout
Span="2"
Orientation="Horizontal"/>
</CollectionView.ItemsLayout>
50. Probamos y jugamos con las propiedades de Orientation y quitarle los tamaños al Frame.
…
<CollectionView
ItemsSource="{Binding Products}"
Header="Products"
Footer="End of list">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical"/>
</CollectionView.ItemsLayout>
…
53. Probamos.
…
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.Header>
<Frame BackgroundColor="{StaticResource Primary}">
<Label
FontAttributes="Bold"
Text="Products"
TextColor="White"/>
</Frame>
</CollectionView.Header>
<CollectionView.Footer>
<HorizontalStackLayout>
<Label FontSize="Title">
<Label.FormattedText>
<FormattedString>
<Span
Text="Powered by: "
TextColor="{StaticResource Tertiary}"/>
<Span
Text=".NET MAUI"
TextColor="{StaticResource Primary}"/>
</FormattedString>
</Label.FormattedText>
</Label>
297
</HorizontalStackLayout>
</CollectionView.Footer>
<CollectionView.ItemsLayout>
…
55. Probamos.
56. Ahora vamos a ver como seleccionamos elementos de la lista. Modificamos el LayoutsPage:
…
<CollectionView
ItemsSource="{Binding Products}"
SelectionMode="Single"
SelectedItem="{Binding SelectedProduct}"
SelectionChangedCommand="{Binding ProductChangedCommand}">
…
…
public Product SelectedProduct { get; set; }
58. Probamos.
<CollectionView.EmptyView>
<VerticalStackLayout
HorizontalOptions="Center"
VerticalOptions="Center"
Spacing="20">
<Image
HeightRequest="150"
Source="notfound.png"/>
<Label
FontAttributes="Bold"
FontSize="Title"
Text="No data found."/>
</VerticalStackLayout>
</CollectionView.EmptyView>
public DataViewModel()
{
298
//RefreshItems();
}
62. Probamos.
public DataViewModel()
{
RefreshItems();
}
64. Probamos.
Consumir APIs
1. Para esta demostración vamos a usar: https://mockapi.io/
4. Creamos el MainViewModel:
namespace RESTDemo.MVVM.ViewModels
{
public class MainViewModel
{
}
}
5. Creamos el MainView:
public MainView()
{
InitializeComponent();
BindingContext = new MainViewModel();
}
7. Modificamos el MainView:
8. Modificamos el MainViewModel:
using System.Text.Json;
using System.Windows.Input;
namespace RESTDemo.MVVM.ViewModels
{
public class MainViewModel
{
private readonly HttpClient _client;
private readonly JsonSerializerOptions _serializerOptions;
private readonly string _baseUrl;
public MainViewModel()
{
_client = new HttpClient();
_baseUrl = "https://643c164570ea0e6602a1163e.mockapi.io";
_serializerOptions = new JsonSerializerOptions
{
WriteIndented= true,
PropertyNameCaseInsensitive = true,
};
}
public App()
{
InitializeComponent();
MainPage = new NavigationPage(new MainView());
}
10. Probamos.
11. Generamos un error y vemos que no es la mejor manera de consumir una API, cambiamos nuestro código por:
12. Probamos.
13. Pero para mejorar aun más la cosa, vamos a crear el modelo User:
namespace RESTDemo.MVVM.Modes
{
public class User
{
public DateTime CreatedAt { get; set; }
15. Probamos.
<Button
Command="{Binding GetAllUsersCommand}"
Text="Get All Users"/>
<Button
Command="{Binding GetSingleUserCommand}"
Margin="0,5,0,0"
Text="Get Gingle User"/>
18. Probamos.
<Button
Command="{Binding AddUserCommand}"
Margin="0,5,0,0"
Text="Add User"/>
302
21. Modificamos el MainViewModel:
22. Probamos.
SQLite
303
1. Creamos un nuevo proyecto llamado SQLiteDemo.
using SQLite;
namespace SQLiteDemo
{
public static class Constants
{
private const string dbFileName = "SQLite.db3";
}
}
using SQLite;
namespace SQLiteDemo.MVVM.Models
{
[Table("Customers")]
public class Customer
{
304
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(20), Unique]
public string Phone { get; set; }
[MaxLength(100)]
public string Address { get; set; }
}
}
using SQLite;
using SQLiteDemo.MVVM.Models;
namespace SQLiteDemo.Repository
{
public class CustomerRepository
{
private readonly SQLiteConnection _connection;
public CustomerRepository()
{
_connection = new SQLiteConnection(Constants.DatabasePath, Constants.Flags);
_connection.CreateTable<Customer>();
}
7. Modificamos el App.xaml.cs:
using SQLiteDemo.Repository;
namespace SQLiteDemo;
8. Modificamos el MauiProgram:
builder.Services.AddSingleton<CustomerRepository>();
return builder.Build();
</CollectionView>
</Grid>
</ContentPage>
307
12. Y al tratar de correr, obenermos un error. Debemos instalar el paquete:
SQLitePCLRaw.provider.dynamic_cdecl:
13. Probamos.
using SQLiteDemo.MVVM.Models;
namespace SQLiteDemo.MVVM.ViewModels
{
public class MainPageViewModel
{
public List<Customer> Customers { get; set; }
</CollectionView>
</Grid>
</ContentPage>
17. Probamos.
308
20. Modificamos el MainPageViewModel:
using Bogus;
using PropertyChanged;
using SQLiteDemo.MVVM.Models;
using System.Windows.Input;
namespace SQLiteDemo.MVVM.ViewModels
{
[AddINotifyPropertyChangedInterface]
public class MainPageViewModel
{
public MainPageViewModel()
{
GenerateNewCustomer();
}
</CollectionView>
</Grid>
</ContentPage>
22. Probamos.
public MainPageViewModel()
{
Refresh();
GenerateNewCustomer();
}
24. Probamos.
<CollectionView
ItemsSource="{Binding Customers}"
310
Grid.Row="1">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,*">
<Label
Text="{Binding Name}"/>
<Label
Grid.Column="1"
Text="{Binding Address}"/>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
26. Probamos.
27. Ahora vamos a actualizar los registros de una forma muy simple, actualizamos el MainPage:
…
<CollectionView
ItemsSource="{Binding Customers}"
SelectionMode="Single"
SelectedItem="{Binding CurrentCustomer}"
Grid.Row="1">
…
28. Probamos.
…
xmlns:local="clr-namespace:SQLiteDemo.MVVM.ViewModels"
…
<CollectionView.ItemTemplate>
<DataTemplate>
<SwipeView>
<SwipeView.LeftItems>
<SwipeItems>
<SwipeItem
Command="{Binding Source={RelativeSource AncestorType={x:Type
local:MainPageViewModel}}, Path=DeleteCommand}"
Text="Delete"
BackgroundColor="Purple"/>
</SwipeItems>
</SwipeView.LeftItems>
<VerticalStackLayout
HeightRequest="30"
Margin="0,0,0,5">
<Grid ColumnDefinitions="*,*">
<Label
Text="{Binding Name}"/>
<Label
Grid.Column="1"
Text="{Binding Address}"/>
311
</Grid>
</VerticalStackLayout>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
31. Probamos.
using SQLite;
namespace SQLiteDemo.Abstractions
{
public class TableData
{
312
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
}
}
using SQLite;
using SQLiteDemo.Abstractions;
namespace SQLiteDemo.MVVM.Models
{
[Table("Customers")]
public class Customer : TableData
{
[Indexed, MaxLength(100), NotNull]
public string Name { get; set; }
[MaxLength(20), Unique]
public string Phone { get; set; }
[MaxLength(100)]
public string Address { get; set; }
}
}
using SQLiteDemo.Abstractions;
namespace SQLiteDemo.MVVM.Models
{
public class Order : TableData
{
public int CustomerId { get; set; }
using System.Linq.Expressions;
namespace SQLiteDemo.Abstractions
{
public interface IBaseRepository<T> : IDisposable where T : TableData, new()
{
void SaveItem(T item);
T GetItem(int id);
313
T GetItem(Expression<Func<T, bool>> predicate);
List<T> GetItems();
5. Creamos el BaseRepository:
using SQLite;
using SQLiteDemo.Abstractions;
using System.Linq.Expressions;
namespace SQLiteDemo.Repository
{
public class BaseRepository<T> : IBaseRepository<T> where T : TableData, new()
{
private readonly SQLiteConnection _connection;
public BaseRepository()
{
_connection = new SQLiteConnection(Constants.DatabasePath, Constants.Flags);
_connection.CreateTable<T>();
}
6. Modificamos el App:
using SQLiteDemo.MVVM.Models;
using SQLiteDemo.MVVM.Views;
using SQLiteDemo.Repository;
namespace SQLiteDemo;
7. Modificamos el MauiProgram:
builder.Services.AddSingleton<BaseRepository<Customer>>();
builder.Services.AddSingleton<BaseRepository<Order>>();
return builder.Build();
8. Borramos el CustomerRepository.
9. Probamos.