From 88af4d4fabac7beae77dd3dd5160639c1543fa60 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 28 Mar 2026 23:04:28 -0400 Subject: [PATCH 1/2] feat: Add project comments' database table with relationships --- src/Analysim.Core/Entities/Project.cs | 3 + src/Analysim.Core/Entities/ProjectComment.cs | 42 + src/Analysim.Core/Entities/User.cs | 3 + .../Data/ApplicationDbContext.cs | 35 +- .../ApplicationDbContextModelSnapshot.cs | 81 +- ...60319234816_AddProjectComments.Designer.cs | 841 ++++++++++++++++++ .../20260319234816_AddProjectComments.cs | 114 +++ 7 files changed, 1112 insertions(+), 7 deletions(-) create mode 100644 src/Analysim.Core/Entities/ProjectComment.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index dd80d370..b09c2d51 100644 --- a/src/Analysim.Core/Entities/Project.cs +++ b/src/Analysim.Core/Entities/Project.cs @@ -39,6 +39,9 @@ public class Project public ICollection Notebooks {get;set;} = new List(); + // Comments + public ICollection ProjectComments { get; set; } = new List(); + public int ForkedFromProjectID { get; set; } } diff --git a/src/Analysim.Core/Entities/ProjectComment.cs b/src/Analysim.Core/Entities/ProjectComment.cs new file mode 100644 index 00000000..ab8f223d --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectComment.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectComment + { + // PK + [KeyAttribute] + public int CommentID { get; set; } + + // Comment Author + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } = null!; + + // Project comment is linked to + [ForeignKey("Project")] + public int ProjectID { get; set; } + public Project Project{ get; set; } = null!; + + // Is this comment a reply? + [ForeignKey("ParentComment")] + public int? ParentCommentID { get; set; } + public ProjectComment? ParentComment { get; set; } + + // Comment content + public string Content { get; set; } = string.Empty; + + // Soft Delete + public bool IsDeleted { get; set; } // ASK ABOUT THIS "Delete your own comments (but still leave the space for it)" + + // Timestamps + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Replies to this comment + public ICollection Replies { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 801e98dd..5416765b 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -20,6 +20,9 @@ public class User : IdentityUser public ICollection Following { get; } = new List(); public ICollection ProjectUsers { get; } = new List(); public ICollection BlobFiles { get; } = new List(); + + // Comments + public ICollection ProjectComments { get; set; } = new List(); public string RegistrationSurvey {get; set;} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 26d61bba..716e3982 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -107,6 +107,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(n=>n.observableNotebookDatasets) .HasForeignKey(d=>d.NotebookID) .OnDelete(DeleteBehavior.Cascade); + + // COMMENTS + + // One To Many Relationship (Project -> ProjectComment) + modelBuilder.Entity() + .HasMany(p => p.ProjectComments) + .WithOne(pc => pc.Project) + .HasForeignKey(pc => pc.ProjectID) + .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (User -> ProjectComment) + modelBuilder.Entity() + .HasMany(u => u.ProjectComments) + .WithOne(pc => pc.User) + .HasForeignKey(pc => pc.UserID) + .OnDelete(DeleteBehavior.Restrict); + + // Self Reference Relationship (ProjectComment -> Replies) + modelBuilder.Entity() + .HasOne(pc => pc.ParentComment) + .WithMany(pc => pc.Replies) + .HasForeignKey(pc => pc.ParentCommentID) + .OnDelete(DeleteBehavior.Restrict); + + // Indexes for common lookups + modelBuilder.Entity() + .HasIndex(pc => pc.ProjectID); + + modelBuilder.Entity() + .HasIndex(pc => pc.ParentCommentID); } @@ -122,9 +152,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet NotebookContent { get; set; } public DbSet BlobFileContent { get; set; } - - - - + public DbSet ProjectComments { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index ee014be1..af670c31 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -242,6 +242,46 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Projects"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.Property("ProjectID") @@ -428,21 +468,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "ebd0169f-30be-4ab7-9fb9-038a9de20efb", + ConcurrencyStamp = "fa79cb69-0854-4116-be7e-0cc32666a38c", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "371acb40-c941-4714-9c2c-bc9de9bff144", + ConcurrencyStamp = "c0e380b3-6959-457d-b623-72d23bd85ed1", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "7ee39326-5034-4323-85cc-6da801861458", + ConcurrencyStamp = "6c80828f-17ef-44b7-8820-bcc50e82e8f3", Name = "Moderator", NormalizedName = "MODERATOR" }); @@ -612,6 +652,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("notebook"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.HasOne("Core.Entities.Project", "Project") @@ -738,11 +804,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Notebooks"); + b.Navigation("ProjectComments"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("Replies"); + }); + modelBuilder.Entity("Core.Entities.Tag", b => { b.Navigation("ProjectTags"); @@ -756,6 +829,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Following"); + b.Navigation("ProjectComments"); + b.Navigation("ProjectUsers"); }); #pragma warning restore 612, 618 diff --git a/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs new file mode 100644 index 00000000..63ba7184 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs @@ -0,0 +1,841 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260319234816_AddProjectComments")] + partial class AddProjectComments + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "fa79cb69-0854-4116-be7e-0cc32666a38c", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "c0e380b3-6959-457d-b623-72d23bd85ed1", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "6c80828f-17ef-44b7-8820-bcc50e82e8f3", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs new file mode 100644 index 00000000..b2625833 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddProjectComments : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectComments", + columns: table => new + { + CommentID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserID = table.Column(type: "integer", nullable: false), + ProjectID = table.Column(type: "integer", nullable: false), + ParentCommentID = table.Column(type: "integer", nullable: true), + Content = table.Column(type: "text", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectComments", x => x.CommentID); + table.ForeignKey( + name: "FK_ProjectComments_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectComments_ProjectComments_ParentCommentID", + column: x => x.ParentCommentID, + principalTable: "ProjectComments", + principalColumn: "CommentID", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectComments_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "fa79cb69-0854-4116-be7e-0cc32666a38c"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "c0e380b3-6959-457d-b623-72d23bd85ed1"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "6c80828f-17ef-44b7-8820-bcc50e82e8f3"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_ParentCommentID", + table: "ProjectComments", + column: "ParentCommentID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_ProjectID", + table: "ProjectComments", + column: "ProjectID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_UserID", + table: "ProjectComments", + column: "UserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectComments"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "ebd0169f-30be-4ab7-9fb9-038a9de20efb"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "371acb40-c941-4714-9c2c-bc9de9bff144"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "7ee39326-5034-4323-85cc-6da801861458"); + } + } +} From bdac962312ddc245929a64e93e3af6c8ba60cbdc Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 28 Mar 2026 23:06:44 -0400 Subject: [PATCH 2/2] feat: Implement comment rendering within projects --- .../src/app/interfaces/project-comment.ts | 12 ++ .../project-comment-box.component.html | 17 +++ .../project-comment-box.component.scss | 28 ++++ .../project-comment-box.component.spec.ts | 23 ++++ .../project-comment-box.component.ts | 15 +++ .../project-comment-item.component.html | 82 ++++++++++++ .../project-comment-item.component.scss | 122 ++++++++++++++++++ .../project-comment-item.component.spec.ts | 23 ++++ .../project-comment-item.component.ts | 42 ++++++ .../project-comments.component.html | 67 +++------- .../project-comments.component.scss | 5 +- .../project-comments.component.ts | 32 ++++- .../projects/project/project.component.html | 2 +- .../src/app/projects/projects.module.ts | 4 + .../src/app/services/project.service.ts | 18 ++- .../styles/base/_bootstrap-overrides.scss | 26 ++++ .../Controllers/ProjectController.cs | 51 +++++++- .../ViewModels/Project/ProjectCommentVM.cs | 19 +++ 18 files changed, 530 insertions(+), 58 deletions(-) create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts create mode 100644 src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts new file mode 100644 index 00000000..469e1f2c --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts @@ -0,0 +1,12 @@ +export interface ProjectComment { + commentID: number; + userID: number; + authorName: string; + projectID: number; + parentCommentID: number | null; + content: string; + isDeleted: boolean; + createdAt: string; + updatedAt: string; + replies: ProjectComment[]; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html new file mode 100644 index 00000000..027906f8 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html @@ -0,0 +1,17 @@ +
+ + +
+ + + +
+
\ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss new file mode 100644 index 00000000..27a5693d --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss @@ -0,0 +1,28 @@ +.comment-box { + border: 1px solid #ddd; + border-radius: 8px; + padding: 12px; + background: #fff; + + textarea { + width: 100%; + resize: none; + border-radius: 6px; + border: 1px solid #ccc; + padding: 8px; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: #4a90e2; + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.15); + } + } +} + +.button-area { + display: flex; + gap: 6px; + justify-content: end; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts new file mode 100644 index 00000000..7ef1715a --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProjectCommentBoxComponent } from './project-comment-box.component'; + +describe('ProjectCommentBoxComponent', () => { + let component: ProjectCommentBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ProjectCommentBoxComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProjectCommentBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts new file mode 100644 index 00000000..030de842 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-project-comment-box', + templateUrl: './project-comment-box.component.html', + styleUrls: ['./project-comment-box.component.scss'] +}) +export class ProjectCommentBoxComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html new file mode 100644 index 00000000..b13ddc02 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html @@ -0,0 +1,82 @@ +
+ +
+
+ + {{ comment.authorName }} + + + + {{ comment.createdAt | date: "MMM d, y • h:mm a" }} + +
+
+ + +
+
+ + +
+ {{ comment.isDeleted ? "[deleted]" : comment.content }} +
+ + +
+
+ +
+ +
+ + + + + +
+
+ +
+
+ + +
+
+
diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss new file mode 100644 index 00000000..9e8e1a53 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss @@ -0,0 +1,122 @@ +.comment-item { + border: var(--border-w-1) var(--border-style) var(--border-color); + border-radius: var(--radius-1); + padding: var(--space-3); + background: var(--surface-0); + + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +// Header +.comment-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.comment-meta { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.comment-author { + font-weight: 600; + font-size: var(--fs-16); + color: var(--text-on-light); +} + +.comment-date { + font-size: var(--fs-12); + opacity: 0.7; +} + +// Body +.comment-body { + font-size: var(--fs-16); + line-height: 1.5; + word-break: break-word; + margin-left: 0.5rem; +} + +// Action Buttons +.actions { + display: flex; + justify-content: flex-end; +} + +.comment-actions { + margin-left: auto; + display: inline-flex; + align-items: stretch; +} + +.comment-actions .btn { + padding: var(--space-2) var(--space-2) !important; + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.actions-left{ + display: inline-flex; + align-items: stretch; + gap: var(--space-4); +} + +.upvote{ + padding: var(--space-1) var(--space-2) !important; +} + +.comment-actions .btn > * { + font-size: medium; +} + +// Open Thread Button +.thread-toggle { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.thread-toggle > * { + display: inline-block; + transition: transform 0.25s ease; + transform-origin: center; + font-size: small; +} + +.thread-toggle.open > * { + transform: rotate(90deg) !important; +} + +// Replies +.comment-replies { + max-height: 0; + opacity: 0; + overflow: hidden; + margin-left: 0.5rem; + padding-left: 1rem; + border-left: 2px solid #ddd; + margin-top: 0; + transition: + max-height 0.3s ease, + opacity 0.25s ease, + margin-top 0.3s ease; +} + +.comment-replies.open { + max-height: 1000px; + opacity: 1; + margin-top: 0.75rem; +} + +.comment-replies-inner { + display: flex; + flex-direction: column; + gap: 0.75rem; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts new file mode 100644 index 00000000..65561cd9 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProjectCommentItemComponent } from './project-comment-item.component'; + +describe('ProjectCommentItemComponent', () => { + let component: ProjectCommentItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ProjectCommentItemComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProjectCommentItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts new file mode 100644 index 00000000..bc1f5a9f --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts @@ -0,0 +1,42 @@ +import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ProjectComment } from 'src/app/interfaces/project-comment'; +import { User } from 'src/app/interfaces/user'; +import { AccountService } from 'src/app/services/account.service'; + +@Component({ + selector: 'app-project-comment-item', + templateUrl: './project-comment-item.component.html', + styleUrls: ['./project-comment-item.component.scss'] +}) +export class ProjectCommentItemComponent { + @Input() comment!: ProjectComment; + + isOpen = false; + currentUser$: Observable = null; + currentUser: User = null; + isOwner = false; + + constructor(private accountService: AccountService) {} + + async ngOnInit(): Promise { + this.currentUser$ = await this.accountService.currentUser; + + this.currentUser$.subscribe((user) => { + this.currentUser = user; + this.isOwner = !!user && user.id === this.comment.userID; + }); + } + + onReply(): void { + // opens comment box below comment being replied to + } + + onViewThread(): void { + this.isOpen = !this.isOpen; + } + + onReport(): void { + // notify admins of comment + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html index 1e9d6716..92a02ef7 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html @@ -1,56 +1,25 @@
- Comments -
- -
- -
-
+ Comments
-
    -
  • -
    - Tammie Smith - 02/23/2023 -
    - -
    -
    -
  • -
  • -
    - Oneal Smith - 02/23/2023 -
    - -
    -
    -
  • -
-
+
+
+ Loading comments... +
+ +
+ +
+ +
+ No comments yet. +
- - diff --git a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts index 0632ac9b..78f834e7 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts @@ -56,6 +56,8 @@ import { SaveConfirmationModalComponent } from './project-overview/project-overv import { ModalDatasetsComponent } from './project-overview/project-overview-view/project-content/modal-datasets/modal-datasets.component'; import { DatasetFolderViewComponent } from './project-overview/project-overview-view/project-content/dataset-folder-view/dataset-folder-view.component'; import { SaveNotebookModalComponent } from './project-overview/project-overview-view/project-content/save-notebook-modal/save-notebook-modal.component'; +import { ProjectCommentItemComponent } from './project-comments/project-comment-item/project-comment-item.component'; +import { ProjectCommentBoxComponent } from './project-comments/project-comment-box/project-comment-box.component'; @NgModule({ declarations: [ @@ -104,6 +106,8 @@ import { SaveNotebookModalComponent } from './project-overview/project-overview- ModalDatasetsComponent, DatasetFolderViewComponent, SaveNotebookModalComponent, + ProjectCommentItemComponent, + ProjectCommentBoxComponent, ], imports: [ CommonModule, diff --git a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts index 210b32c2..3646c042 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -14,6 +14,7 @@ import { NotificationService } from './notification.service'; import { saveAs } from 'file-saver'; import { Notebook, NotebookFile, NotebookURL } from '../interfaces/notebook'; import { getItem } from 'localforage'; +import { ProjectComment } from '../interfaces/project-comment'; @Injectable({ providedIn: 'root' @@ -39,6 +40,7 @@ export class ProjectService { private urlDownloadImage: string = this.baseUrl + "downloadFile/" private urlDownloadNotebook: string = this.baseUrl + "DownloadNotebook/" private urlGetNotebookVersions: string = this.baseUrl + "getnotebookversions/" + private urlGetProjectComments: string = this.baseUrl + "getprojectcomments/"; // Post @@ -90,7 +92,7 @@ export class ProjectService { }), catchError(error => { console.log(error) - return throwError(error) + return throwError(error) }) ) } @@ -801,5 +803,19 @@ export class ProjectService { ) } + getProjectComments(projectID: number): Observable { + return this.http.get(this.urlGetProjectComments + projectID) + .pipe( + map(body => { + console.log(body.message); + return body.result; + }), + catchError(error => { + console.log(error); + return throwError(() => error); + }) + ); + } + } diff --git a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss index f2cc6672..61f27235 100644 --- a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss +++ b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss @@ -56,6 +56,7 @@ border: var(--button-border-width) var(--button-border-style) var(--button-border-color) !important; color: var(--button-border-color) !important; + font-weight: 600; } .btn-outline-primary:hover:not(:disabled):not(.disabled), @@ -66,12 +67,25 @@ color: var(--button-font-color) !important; } +.btn-ghost-primary { + background: #fff !important; + color: var(--button-border-color) !important; + font-weight: 600; +} + +.btn-ghost-primary:hover:not(:disabled):not(.disabled), +.btn-ghost-primary:focus:not(:disabled):not(.disabled) { + background: var(--button-background-color) !important; + color: var(--button-font-color) !important; +} + /* Quiet danger button */ .btn-danger { background: transparent !important; border: var(--button-border-width) var(--button-border-style) var(--color-danger) !important; color: var(--color-danger) !important; + font-weight: 600; } /* Hover = full danger */ @@ -82,3 +96,15 @@ var(--color-danger) !important; color: var(--c-text-inverse) !important; } + +.btn-ghost-danger { + background: transparent !important; + color: var(--color-danger) !important; + font-weight: 600; +} + +.btn-ghost-danger:hover:not(:disabled):not(.disabled), +.btn-ghost-danger:focus:not(:disabled):not(.disabled) { + background: var(--color-danger) !important; + color: var(--c-text-inverse) !important; +} diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index f4d01fb6..78cedeaa 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -325,8 +325,57 @@ public async Task GetNotebookVersions([FromRoute] int notebookID) } } + /* + * Type : GET + * URL : /api/projects/getprojectcomments/projectId + * Description: Gets all comments for a project + */ + [HttpGet("[action]/{projectId}")] + public async Task GetProjectComments([FromRoute] int projectId) + { + if (projectId <= 0) + return BadRequest("Invalid project id."); + + var flatComments = await _dbContext.ProjectComments + .AsNoTracking() + .Where(p => p.ProjectID == projectId) + .OrderBy(c => c.CreatedAt) + .Select(c => new ProjectCommentVM + { + CommentID = c.CommentID, + UserID = c.UserID, + AuthorName = c.User.UserName, + ProjectID = c.ProjectID, + ParentCommentID = c.ParentCommentID, + Content = c.Content, + IsDeleted = c.IsDeleted, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + Replies = new List() + }).ToListAsync(); + + var commentLookup = flatComments.ToDictionary(c => c.CommentID); // create lookup (O(1) lookup speed) + var rootComments = new List(); // top level comments + + // sort comments into nest object + foreach (var comment in flatComments) + { + if(comment.ParentCommentID.HasValue && commentLookup.TryGetValue(comment.ParentCommentID.Value, out var parent)) + { + parent.Replies.Add(comment); + } + else + { + rootComments.Add(comment); + } + } - + return Ok(new + { + result = rootComments, + message = "Received Comments" + }); + } #endregion diff --git a/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs new file mode 100644 index 00000000..d7b66e1a --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectCommentVM + { + public int CommentID { get; set; } + public int UserID { get; set; } + public string AuthorName { get; set; } = string.Empty; + public int ProjectID { get; set; } + public int? ParentCommentID { get; set; } + public string Content { get; set; } = string.Empty; + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Replies { get; set; } = new(); + } +} \ No newline at end of file