comment-a-link #24

Merged
jleicher merged 3 commits from comment-a-link into master 2020-12-11 09:46:43 +01:00
21 changed files with 403 additions and 36 deletions

View File

@ -0,0 +1,26 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediatR;
namespace HN.Application
{
public sealed class CommentLinkCommand : IRequest<Guid>
{
[Required]
public Guid LinkId { get; set; }
[Required]
public string Content { get; set; }
// Constructeur vide nécessaire pour le model binding
public CommentLinkCommand()
{
}
public CommentLinkCommand(Guid linkId)
{
LinkId = linkId;
}
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HN.Domain;
using MediatR;
namespace HN.Application
{
public sealed class CommentLinkCommandHandler : IRequestHandler<CommentLinkCommand, Guid>
{
private readonly ILinkRepository _linkRepository;
private readonly ICommentRepository _commentRepository;
public CommentLinkCommandHandler(ILinkRepository linkRepository, ICommentRepository commentRepository)
{
_linkRepository = linkRepository;
_commentRepository = commentRepository;
}
public async Task<Guid> Handle(CommentLinkCommand request, CancellationToken cancellationToken)
{
var link = await _linkRepository.GetByIdAsync(request.LinkId);
var comment = link.AddComment(request.Content);
await _commentRepository.AddAsync(comment);
return comment.Id;
}
}
}

View File

@ -0,0 +1,34 @@
using HN.Application;
using Website.Models;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using System.Threading.Tasks;
namespace Website.Controllers
{
public sealed class CommentsController : BaseController
{
private readonly IMediator _bus;
public CommentsController(IMediator bus)
{
_bus = bus;
}
[ValidateAntiForgeryToken]
[HttpPost]
public async Task<IActionResult> Create(CommentLinkCommand command)
{
if (!ModelState.IsValid)
{
return View("../Links/Show", new ShowLinkViewModel(await _bus.Send(new GetLinkQuery(command.LinkId)), command));
}
await _bus.Send(command);
SetFlash("Comment added!");
return RedirectToAction(nameof(LinksController.Show), "Links", new { id = command.LinkId });
}
}
}

View File

@ -4,6 +4,7 @@ using MediatR;
using System.Threading.Tasks; using System.Threading.Tasks;
using System; using System;
using HN.Domain; using HN.Domain;
using Website.Models;
namespace Website.Controllers namespace Website.Controllers
{ {
@ -25,7 +26,7 @@ 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(await _bus.Send(new GetLinkQuery(id))); return View(new ShowLinkViewModel(await _bus.Send(new GetLinkQuery(id)), new CommentLinkCommand(id)));
} }
public IActionResult Create() public IActionResult Create()
@ -35,12 +36,12 @@ namespace Website.Controllers
[HttpPost("{controller}/{id:guid}/vote")] [HttpPost("{controller}/{id:guid}/vote")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Vote(Guid id, string url, VoteType type) public async Task<IActionResult> Vote(Guid id, string url, VoteType type, string redirectTo)
{ {
await _bus.Send(new VoteForLinkCommand(id, type)); await _bus.Send(new VoteForLinkCommand(id, type));
SetFlash($"Successfuly {type} for {url}!"); SetFlash($"Successfuly {type} for {url}!");
return RedirectToAction(nameof(Index)); return Redirect(redirectTo);
} }
[HttpPost] [HttpPost]

View File

@ -0,0 +1,18 @@
using HN.Application;
namespace Website.Models
{
public class ShowLinkViewModel
{
public LinkDto Link { get; set; }
public CommentLinkCommand CommentForm { get; set; }
public ShowLinkViewModel(LinkDto link, CommentLinkCommand commentForm)
{
Link = link;
CommentForm = commentForm;
}
}
}

View File

@ -27,7 +27,8 @@ namespace Website
services.AddDbContext<HNDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("Default"))); services.AddDbContext<HNDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("Default")));
services.AddScoped<IDbContext, HNDbContext>(); services.AddScoped<IDbContext, HNDbContext>();
services.AddScoped<ILinkRepository, LinkRepository>(); services.AddScoped<ILinkRepository, LinkRepository>();
services.AddMediatR(typeof(HN.Application.AddLinkCommand)); services.AddScoped<ICommentRepository, CommentRepository>();
services.AddMediatR(typeof(HN.Application.IDbContext));
services.Configure<RouteOptions>(options => services.Configure<RouteOptions>(options =>
{ {

View File

@ -5,9 +5,13 @@
<form method="post"> <form method="post">
<div> <div>
@Html.LabelFor(m => m.Url) @* @Html.LabelFor(m => m.Url)
@Html.EditorFor(m => m.Url) @Html.EditorFor(m => m.Url)
@Html.ValidationMessageFor(m => m.Url) @Html.ValidationMessageFor(m => m.Url) *@
<label asp-for="@Model.Url"></label>
<input asp-for="@Model.Url" />
<span asp-validation-for="@Model.Url"></span>
</div> </div>
<button type="submit">Post!</button> <button type="submit">Post!</button>
</form> </form>

View File

@ -1,3 +1,4 @@
@model HN.Application.LinkDto @model ShowLinkViewModel
<partial name="_LinkItem" model="@Model" /> <partial name="_LinkItem" model="@Model.Link" />
<partial name="_CommentForm" model="@Model.CommentForm" />

View File

@ -0,0 +1,12 @@
@model HN.Application.CommentLinkCommand
<div>
<h2>Add a comment</h2>
<form asp-action="Create" asp-controller="Comments" method="post">
<input type="hidden" asp-for="@Model.LinkId" />
<textarea asp-for="@Model.Content"></textarea>
<span asp-validation-for="@Model.Content"></span>
<button type="submit">Post a comment</button>
</form>
</div>

View File

@ -3,6 +3,7 @@
<a asp-action="Show" asp-controller="Links" asp-route-id="@Model.Id">@Model.Url - created at @Model.CreatedAt.ToLocalTime() (👍: @Model.UpVotes / 👎: @Model.DownVotes)</a> <a asp-action="Show" asp-controller="Links" asp-route-id="@Model.Id">@Model.Url - created at @Model.CreatedAt.ToLocalTime() (👍: @Model.UpVotes / 👎: @Model.DownVotes)</a>
<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="submit" name="type" value="up">👍</button> <input type="submit" name="type" value="up">👍</button>
<input type="submit" name="type" value="down">👎</button> <input type="submit" name="type" value="down">👎</button>
</form> </form>

20
Domain/Comment.cs Normal file
View File

@ -0,0 +1,20 @@
using System;
namespace HN.Domain
{
public sealed class Comment
{
public Guid Id { get; private set; }
public Guid LinkId { get; private set; }
public string Content { get; private set; }
public DateTime CreatedAt { get; private set; }
internal Comment(Guid linkId, string content)
{
Id = Guid.NewGuid();
LinkId = linkId;
Content = content;
CreatedAt = DateTime.UtcNow;
}
}
}

View File

@ -0,0 +1,9 @@
using System.Threading.Tasks;
namespace HN.Domain
{
public interface ICommentRepository
{
Task AddAsync(Comment comment);
}
}

View File

@ -9,7 +9,7 @@ namespace HN.Domain
public string Url { get; } public string Url { get; }
public DateTime CreatedAt { get; } public DateTime CreatedAt { get; }
private List<Vote> _votes; private List<Vote> _votes;
public IReadOnlyList<Vote> Votes { get { return _votes; } } public IReadOnlyList<Vote> Votes => _votes;
private Link(string url) private Link(string url)
{ {
@ -33,5 +33,10 @@ namespace HN.Domain
{ {
_votes.Add(new Vote(VoteType.Down)); _votes.Add(new Vote(VoteType.Down));
} }
public Comment AddComment(string content)
{
return new Comment(Id, content);
}
} }
} }

View File

@ -0,0 +1,17 @@
using System.Threading.Tasks;
using HN.Domain;
namespace HN.Infrastructure
{
public sealed class CommentRepository : Repository<Comment>, ICommentRepository
{
public CommentRepository(HNDbContext context) : base(context)
{
}
public Task AddAsync(Comment comment)
{
return base.AddAsync(comment);
}
}
}

View File

@ -0,0 +1,22 @@
using HN.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HN.Infrastructure
{
public sealed class CommentEntityType : IEntityTypeConfiguration<Comment>
{
public void Configure(EntityTypeBuilder<Comment> builder)
{
builder.ToTable("comments");
builder.HasKey(o => o.Id);
builder.HasOne<Link>()
.WithMany()
.HasForeignKey(o => o.LinkId)
.IsRequired();
builder.Property(o => o.Content).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
}
}
}

View File

@ -14,11 +14,14 @@ namespace HN.Infrastructure.EntityTypes
builder.Property(o => o.CreatedAt).IsRequired(); builder.Property(o => o.CreatedAt).IsRequired();
builder.HasIndex(o => o.Url).IsUnique(); builder.HasIndex(o => o.Url).IsUnique();
builder.HasMany<Vote>(o => o.Votes) builder.OwnsMany(o => o.Votes, vote =>
.WithOne() {
.HasForeignKey("LinkId") vote.ToTable("link_votes");
.OnDelete(DeleteBehavior.Cascade) vote.WithOwner().HasForeignKey("LinkId");
.IsRequired(); vote.HasKey("LinkId");
vote.Property(o => o.Type).IsRequired();
vote.Property(o => o.CreatedAt).IsRequired();
});
} }
} }
} }

View File

@ -1,18 +0,0 @@
using HN.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HN.Infrastructure.EntityTypes
{
public sealed class VoteEntityType : IEntityTypeConfiguration<Vote>
{
public void Configure(EntityTypeBuilder<Vote> builder)
{
builder.ToTable("link_votes");
builder.HasKey("LinkId");
builder.Property(o => o.Type).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
}
}
}

View File

@ -11,9 +11,9 @@ namespace HN.Infrastructure
{ {
} }
public async Task AddAsync(Link link) public Task AddAsync(Link link)
{ {
await base.AddAsync(link); return base.AddAsync(link);
} }
public Task<Link> GetByIdAsync(Guid id) public Task<Link> GetByIdAsync(Guid id)
@ -21,9 +21,9 @@ namespace HN.Infrastructure
return Entries.SingleOrDefaultAsync(o => o.Id == id); return Entries.SingleOrDefaultAsync(o => o.Id == id);
} }
public async Task UpdateAsync(Link link) public Task UpdateAsync(Link link)
{ {
await base.UpdateAsync(link); return base.UpdateAsync(link);
} }
} }
} }

View File

@ -0,0 +1,107 @@
// <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("20201210171100_CreateComment")]
partial class CreateComment
{
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.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 =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany()
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HN.Domain.Vote", b =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany("Votes")
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HN.Domain.Link", b =>
{
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Infrastructure.Migrations
{
public partial class CreateComment : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "comments",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
LinkId = table.Column<Guid>(type: "TEXT", nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_comments", x => x.Id);
table.ForeignKey(
name: "FK_comments_links_LinkId",
column: x => x.LinkId,
principalTable: "links",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_comments_LinkId",
table: "comments",
column: "LinkId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "comments");
}
}
}

View File

@ -16,6 +16,29 @@ namespace Infrastructure.Migrations
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "5.0.0"); .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 => modelBuilder.Entity("HN.Domain.Link", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -54,6 +77,15 @@ namespace Infrastructure.Migrations
b.ToTable("link_votes"); b.ToTable("link_votes");
}); });
modelBuilder.Entity("HN.Domain.Comment", b =>
{
b.HasOne("HN.Domain.Link", null)
.WithMany()
.HasForeignKey("LinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HN.Domain.Vote", b => modelBuilder.Entity("HN.Domain.Vote", b =>
{ {
b.HasOne("HN.Domain.Link", null) b.HasOne("HN.Domain.Link", null)