Side Nav Redesign (#2310)

This commit is contained in:
Joe Milazzo 2023-10-14 10:07:53 -05:00 committed by GitHub
parent 5c2ebb87cc
commit 00dddaefae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 5971 additions and 572 deletions

View file

@ -56,6 +56,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
public DbSet<AppUserSmartFilter> AppUserSmartFilter { get; set; } = null!;
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
public DbSet<AppUserSideNavStream> AppUserSideNavStream { get; set; } = null!;
public DbSet<AppUserExternalSource> AppUserExternalSource { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
@ -128,6 +130,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserDashboardStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<AppUserSideNavStream>()
.Property(b => b.StreamType)
.HasDefaultValue(SideNavStreamType.SmartFilter);
builder.Entity<AppUserSideNavStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
}

View file

@ -0,0 +1,52 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Introduced in v0.7.8.7 and v0.7.9, this adds SideNavStream's for all Libraries a User has access to
/// </summary>
public static class MigrateUserLibrarySideNavStream
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Please be patient, this may take some time. This is not an error");
var usersWithLibraryStreams = await dataContext.AppUser.Include(u => u.SideNavStreams)
.AnyAsync(u => u.SideNavStreams.Count > 0 && u.SideNavStreams.Any(s => s.LibraryId > 0));
if (usersWithLibraryStreams)
{
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - complete. Nothing to do");
return;
}
var users = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams);
foreach (var user in users)
{
var userLibraries = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
foreach (var lib in userLibraries)
{
var prevMaxOrder = user.SideNavStreams.Max(s => s.Order);
user.SideNavStreams.Add(new AppUserSideNavStream()
{
Name = lib.Name,
LibraryId = lib.Id,
IsProvided = false,
Visible = true,
StreamType = SideNavStreamType.Library,
Order = prevMaxOrder + 1
});
}
unitOfWork.UserRepository.Update(user);
}
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateUserLibrarySideNavStream migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SideNavStreamAndExternalSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserExternalSource",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Host = table.Column<string>(type: "TEXT", nullable: true),
ApiKey = table.Column<string>(type: "TEXT", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserExternalSource", x => x.Id);
table.ForeignKey(
name: "FK_AppUserExternalSource_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AppUserSideNavStream",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
IsProvided = table.Column<bool>(type: "INTEGER", nullable: false),
Order = table.Column<int>(type: "INTEGER", nullable: false),
LibraryId = table.Column<int>(type: "INTEGER", nullable: true),
ExternalSourceId = table.Column<int>(type: "INTEGER", nullable: true),
StreamType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 5),
Visible = table.Column<bool>(type: "INTEGER", nullable: false),
SmartFilterId = table.Column<int>(type: "INTEGER", nullable: true),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserSideNavStream", x => x.Id);
table.ForeignKey(
name: "FK_AppUserSideNavStream_AppUserSmartFilter_SmartFilterId",
column: x => x.SmartFilterId,
principalTable: "AppUserSmartFilter",
principalColumn: "Id");
table.ForeignKey(
name: "FK_AppUserSideNavStream_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserExternalSource_AppUserId",
table: "AppUserExternalSource",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_AppUserId",
table: "AppUserSideNavStream",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_SmartFilterId",
table: "AppUserSideNavStream",
column: "SmartFilterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserSideNavStream_Visible",
table: "AppUserSideNavStream",
column: "Visible");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserExternalSource");
migrationBuilder.DropTable(
name: "AppUserSideNavStream");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -223,6 +223,31 @@ namespace API.Data.Migrations
b.ToTable("AppUserDashboardStream");
});
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiKey")
.HasColumnType("TEXT");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("Host")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserExternalSource");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
{
b.Property<int>("Id")
@ -456,6 +481,52 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int?>("ExternalSourceId")
.HasColumnType("INTEGER");
b.Property<bool>("IsProvided")
.HasColumnType("INTEGER");
b.Property<int?>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<int?>("SmartFilterId")
.HasColumnType("INTEGER");
b.Property<int>("StreamType")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5);
b.Property<bool>("Visible")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("SmartFilterId");
b.HasIndex("Visible");
b.ToTable("AppUserSideNavStream");
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
{
b.Property<int>("Id")
@ -1790,6 +1861,17 @@ namespace API.Data.Migrations
b.Navigation("SmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("ExternalSources")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -1887,6 +1969,23 @@ namespace API.Data.Migrations
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("SideNavStreams")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter")
.WithMany()
.HasForeignKey("SmartFilterId");
b.Navigation("AppUser");
b.Navigation("SmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2303,6 +2402,8 @@ namespace API.Data.Migrations
b.Navigation("Devices");
b.Navigation("ExternalSources");
b.Navigation("Progresses");
b.Navigation("Ratings");
@ -2311,6 +2412,8 @@ namespace API.Data.Migrations
b.Navigation("ScrobbleHolds");
b.Navigation("SideNavStreams");
b.Navigation("SmartFilters");
b.Navigation("TableOfContents");

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.SideNav;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Helpers;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IAppUserExternalSourceRepository
{
void Update(AppUserExternalSource source);
void Delete(AppUserExternalSource source);
Task<AppUserExternalSource> GetById(int externalSourceId);
Task<IList<ExternalSourceDto>> GetExternalSources(int userId);
Task<bool> ExternalSourceExists(int userId, string name, string host, string apiKey);
}
public class AppUserExternalSourceRepository : IAppUserExternalSourceRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public AppUserExternalSourceRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(AppUserExternalSource source)
{
_context.Entry(source).State = EntityState.Modified;
}
public void Delete(AppUserExternalSource source)
{
_context.AppUserExternalSource.Remove(source);
}
public async Task<AppUserExternalSource> GetById(int externalSourceId)
{
return await _context.AppUserExternalSource
.Where(s => s.Id == externalSourceId)
.FirstOrDefaultAsync();
}
public async Task<IList<ExternalSourceDto>> GetExternalSources(int userId)
{
return await _context.AppUserExternalSource.Where(s => s.AppUserId == userId)
.ProjectTo<ExternalSourceDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
/// <summary>
/// Checks if all the properties match exactly. This will allow a user to setup 2 External Sources with different Users
/// </summary>
/// <param name="userId"></param>
/// <param name="host"></param>
/// <param name="name"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
public async Task<bool> ExternalSourceExists(int userId, string name, string host, string apiKey)
{
host = host.Trim();
if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(apiKey)) return false;
var hostWithEndingSlash = UrlHelper.EnsureEndsWithSlash(host)!;
return await _context.AppUserExternalSource
.Where(s => s.AppUserId == userId )
.Where(s => s.Host.ToUpper().Equals(hostWithEndingSlash.ToUpper())
&& s.Name.ToUpper().Equals(name.ToUpper())
&& s.ApiKey.Equals(apiKey))
.AnyAsync();
}
}

View file

@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -11,11 +10,11 @@ using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
using API.DTOs.SideNav;
using API.Entities;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
@ -37,7 +36,9 @@ public enum AppUserIncludes
Devices = 256,
ScrobbleHolds = 512,
SmartFilters = 1024,
DashboardStreams = 2048
DashboardStreams = 2048,
SideNavStreams = 4096,
ExternalSources = 8192 // 2^13
}
public interface IUserRepository
@ -46,10 +47,12 @@ public interface IUserRepository
void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark);
void Update(AppUserDashboardStream stream);
void Update(AppUserSideNavStream stream);
void Add(AppUserBookmark bookmark);
void Delete(AppUser? user);
void Delete(AppUserBookmark bookmark);
void Delete(IList<AppUserDashboardStream> streams);
void Delete(IEnumerable<AppUserDashboardStream> streams);
void Delete(IEnumerable<AppUserSideNavStream> streams);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user);
@ -83,6 +86,11 @@ public interface IUserRepository
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
Task<AppUserSideNavStream?> GetSideNavStream(int streamId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
}
public class UserRepository : IUserRepository
@ -118,6 +126,11 @@ public class UserRepository : IUserRepository
_context.Entry(stream).State = EntityState.Modified;
}
public void Update(AppUserSideNavStream stream)
{
_context.Entry(stream).State = EntityState.Modified;
}
public void Add(AppUserBookmark bookmark)
{
_context.AppUserBookmark.Add(bookmark);
@ -134,11 +147,16 @@ public class UserRepository : IUserRepository
_context.AppUserBookmark.Remove(bookmark);
}
public void Delete(IList<AppUserDashboardStream> streams)
public void Delete(IEnumerable<AppUserDashboardStream> streams)
{
_context.AppUserDashboardStream.RemoveRange(streams);
}
public void Delete(IEnumerable<AppUserSideNavStream> streams)
{
_context.AppUserSideNavStream.RemoveRange(streams);
}
/// <summary>
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
/// </summary>
@ -353,6 +371,89 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false)
{
var sideNavStreams = await _context.AppUserSideNavStream
.Where(d => d.AppUserId == userId)
.WhereIf(visibleOnly, d => d.Visible)
.OrderBy(d => d.Order)
.Include(d => d.SmartFilter)
.Select(d => new SideNavStreamDto()
{
Id = d.Id,
Name = d.Name,
IsProvided = d.IsProvided,
SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id,
SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter,
LibraryId = d.LibraryId ?? 0,
ExternalSourceId = d.ExternalSourceId ?? 0,
StreamType = d.StreamType,
Order = d.Order,
Visible = d.Visible
})
.ToListAsync();
var libraryIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.Library)
.Select(d => d.LibraryId)
.ToList();
var libraryDtos = _context.Library
.Where(l => libraryIds.Contains(l.Id))
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToList();
foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library))
{
dto.Library = libraryDtos.FirstOrDefault(l => l.Id == dto.LibraryId);
}
var externalSourceIds = sideNavStreams.Where(d => d.StreamType == SideNavStreamType.ExternalSource)
.Select(d => d.ExternalSourceId)
.ToList();
var externalSourceDtos = _context.AppUserExternalSource
.Where(l => externalSourceIds.Contains(l.Id))
.ProjectTo<ExternalSourceDto>(_mapper.ConfigurationProvider)
.ToList();
foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.ExternalSource))
{
dto.ExternalSource = externalSourceDtos.FirstOrDefault(l => l.Id == dto.ExternalSourceId);
}
return sideNavStreams;
}
public async Task<AppUserSideNavStream> GetSideNavStream(int streamId)
{
return await _context.AppUserSideNavStream
.Include(d => d.SmartFilter)
.FirstOrDefaultAsync(d => d.Id == streamId);
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId)
{
return await _context.AppUserSideNavStream
.Include(d => d.SmartFilter)
.Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId)
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId)
{
return await _context.AppUserSideNavStream
.Where(d => d.LibraryId == libraryId)
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId)
{
return await _context.AppUserSideNavStream
.Where(d => d.ExternalSourceId == externalSourceId)
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View file

@ -44,7 +44,7 @@ public static class Seed
{
new()
{
Name = "On Deck",
Name = "on-deck",
StreamType = DashboardStreamType.OnDeck,
Order = 0,
IsProvided = true,
@ -52,7 +52,7 @@ public static class Seed
},
new()
{
Name = "Recently Updated",
Name = "recently-updated",
StreamType = DashboardStreamType.RecentlyUpdated,
Order = 1,
IsProvided = true,
@ -60,7 +60,7 @@ public static class Seed
},
new()
{
Name = "Newly Added",
Name = "newly-added",
StreamType = DashboardStreamType.NewlyAdded,
Order = 2,
IsProvided = true,
@ -68,7 +68,7 @@ public static class Seed
},
new()
{
Name = "More In",
Name = "more-in-genre",
StreamType = DashboardStreamType.MoreInGenre,
Order = 3,
IsProvided = true,
@ -76,6 +76,50 @@ public static class Seed
},
}.ToArray());
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams = ImmutableArray.Create(new[]
{
new AppUserSideNavStream()
{
Name = "want-to-read",
StreamType = SideNavStreamType.WantToRead,
Order = 1,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "collections",
StreamType = SideNavStreamType.Collections,
Order = 2,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "reading-lists",
StreamType = SideNavStreamType.ReadingLists,
Order = 3,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "bookmarks",
StreamType = SideNavStreamType.Bookmarks,
Order = 4,
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
{
Name = "all-series",
StreamType = SideNavStreamType.AllSeries,
Order = 5,
IsProvided = true,
Visible = true
}
});
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = typeof(PolicyConstants)
@ -137,6 +181,31 @@ public static class Seed
}
}
public static async Task SeedDefaultSideNavStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams);
foreach (var user in allUsers)
{
if (user.SideNavStreams.Count != 0) continue;
user.SideNavStreams ??= new List<AppUserSideNavStream>();
foreach (var defaultStream in DefaultSideNavStreams)
{
var newStream = new AppUserSideNavStream()
{
Name = defaultStream.Name,
IsProvided = defaultStream.IsProvided,
Order = defaultStream.Order,
StreamType = defaultStream.StreamType,
Visible = defaultStream.Visible,
};
user.SideNavStreams.Add(newStream);
}
unitOfWork.UserRepository.Update(user);
await unitOfWork.CommitAsync();
}
}
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
{
await context.Database.EnsureCreatedAsync();

View file

@ -29,6 +29,7 @@ public interface IUnitOfWork
IScrobbleRepository ScrobbleRepository { get; }
IUserTableOfContentRepository UserTableOfContentRepository { get; }
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -70,6 +71,7 @@ public class UnitOfWork : IUnitOfWork
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.