add vote for comments and listing (#25)

This commit is contained in:
Julien Leicher 2020-12-11 12:02:45 +01:00
parent 66cc78d30e
commit 9c75758921
25 changed files with 454 additions and 39 deletions

View File

@ -8,9 +8,9 @@ namespace HN.Application
{ {
public sealed class GetLinkQueryHandler : IRequestHandler<GetLinkQuery, LinkDto> public sealed class GetLinkQueryHandler : IRequestHandler<GetLinkQuery, LinkDto>
{ {
private readonly IDbContext _context; private readonly IHNContext _context;
public GetLinkQueryHandler(IDbContext context) public GetLinkQueryHandler(IHNContext context)
{ {
_context = context; _context = context;
} }

View File

@ -0,0 +1,18 @@
using System;
namespace HN.Application
{
public sealed class CommentDto
{
public Guid Id { get; set; }
public string Content { get; set; }
public DateTime CreatedAt { get; set; }
public int UpVotes { get; set; }
public int DownVotes { get; set; }
public CommentDto()
{
}
}
}

View File

@ -0,0 +1,17 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediatR;
namespace HN.Application
{
public sealed class GetLinkCommentsQuery : IRequest<CommentDto[]>
{
[Required]
public Guid LinkId { get; set; }
public GetLinkCommentsQuery(Guid linkId)
{
LinkId = linkId;
}
}
}

View File

@ -0,0 +1,34 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class GetLinkCommentsQueryHandler : IRequestHandler<GetLinkCommentsQuery, CommentDto[]>
{
private readonly IHNContext _context;
public GetLinkCommentsQueryHandler(IHNContext context)
{
_context = context;
}
public Task<CommentDto[]> Handle(GetLinkCommentsQuery request, CancellationToken cancellationToken)
{
var comments = from comment in _context.Comments
where comment.LinkId == request.LinkId
select new CommentDto
{
Id = comment.Id,
CreatedAt = comment.CreatedAt,
Content = comment.Content,
UpVotes = comment.Votes.Count(c => c.Type == VoteType.Up),
DownVotes = comment.Votes.Count(c => c.Type == VoteType.Down)
};
return Task.FromResult(comments.OrderBy(c => c.CreatedAt).ToArray());
}
}
}

View File

@ -6,8 +6,9 @@ namespace HN.Application
/// <summary> /// <summary>
/// Interface permettant l'accès aux DbSet pour toute la partie Query. /// Interface permettant l'accès aux DbSet pour toute la partie Query.
/// </summary> /// </summary>
public interface IDbContext public interface IHNContext
{ {
DbSet<Link> Links { get; } DbSet<Link> Links { get; }
DbSet<Comment> Comments { get; }
} }
} }

View File

@ -36,9 +36,9 @@ namespace HN.Application
public sealed class ListLinksQueryHandler : IRequestHandler<ListLinksQuery, LinkDto[]> public sealed class ListLinksQueryHandler : IRequestHandler<ListLinksQuery, LinkDto[]>
{ {
private readonly IDbContext _context; private readonly IHNContext _context;
public ListLinksQueryHandler(IDbContext context) public ListLinksQueryHandler(IHNContext context)
{ {
_context = context; _context = context;
} }
@ -55,7 +55,7 @@ namespace HN.Application
DownVotes = link.Votes.Count(v => v.Type == VoteType.Down) DownVotes = link.Votes.Count(v => v.Type == VoteType.Down)
}; };
return Task.FromResult(links.ToArray()); return Task.FromResult(links.OrderByDescending(l => l.CreatedAt).ToArray());
} }
} }
} }

View File

@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class VoteForCommentCommand : IRequest
{
[Required]
public Guid CommentId { get; set; }
[Required]
public VoteType Type { get; set; }
public VoteForCommentCommand(Guid commentId, VoteType type)
{
CommentId = commentId;
Type = type;
}
}
}

View File

@ -0,0 +1,36 @@
using System.Threading;
using System.Threading.Tasks;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class VoteForCommentCommandHandler : IRequestHandler<VoteForCommentCommand>
{
private readonly ICommentRepository _commentRepository;
public VoteForCommentCommandHandler(ICommentRepository commentRepository)
{
_commentRepository = commentRepository;
}
public async Task<Unit> Handle(VoteForCommentCommand request, CancellationToken cancellationToken)
{
var comment = await _commentRepository.GetByIdAsync(request.CommentId);
switch (request.Type)
{
case VoteType.Up:
comment.Upvote();
break;
case VoteType.Down:
comment.Downvote();
break;
}
await _commentRepository.UpdateAsync(comment);
return Unit.Value;
}
}
}

View File

@ -3,6 +3,8 @@ using Website.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MediatR; using MediatR;
using System.Threading.Tasks; using System.Threading.Tasks;
using HN.Domain;
using System;
namespace Website.Controllers namespace Website.Controllers
{ {
@ -21,7 +23,9 @@ namespace Website.Controllers
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return View("../Links/Show", new ShowLinkViewModel(await _bus.Send(new GetLinkQuery(command.LinkId)), command)); var link = await _bus.Send(new GetLinkQuery(command.LinkId));
var comments = await _bus.Send(new GetLinkCommentsQuery(command.LinkId));
return View("../Links/Show", new ShowLinkViewModel(link, command, comments));
} }
await _bus.Send(command); await _bus.Send(command);
@ -30,5 +34,16 @@ namespace Website.Controllers
return RedirectToAction(nameof(LinksController.Show), "Links", new { id = command.LinkId }); return RedirectToAction(nameof(LinksController.Show), "Links", new { id = command.LinkId });
} }
[HttpPost("{controller}/{id:guid}/vote")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Vote(Guid id, VoteType type, string redirectTo)
{
await _bus.Send(new VoteForCommentCommand(id, type));
SetFlash($"Comment {type} added!");
return Redirect(redirectTo);
}
} }
} }

View File

@ -26,7 +26,9 @@ namespace Website.Controllers
[HttpGet("{controller}/{id:guid}")] [HttpGet("{controller}/{id:guid}")]
public async Task<IActionResult> Show(Guid id) public async Task<IActionResult> Show(Guid id)
{ {
return View(new ShowLinkViewModel(await _bus.Send(new GetLinkQuery(id)), new CommentLinkCommand(id))); var link = await _bus.Send(new GetLinkQuery(id));
var comments = await _bus.Send(new GetLinkCommentsQuery(id));
return View(new ShowLinkViewModel(link, new CommentLinkCommand(id), comments));
} }
public IActionResult Create() public IActionResult Create()

View File

@ -7,11 +7,13 @@ namespace Website.Models
public LinkDto Link { get; set; } public LinkDto Link { get; set; }
public CommentLinkCommand CommentForm { get; set; } public CommentLinkCommand CommentForm { get; set; }
public CommentDto[] Comments { get; set; }
public ShowLinkViewModel(LinkDto link, CommentLinkCommand commentForm) public ShowLinkViewModel(LinkDto link, CommentLinkCommand commentForm, CommentDto[] comments)
{ {
Link = link; Link = link;
CommentForm = commentForm; CommentForm = commentForm;
Comments = comments;
} }
} }

View File

@ -25,10 +25,10 @@ namespace Website
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddDbContext<HNDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("Default"))); services.AddDbContext<HNDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("Default")));
services.AddScoped<IDbContext, HNDbContext>(); services.AddScoped<IHNContext, HNDbContext>();
services.AddScoped<ILinkRepository, LinkRepository>(); services.AddScoped<ILinkRepository, LinkRepository>();
services.AddScoped<ICommentRepository, CommentRepository>(); services.AddScoped<ICommentRepository, CommentRepository>();
services.AddMediatR(typeof(HN.Application.IDbContext)); services.AddMediatR(typeof(HN.Application.IHNContext));
services.Configure<RouteOptions>(options => services.Configure<RouteOptions>(options =>
{ {

View File

@ -1,4 +1,16 @@
@model ShowLinkViewModel @model ShowLinkViewModel
<partial name="_LinkItem" model="@Model.Link" /> <partial name="_LinkItem" model="@Model.Link" />
@if(Model.Comments.Length == 0) {
<p>No comments yet</p>
} else {
<ul>
@foreach (var comment in Model.Comments)
{
<li><partial name="_CommentItem" model="@comment" /></li>
}
</ul>
}
<partial name="_CommentForm" model="@Model.CommentForm" /> <partial name="_CommentForm" model="@Model.CommentForm" />

View File

@ -0,0 +1,11 @@
@model HN.Application.CommentDto
<span>@Model.Content</span>
<div>
👍: @Model.UpVotes / 👎: @Model.DownVotes
</div>
<form asp-action="Vote" asp-controller="Comments" asp-route-id="@Model.Id" method="post">
<input type="hidden" name="redirectTo" value="@Context.Request.Path" />
<input type="submit" name="type" value="up" />
<input type="submit" name="type" value="down" />
</form>

View File

@ -4,6 +4,6 @@
<form asp-controller="Links" asp-action="Vote" asp-route-id="@Model.Id" asp-route-url="@Model.Url" method="post"> <form asp-controller="Links" asp-action="Vote" asp-route-id="@Model.Id" asp-route-url="@Model.Url" method="post">
<input type="hidden" name="redirectTo" value="@Context.Request.Path" /> <input type="hidden" name="redirectTo" value="@Context.Request.Path" />
<input type="submit" name="type" value="up">👍</button> <input type="submit" name="type" value="up" />
<input type="submit" name="type" value="down">👎</button> <input type="submit" name="type" value="down" />
</form> </form>

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace HN.Domain namespace HN.Domain
{ {
@ -8,6 +9,8 @@ namespace HN.Domain
public Guid LinkId { get; private set; } public Guid LinkId { get; private set; }
public string Content { get; private set; } public string Content { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
private List<Vote> _votes;
public IReadOnlyList<Vote> Votes => _votes;
internal Comment(Guid linkId, string content) internal Comment(Guid linkId, string content)
{ {
@ -15,6 +18,17 @@ namespace HN.Domain
LinkId = linkId; LinkId = linkId;
Content = content; Content = content;
CreatedAt = DateTime.UtcNow; CreatedAt = DateTime.UtcNow;
_votes = new List<Vote>();
}
public void Upvote()
{
_votes.Add(new Vote(VoteType.Up));
}
public void Downvote()
{
_votes.Add(new Vote(VoteType.Down));
} }
} }
} }

View File

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace HN.Domain namespace HN.Domain
@ -5,5 +6,7 @@ namespace HN.Domain
public interface ICommentRepository public interface ICommentRepository
{ {
Task AddAsync(Comment comment); Task AddAsync(Comment comment);
Task UpdateAsync(Comment comment);
Task<Comment> GetByIdAsync(Guid id);
} }
} }

View File

@ -7,7 +7,7 @@ namespace HN.Domain
public VoteType Type { get; private set; } public VoteType Type { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
public Vote(VoteType type) internal Vote(VoteType type)
{ {
Type = type; Type = type;
CreatedAt = DateTime.UtcNow; CreatedAt = DateTime.UtcNow;

View File

@ -1,5 +1,7 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using HN.Domain; using HN.Domain;
using Microsoft.EntityFrameworkCore;
namespace HN.Infrastructure namespace HN.Infrastructure
{ {
@ -13,5 +15,15 @@ namespace HN.Infrastructure
{ {
return base.AddAsync(comment); return base.AddAsync(comment);
} }
public Task<Comment> GetByIdAsync(Guid id)
{
return Entries.SingleOrDefaultAsync(o => o.Id == id);
}
public Task UpdateAsync(Comment comment)
{
return base.UpdateAsync(comment);
}
} }
} }

View File

@ -16,6 +16,15 @@ namespace HN.Infrastructure
.IsRequired(); .IsRequired();
builder.Property(o => o.Content).IsRequired(); builder.Property(o => o.Content).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired(); builder.Property(o => o.CreatedAt).IsRequired();
builder.OwnsMany(o => o.Votes, vote =>
{
vote.ToTable("comment_votes");
vote.WithOwner().HasForeignKey("CommentId");
vote.HasKey("CommentId");
vote.Property(o => o.Type).IsRequired();
vote.Property(o => o.CreatedAt).IsRequired();
});
} }
} }

View File

@ -5,10 +5,11 @@ using Microsoft.Extensions.Logging;
namespace HN.Infrastructure namespace HN.Infrastructure
{ {
public sealed class HNDbContext : DbContext, IDbContext public sealed class HNDbContext : DbContext, IHNContext
{ {
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
public DbSet<Link> Links { get; set; } public DbSet<Link> Links { get; set; }
public DbSet<Comment> Comments { get; set; }
public HNDbContext() public HNDbContext()
{ {

View File

@ -0,0 +1,122 @@
// <auto-generated />
using System;
using HN.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Infrastructure.Migrations
{
[DbContext(typeof(HNDbContext))]
[Migration("20201211102901_CreateCommentVote")]
partial class CreateCommentVote
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("HN.Domain.Comment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LinkId");
b.ToTable("comments");
});
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Url")
.IsUnique();
b.ToTable("links");
});
modelBuilder.Entity("HN.Domain.Comment", b =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany()
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HN.Domain.Vote", "Votes", b1 =>
{
b1.Property<Guid>("CommentId")
.HasColumnType("TEXT");
b1.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<int>("Type")
.HasColumnType("INTEGER");
b1.HasKey("CommentId");
b1.ToTable("comment_votes");
b1.WithOwner()
.HasForeignKey("CommentId");
});
b.Navigation("Votes");
});
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.OwnsMany("HN.Domain.Vote", "Votes", b1 =>
{
b1.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b1.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<int>("Type")
.HasColumnType("INTEGER");
b1.HasKey("LinkId");
b1.ToTable("link_votes");
b1.WithOwner()
.HasForeignKey("LinkId");
});
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Infrastructure.Migrations
{
public partial class CreateCommentVote : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "comment_votes",
columns: table => new
{
CommentId = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_comment_votes", x => x.CommentId);
table.ForeignKey(
name: "FK_comment_votes_comments_CommentId",
column: x => x.CommentId,
principalTable: "comments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "comment_votes");
}
}
}

View File

@ -61,22 +61,6 @@ namespace Infrastructure.Migrations
b.ToTable("links"); b.ToTable("links");
}); });
modelBuilder.Entity("HN.Domain.Vote", b =>
{
b.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("LinkId");
b.ToTable("link_votes");
});
modelBuilder.Entity("HN.Domain.Comment", b => modelBuilder.Entity("HN.Domain.Comment", b =>
{ {
b.HasOne("HN.Domain.Link", null) b.HasOne("HN.Domain.Link", null)
@ -84,19 +68,50 @@ namespace Infrastructure.Migrations
.HasForeignKey("LinkId") .HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.OwnsMany("HN.Domain.Vote", "Votes", b1 =>
{
b1.Property<Guid>("CommentId")
.HasColumnType("TEXT");
b1.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<int>("Type")
.HasColumnType("INTEGER");
b1.HasKey("CommentId");
b1.ToTable("comment_votes");
b1.WithOwner()
.HasForeignKey("CommentId");
}); });
modelBuilder.Entity("HN.Domain.Vote", b => b.Navigation("Votes");
{
b.HasOne("HN.Domain.Link", null)
.WithMany("Votes")
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
}); });
modelBuilder.Entity("HN.Domain.Link", b => modelBuilder.Entity("HN.Domain.Link", b =>
{ {
b.OwnsMany("HN.Domain.Vote", "Votes", b1 =>
{
b1.Property<Guid>("LinkId")
.HasColumnType("TEXT");
b1.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<int>("Type")
.HasColumnType("INTEGER");
b1.HasKey("LinkId");
b1.ToTable("link_votes");
b1.WithOwner()
.HasForeignKey("LinkId");
});
b.Navigation("Votes"); b.Navigation("Votes");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618

View File

@ -2,12 +2,41 @@
Le but est de réaliser de A à Z une application Web imitant le fonctionnement de Hacker News en abordant des bonnes pratiques de développement en .Net. Le but est de réaliser de A à Z une application Web imitant le fonctionnement de Hacker News en abordant des bonnes pratiques de développement en .Net.
## Plan de la formation
- Présentation d'ASP.Net Core
- Mise en place du domaine (Création de librairies de classes)
- Mise en place d'une couche applicative découplée avec MediatR
- Persistence des données avec EF Core
- Aperçu de l'outil
- Définir le modèle grâce à l'API Fluent
- Gérer les migrations
- Réalisation d'un projet MVC
- Rappels sur l'architecture MVC
- Injection de dépendances
- Afficher une page et naviguer entre les pages
- Gestion des formulaires
- Routage conventionnel / par attributs
- Découpage des vues grâce aux vues partielles et composants
- Utilisation de TempData pour les messages de statut
- Authentification et autorisation
- Déploiement
- Réalisation d'une API Rest
- Rappels sur l'architecture REST
- Exposer nos cas d'utilisations au travers de ressources
- Documenter l'API
- Réalisation d'un client en Blazor
- Présentation
- Découpage en composants
- Gestion des formulaires
## Ressources utiles ## Ressources utiles
- https://aspnetcore.readthedocs.io/en/stable/mvc/index.html - https://aspnetcore.readthedocs.io/en/stable/mvc/index.html
- https://andrewlock.net/an-introduction-to-viewcomponents-a-login-status-view-component/ - https://andrewlock.net/an-introduction-to-viewcomponents-a-login-status-view-component/
- https://stackoverflow.com/a/47011478 - https://stackoverflow.com/a/47011478
- https://stackoverflow.com/a/34291650 - https://stackoverflow.com/a/34291650
- https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/
## Commençer par le domaine ! ## Commençer par le domaine !
@ -19,6 +48,10 @@ Mise en place de la couche applicative avec `MediatR` et implémentation du prem
## Le site internet en MVC ## Le site internet en MVC
### Présentation de l'architecture du projet de base
Aborder l'injection de dépendances et le fichier `Startup.cs` en général.
### Options pattern ### Options pattern
Permet d'avoir des objets de configuration facilement sortable depuis l'appsettings. Utilisation de https://docs.microsoft.com/fr-fr/aspnet/core/fundamentals/configuration/options?view=aspnetcore-5.0. Permet d'avoir des objets de configuration facilement sortable depuis l'appsettings. Utilisation de https://docs.microsoft.com/fr-fr/aspnet/core/fundamentals/configuration/options?view=aspnetcore-5.0.