Créer une application web avec ASP.NET Razor Pages et EF Core

Créer une application web avec ASP.NET Core Razor Pages et Entity Framework Core

À propos de ce tutoriel

Je vous explique comment créer une application web avec ASP.NET Razor Pages et Entity Framework Core avec une mise en application concrète.

1. Créer les modèles de données

Models/Fruit.cs

public class Fruit
{
    public int Id { get; set; }

    [StringLength(60, MinimumLength = 3)]
    [Required]
    [Display(Name = "Nom")]
    public string Name { get; set; }

    public string Description { get; set; } = string.Empty;

    public virtual Image Image { get; set; }

    [Range(1, 100)]
    [DataType(DataType.Currency)]
    [Column(TypeName = "decimal(18, 2)")]
    [Required]
    [Display(Name = "Prix")]
    public decimal Price { get; set; } = decimal.One;
}

Models/Image.cs

public class Image
{
    public int Id { get; set; }

    public string Name { get; set; }

    public string Path { get; set; }

    [NotMapped]
    [DisplayName("Image")]
    public IFormFile File { get; set; }
    
    public int FruitId { get; set; }
    public Fruit Fruit { get; set; }
}

2. Générer automatiquement un modèle de CRUD

  • Ajouter le package Microsoft.EntityFrameworkCore.Design
  • Créer la structure de dossier suivante : Areas/Fruits/Pages
  • Faites un clique-droit sur Pages => Nouvel élément généré automatiquement
  • Choisissez Page Razor => Page Razor avec Entity Framework (CRUD)
  • Renseignez la classe de modèle (Fruit), la classe de contexte de donnés et utilisez une page de disposition

3. Créer le schéma de la base de données

a) Modifier le contexte de la base de données

public DbSet<Fruit> Fruits { get; set; }
public DbSet<Image> Images { get; set; }

b) Exécutez les commandes suivantes

Add-Migration InitialCreate
Update-Database

La première commande « Add-Migration » permet de générer le script SQL nécessaire à la création de nos entités en base de données.

La seconde applique le script SQL et crée les tables correspondantes.

4. La liste des fruits

@page
@model MyFruits.Areas.Fruits.Pages.IndexModel

@{
    ViewData["Title"] = "Index";
}

<h1>Index</h1>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Fruit[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Fruit[0].Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Fruit[0].Image.File)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Fruit[0].Price)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Fruit)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Description)
                </td>
                <td>
                    @if (null != item.Image)
                    {
                        <img src="@Url.Content(item.Image.Path)" width="128" height="128">
                    }
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Price)
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.Id">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.Id">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.Id">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>
#nullable disable
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MyFruits.Data;
using MyFruits.Models;

namespace MyFruits.Areas.Fruits.Pages
{
    public class IndexModel : PageModel
    {
        private readonly ApplicationDbContext ctx;

        public IndexModel(ApplicationDbContext ctx)
        {
            this.ctx = ctx;
        }

        public IList<Fruit> Fruit { get;set; }

        public async Task OnGetAsync()
        {
            Fruit = await ctx.Fruits.Include(f => f.Image).ToListAsync();
        }
    }
}

5. Ajout d’une option globale pour le dossier d’uploads

{
	"Path": {
	    "FruitsImages":  "fruits/images"
	  }
}
public class PathOptions
{
    public const string Path = "Path";

    public string FruitsImages{ get; set; } = string.Empty;
}

6. Ajout de l’upload d’images

public class PathService
{
    private readonly IConfiguration configuration;
    private readonly IWebHostEnvironment env;

    public PathService(IWebHostEnvironment env, IConfiguration configuration)
    {
        this.configuration = configuration;
        this.env = env;
    }

    public string GetUploadsPath(string filename = null, bool withWebRootPath = true)
    {
        var pathOptions = new PathOptions();

        configuration.GetSection(PathOptions.Path).Bind(pathOptions);

        var uploadsPath = pathOptions.FruitsImages;

        if (null != filename)
            uploadsPath = Path.Combine(uploadsPath, filename);

        return withWebRootPath ? Path.Combine(env.WebRootPath, uploadsPath) : uploadsPath;
    }
}
public class ImageService
{
    private readonly PathService pathService;

    public ImageService(PathService pathService)
    {
        this.pathService = pathService;
    }

    public async Task<Image> UploadAsync(Image image)
    {
        var uploadsPath = pathService.GetUploadsPath();

        var imageFile = image.File;
        var imageFileName = GetRandomFileName(imageFile.FileName);
        var imageUploadPath = Path.Combine(uploadsPath, imageFileName);

        using (var fs = new FileStream(imageUploadPath, FileMode.Create))
        {
            await imageFile.CopyToAsync(fs);
        }

        image.Name = imageFile.FileName;
        image.Path = pathService.GetUploadsPath(imageFileName, withWebRootPath: false);

        return image;
    }

    public void DeleteFile(Image? image)
    {
        if (image == null)
            return;

        var imagePath = pathService.GetUploadsPath(Path.GetFileName(image.Path));

        if (File.Exists(imagePath))
            File.Delete(imagePath);
    }

    private static string GetRandomFileName(string path)
    {
        return Guid.NewGuid() + Path.GetExtension(path);
    }
}

7. La page de création d’un fruit

@model TutoWebApp.Models.Fruit

@{
    ViewData.TemplateInfo.HtmlFieldPrefix = "Fruit";
}

<div class="form-group">
    <div class="mb-2">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
</div>

<div class="form-group">
    <div class="mb-2">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description" class="form-control"></textarea>
        <span asp-validation-for="Description" class="text-danger"></span>
    </div>
</div>

<div class="form-group">
    <div class="mb-2">
        <label asp-for="Image.File" class="form-label"></label>

        @if (null != @Model.Image)
        {
            <span>(<a href="@Url.Content($"~/{@Model.Image.Path}")" target="_blank">@Model.Image.Name</a>)</span>
        }

        <input class="form-control" type="file" asp-for="Image.File" />
        <span asp-validation-for="Image.File" class="text-danger"></span>
    </div>
</div>

<div class="form-group">
    <div class="mb-2">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price" class="text-danger"></span>
    </div>
</div>
@page
@model TutoWebApp.Pages.Fruits.CreateModel

@{
    ViewData["Title"] = "Ajouter un fruit";
}

<h2>Ajouter un fruit</h2>

<hr />

<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">

            <partial name="~/Pages/Shared/EditorTemplates/Fruit.cshtml" model="@Model.Fruit" />

            <div class="form-group mt-2">
                <input type="submit" value="Créer" class="btn btn-primary" />
                 <a class="btn btn-success" asp-page="./Index">Retour à la liste</a>
            </div>

        </form>
    </div>
</div>
public class CreateModel : PageModel
{
    private readonly TutoWebAppContext ctx;
    private readonly ImageService imageService;

    public CreateModel(TutoWebAppContext context, ImageService imageService)
    {
        ctx = context;
        this.imageService = imageService;
    }

    public IActionResult OnGet()
    {
        return Page();
    }

    [BindProperty]
    public Fruit Fruit { get; set; } = new();

    public async Task<IActionResult> OnPostAsync()
    {
        var emptyFruit = new Fruit();

        if (null != Fruit.Image)
            emptyFruit.Image = await imageService.UploadAsync(Fruit.Image);

        if (await TryUpdateModelAsync(emptyFruit, "fruit", f => f.Name, f => f.Description, f => f.Price))
        {
            ctx.Fruits.Add(emptyFruit);
            await ctx.SaveChangesAsync();
            return RedirectToPage("./Index");
        }

        return Page();
    }
}

8. La page d’édition d’un fruit

@page
@model MyFruits.Areas.Fruits.Pages.EditModel

@{
    ViewData["Title"] = "Edit";
}

<h1>Edit</h1>

<h4>Fruit</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">
            <partial name="~/Pages/Shared/EditorTemplates/Fruit.cshtml" model="@Model.Fruit" />

            <div class="form-group mt-2">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>

        </form>
    </div>
</div>

<div>
    <a asp-page="./Index">Back to List</a>
</div>
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using MyFruits.Data;
using MyFruits.Models;
using MyFruits.Services;

namespace MyFruits.Areas.Fruits.Pages
{
    public class EditModel : PageModel
    {
        private readonly ApplicationDbContext ctx;
        private readonly ImageService imageService;

        public EditModel(ApplicationDbContext ctx, ImageService imageService)
        {
            this.ctx = ctx;
            this.imageService = imageService;
        }

        [BindProperty]
        public Fruit Fruit { get; set; }

        public async Task<IActionResult> OnGetAsync(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            Fruit = await ctx.Fruits
                .Include(f => f.Image)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.Id == id);

            if (Fruit == null)
                return NotFound();

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            var fruitToUpdate = await ctx.Fruits
                .Include(f => f.Image)
                .FirstOrDefaultAsync(f => f.Id == id);

            if (fruitToUpdate == null)
                return NotFound();

            var uploadedImage = Fruit.Image;

            if (null != uploadedImage)
            {
                uploadedImage = await imageService.UploadAsync(uploadedImage);

                if (fruitToUpdate.Image != null)
                {
                    imageService.DeleteUploadedFile(fruitToUpdate.Image);
                    fruitToUpdate.Image.Name = uploadedImage.Name;
                    fruitToUpdate.Image.Path = uploadedImage.Path;
                }
                else
                    fruitToUpdate.Image = uploadedImage;
            }

            if (await TryUpdateModelAsync(fruitToUpdate, "fruit", f => f.Name, f => f.Description, f => f.Price))
            {
                await ctx.SaveChangesAsync();

                return RedirectToPage("./Index");
            }

            return Page();
        }

        private bool FruitExists(int id)
        {
            return ctx.Fruits.Any(e => e.Id == id);
        }
    }
}

9. La page de suppression d’un fruit

@page
@model MyFruits.Areas.Fruits.Pages.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<h3>Are you sure you want to delete this?</h3>

<p class="text-danger">@Model.ErrorMessage</p>

<div>
    <h4>Fruit</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Fruit.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Fruit.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Fruit.Description)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Fruit.Description)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Fruit.Price)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Fruit.Price)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Fruit.Id" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>
#nullable disable
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MyFruits.Data;
using MyFruits.Models;
using MyFruits.Services;

namespace MyFruits.Areas.Fruits.Pages
{
    public class DeleteModel : PageModel
    {
        private readonly ApplicationDbContext ctx;
        private ImageService imageService;

        public DeleteModel(ApplicationDbContext ctx, ImageService imageService)
        {
            this.ctx = ctx;
            this.imageService = imageService;
        }

        [BindProperty]
        public Fruit Fruit { get; set; }
        public string ErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int? id, bool? hasErrorMessage = false)
        {
            if (id == null)
            {
                return NotFound();
            }

            Fruit = await ctx.Fruits
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.Id == id);

            if (Fruit == null)
            {
                return NotFound();
            }

            if (hasErrorMessage.GetValueOrDefault())
            {
                ErrorMessage = $"Une erreur est survenue lors de la tentative de suppression de {Fruit.Name} ({Fruit.Id})";
            }

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var fruitToDelete = await ctx.Fruits
                .Include(f => f.Image)
                .FirstOrDefaultAsync(f => f.Id == id);

            if (fruitToDelete == null)
            {
                return NotFound();
            }

            try
            {
                imageService.DeleteUploadedFile(fruitToDelete.Image);
                ctx.Fruits.Remove(fruitToDelete);
                await ctx.SaveChangesAsync();

                return RedirectToPage("./Index");
            }
            catch (Exception)
            {
                return RedirectToAction("./Delete", new { id, hasErrorMessage = true });
            }
        }
    }
}