From 685f7365e1058a18f1665a6c6c6de26f0fd98e5c Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Fri, 2 Feb 2024 17:12:37 -0600 Subject: [PATCH] More Testing (#2683) --- API/Controllers/ReviewController.cs | 19 +++++++ .../ExternalSeriesMetadataRepository.cs | 27 ++++++--- API/Services/Plus/ExternalMetadataService.cs | 4 +- API/Services/ReviewService.cs | 5 +- API/Services/SeriesService.cs | 2 +- UI/Web/src/app/_services/series.service.ts | 11 +++- .../review-card/review-card.component.ts | 23 +++++++- .../review-series-modal.component.html | 1 + .../review-series-modal.component.ts | 29 +++++++++- .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 55 ++++++++++++++----- UI/Web/src/assets/langs/en.json | 3 + openapi.json | 23 +++++++- 13 files changed, 168 insertions(+), 36 deletions(-) diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index a32167086..ae8ce02ee 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -61,4 +61,23 @@ public class ReviewController : BaseApiController _scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body)); return Ok(_mapper.Map(rating)); } + + /// + /// Deletes the user's review for the given series + /// + /// + [HttpDelete] + public async Task DeleteReview(int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings); + if (user == null) return Unauthorized(); + + user.Ratings = user.Ratings.Where(r => r.SeriesId != seriesId).ToList(); + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + + return Ok(); + } } diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index bb2ad6b5f..341997d86 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -132,20 +132,31 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - var seriesDetailPlusDto = new SeriesDetailPlusDto() + IEnumerable reviews = new List(); + if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any()) { - Ratings = seriesDetailDto.ExternalRatings - .DefaultIfEmpty() - .Select(r => _mapper.Map(r)), - Reviews = seriesDetailDto.ExternalReviews - .DefaultIfEmpty() - .OrderByDescending(r => r.Score) + reviews = seriesDetailDto.ExternalReviews .Select(r => { var ret = _mapper.Map(r); ret.IsExternal = true; return ret; - }), + }) + .OrderByDescending(r => r.Score); + } + + IEnumerable ratings = new List(); + if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any()) + { + ratings = seriesDetailDto.ExternalRatings + .Select(r => _mapper.Map(r)); + } + + + var seriesDetailPlusDto = new SeriesDetailPlusDto() + { + Ratings = ratings, + Reviews = reviews, Recommendations = new RecommendationDto() { ExternalSeries = externalSeriesRecommendations, diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 677edf7a8..9dd89a0d0 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -114,7 +114,7 @@ public class ExternalMetadataService : IExternalMetadataService try { var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/series-detail") + var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") .WithHeader("x-license-key", license) @@ -298,7 +298,7 @@ public class ExternalMetadataService : IExternalMetadataService } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/series/detail") + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") .WithHeader("x-license-key", license) diff --git a/API/Services/ReviewService.cs b/API/Services/ReviewService.cs index 0ebdf29b3..69ab784ae 100644 --- a/API/Services/ReviewService.cs +++ b/API/Services/ReviewService.cs @@ -10,7 +10,7 @@ namespace API.Services; public static class ReviewService { - public static IList SelectSpectrumOfReviews(IList reviews) + public static IEnumerable SelectSpectrumOfReviews(IList reviews) { IList externalReviews; var totalReviews = reviews.Count; @@ -42,8 +42,9 @@ public static class ReviewService externalReviews = reviews; } - return externalReviews; + return externalReviews.OrderByDescending(r => r.Score); } + public static string GetCharacters(string body) { if (string.IsNullOrEmpty(body)) return body; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 8bd8a87d8..dcb8b5c66 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -171,7 +171,7 @@ public class SeriesService : ISeriesService } else { hasWebLinksChanged = - series.Metadata.WebLinks.Equals(updateSeriesMetadataDto.SeriesMetadata?.WebLinks); + series.Metadata.WebLinks != updateSeriesMetadataDto.SeriesMetadata?.WebLinks; series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks .Split(",") .Where(s => !string.IsNullOrEmpty(s)) diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 84cda86a9..17f0bf5f5 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -209,16 +209,21 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } - getReviews(seriesId: number) { - return this.httpClient.get>(this.baseUrl + 'review?seriesId=' + seriesId); - } + + deleteReview(seriesId: number) { + return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId); + } updateReview(seriesId: number, body: string) { return this.httpClient.post(this.baseUrl + 'review', { seriesId, body }); } + getReviews(seriesId: number) { + return this.httpClient.get>(this.baseUrl + 'review?seriesId=' + seriesId); + } + getRatings(seriesId: number) { return this.httpClient.get>(this.baseUrl + 'rating?seriesId=' + seriesId); } diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts index fe2b3b22f..7a0b29de2 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts @@ -1,10 +1,23 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; import {CommonModule, NgOptimizedImage} from '@angular/common'; import {UserReview} from "./user-review"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component"; import {AccountService} from "../../_services/account.service"; -import {ReviewSeriesModalComponent} from "../review-series-modal/review-series-modal.component"; +import { + ReviewSeriesModalCloseAction, + ReviewSeriesModalCloseEvent, + ReviewSeriesModalComponent +} from "../review-series-modal/review-series-modal.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {ImageComponent} from "../../shared/image/image.component"; @@ -25,6 +38,7 @@ export class ReviewCardComponent implements OnInit { protected readonly ScrobbleProvider = ScrobbleProvider; @Input({required: true}) review!: UserReview; + @Output() refresh = new EventEmitter(); isMyReview: boolean = false; @@ -48,5 +62,10 @@ export class ReviewCardComponent implements OnInit { } const ref = this.modalService.open(component, {size: 'lg', fullscreen: 'md'}); ref.componentInstance.review = this.review; + ref.closed.subscribe((res: ReviewSeriesModalCloseEvent | undefined) => { + if (res) { + this.refresh.emit(res); + } + }) } } diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html index 9c4053135..90ff4867a 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html @@ -26,6 +26,7 @@ diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts index 5ae6aa215..f7158b27c 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts @@ -12,7 +12,21 @@ import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap'; import { SeriesService } from 'src/app/_services/series.service'; import {UserReview} from "../review-card/user-review"; import {CommonModule} from "@angular/common"; -import {TranslocoDirective} from "@ngneat/transloco"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {ConfirmService} from "../../shared/confirm.service"; +import {ToastrService} from "ngx-toastr"; + +export enum ReviewSeriesModalCloseAction { + Create, + Edit, + Delete, + Close +} +export interface ReviewSeriesModalCloseEvent { + success: boolean, + review: UserReview; + action: ReviewSeriesModalCloseAction +} @Component({ selector: 'app-review-series-modal', @@ -27,6 +41,8 @@ export class ReviewSeriesModalComponent implements OnInit { protected readonly modal = inject(NgbActiveModal); private readonly seriesService = inject(SeriesService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly confirmService = inject(ConfirmService); + private readonly toastr = inject(ToastrService); protected readonly minLength = 5; @Input({required: true}) review!: UserReview; @@ -40,16 +56,23 @@ export class ReviewSeriesModalComponent implements OnInit { } close() { - this.modal.close({success: false, review: null}); + this.modal.close({success: false, review: this.review, action: ReviewSeriesModalCloseAction.Close}); } + async delete() { + if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return; + this.seriesService.deleteReview(this.review.seriesId).subscribe(() => { + this.toastr.success(translate('toasts.review-deleted')); + this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete}); + }); + } save() { const model = this.reviewGroup.value; if (model.reviewBody.length < this.minLength) { return; } this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => { - this.modal.close({success: true, review: review}); + this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit}); }); } } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index c9c641ea1..2436defa7 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -138,7 +138,7 @@ iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}" [clickableTitle]="true" (sectionClick)="openReviewModal()"> - + diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index b8f321a10..3d234c31e 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -4,7 +4,8 @@ import { DOCUMENT, NgClass, NgFor, - NgIf, NgOptimizedImage, + NgIf, + NgOptimizedImage, NgStyle, NgSwitch, NgSwitchCase, @@ -44,7 +45,7 @@ import { } from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {catchError, forkJoin, Observable, of} from 'rxjs'; -import {filter, map, take, tap} from 'rxjs/operators'; +import {map, take} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component'; import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; @@ -75,7 +76,11 @@ import {ReaderService} from 'src/app/_services/reader.service'; import {ReadingListService} from 'src/app/_services/reading-list.service'; import {ScrollService} from 'src/app/_services/scroll.service'; import {SeriesService} from 'src/app/_services/series.service'; -import {ReviewSeriesModalComponent} from '../../../_single-module/review-series-modal/review-series-modal.component'; +import { + ReviewSeriesModalCloseAction, + ReviewSeriesModalCloseEvent, + ReviewSeriesModalComponent +} from '../../../_single-module/review-series-modal/review-series-modal.component'; import {PageLayoutMode} from 'src/app/_models/page-layout-mode'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {UserReview} from "../../../_single-module/review-card/user-review"; @@ -137,7 +142,13 @@ const KavitaPlusSupportedLibraryTypes = [LibraryType.Manga, LibraryType.Book]; styleUrls: ['./series-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe] + imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, + TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, + NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, + NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, + EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, + LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, + NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe] }) export class SeriesDetailComponent implements OnInit, AfterContentChecked { @@ -704,7 +715,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { if (data.ratings) { this.ratings = [...data.ratings]; } - + // Recommendations if (data.recommendations) { @@ -854,18 +865,36 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } modalRef.closed.subscribe((closeResult) => { - // BUG: This never executes! - console.log('Close Result: ') - if (closeResult.success && closeResult.review !== null) { - const index = this.reviews.findIndex(r => r.username === closeResult.review!.username); - console.log('update index: ', index, ' with review ', closeResult.review); - this.reviews[index] = closeResult.review; - this.cdRef.markForCheck(); - } + this.updateOrDeleteReview(closeResult); }); } + updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) { + if (closeResult.action === ReviewSeriesModalCloseAction.Close) return; + + const index = this.reviews.findIndex(r => r.username === closeResult.review!.username); + if (closeResult.action === ReviewSeriesModalCloseAction.Edit) { + if (index === -1 ) { + // A new series was added: + this.reviews = [closeResult.review, ...this.reviews]; + this.cdRef.markForCheck(); + return; + } + // An edit occurred + this.reviews[index] = closeResult.review; + this.cdRef.markForCheck(); + return; + } + + if (closeResult.action === ReviewSeriesModalCloseAction.Delete) { + // An edit occurred + this.reviews = [...this.reviews.filter(r => r.username !== closeResult.review!.username)]; + this.cdRef.markForCheck(); + return; + } + } + performAction(action: ActionItem) { if (typeof action.callback === 'function') { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 660ff2191..f71d0ce6e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -65,6 +65,7 @@ "review-label": "Review", "close": "{{common.close}}", "save": "{{common.save}}", + "delete": "{{common.delete}}", "min-length": "Review must be at least {{count}} characters", "required": "{{validation.required-field}}" }, @@ -2011,6 +2012,8 @@ "mark-unread": "Marked as Unread", "series-removed-want-to-read": "Series removed from Want to Read list", "series-deleted": "Series deleted", + "delete-review": "Are you sure you want to delete your review?", + "review-deleted": "Review deleted", "file-send-to": "File(s) emailed to {{name}}", "theme-missing": "The active theme no longer exists. Please refresh the page.", "email-sent": "Email sent to {{email}}", diff --git a/openapi.json b/openapi.json index b15b30d03..f862c6c88 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.13.15" + "version": "0.7.13.16" }, "servers": [ { @@ -7193,6 +7193,27 @@ } } } + }, + "delete": { + "tags": [ + "Review" + ], + "summary": "Deletes the user's review for the given series", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } } }, "/api/Scrobbling/anilist-token": {